Added logging.

This commit is contained in:
2025-10-20 23:32:38 -04:00
parent e0e8945728
commit 3a115bc7b8
18 changed files with 381 additions and 64 deletions

View File

@@ -1,14 +1,33 @@
using JSMR.Application.Common.Search;
using JSMR.Application.Creators.Queries.Search;
using JSMR.Application.Logging;
using Microsoft.Extensions.Logging;
using System.Diagnostics;
namespace JSMR.Application.Circles.Queries.Search;
public sealed class SearchCirclesHandler(ICircleSearchProvider searchProvider)
public sealed class SearchCirclesHandler(ICircleSearchProvider searchProvider, ILogger<SearchCreatorsHandler> logger)
{
public async Task<SearchCirclesResponse> HandleAsync(SearchCirclesRequest request, CancellationToken cancellationToken)
{
SearchOptions<CircleSearchCriteria, CircleSortField> searchOptions = request.Options;
Stopwatch stopWatch = Stopwatch.StartNew();
LogEvents.SearchStart(logger, searchOptions.PageNumber, searchOptions.PageSize, searchOptions.Criteria);
SearchResult<CircleSearchItem> results = await searchProvider.SearchAsync(searchOptions, cancellationToken);
long elapsedMilliseconds = stopWatch.ElapsedMilliseconds;
LogEvents.SearchCompleted(
logger,
Elapsed: elapsedMilliseconds,
Items: results.Items.Length,
Total: results.TotalItems,
Page: searchOptions.PageNumber,
Size: searchOptions.PageSize,
Sort: searchOptions.SortOptions.ToLogObject(),
Criteria: searchOptions.Criteria.ToLogObject()
);
return new SearchCirclesResponse(results);
}

View File

@@ -1,31 +1,32 @@
using JSMR.Application.Common.Caching;
using JSMR.Application.Common.Search;
using JSMR.Application.Common.Search;
using JSMR.Application.Creators.Queries.Search.Contracts;
using JSMR.Application.Creators.Queries.Search.Ports;
using JSMR.Application.Logging;
using Microsoft.Extensions.Logging;
using System.Diagnostics;
namespace JSMR.Application.Creators.Queries.Search;
public sealed class SearchCreatorsHandler(ICreatorSearchProvider searchProvider, ICache cache)
public sealed class SearchCreatorsHandler(ICreatorSearchProvider searchProvider, ILogger<SearchCreatorsHandler> logger)
{
public async Task<SearchCreatorsResponse> HandleAsync(SearchCreatorsRequest request, CancellationToken cancellationToken)
{
SearchOptions<CreatorSearchCriteria, CreatorSortField> searchOptions = request.Options;
string cacheKey = $"creator:{searchOptions.GetHashCode()}";
CreatorSearchResults? cachedResults = await cache.GetAsync<CreatorSearchResults>(cacheKey, cancellationToken);
if (cachedResults != null)
return new SearchCreatorsResponse(cachedResults);
Stopwatch stopWatch = Stopwatch.StartNew();
SearchResult<CreatorSearchItem> results = await searchProvider.SearchAsync(searchOptions, cancellationToken);
long elapsedMilliseconds = stopWatch.ElapsedMilliseconds;
CacheEntryOptions cacheEntryOptions = new()
{
SlidingExpiration = TimeSpan.FromMinutes(10)
};
await cache.SetAsync(cacheKey, results, cacheEntryOptions, cancellationToken);
LogEvents.SearchCompleted(
logger,
Elapsed: elapsedMilliseconds,
Items: results.Items.Length,
Total: results.TotalItems,
Page: searchOptions.PageNumber,
Size: searchOptions.PageSize,
Sort: searchOptions.SortOptions.ToLogObject(),
Criteria: searchOptions.Criteria.ToLogObject()
);
return new SearchCreatorsResponse(results);
}

View File

@@ -12,6 +12,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,51 @@
using JSMR.Application.Circles.Queries.Search;
using JSMR.Application.Creators.Queries.Search.Contracts;
using JSMR.Application.Tags.Queries.Search.Contracts;
using JSMR.Application.VoiceWorks.Queries.Search;
namespace JSMR.Application.Logging;
public static class CriteriaLoggingExtensions
{
//public static object ToLogObject(this VoiceWorkSearchCriteria criteria)
//{
// return new LogObjectBuilder()
// .AddIfNotEmpty("Keywords", criteria.Keywords)
// .AddIfNotEmpty("Title", criteria.Title)
// .AddIfNotEmpty("Circle", criteria.Circle)
// .Add("Locale", criteria.Locale)
// .AddIfNotEmpty("AgeRatings", criteria.AgeRatings)
// .AddIfNotEmpty("Languages", criteria.SupportedLanguages)
// .AddIfNotEmpty("TagIds", criteria.TagIds, preview: 5)
// .AddIfNotEmpty("CreatorIds", criteria.CreatorIds, preview: 5)
// .Add("IncludeAllTags", criteria.IncludeAllTags ? true : null)
// .Add("IncludeAllCreators", criteria.IncludeAllCreators ? true : null)
// .Add("MinDownloads", criteria.MinDownloads)
// .Add("MaxDownloads", criteria.MaxDownloads)
// .Add("ReleaseDateStart", criteria.ReleaseDateStart)
// .Add("ReleaseDateEnd", criteria.ReleaseDateEnd)
// .Build();
//}
//public static object ToLogObject(this CircleSearchCriteria criteria)
//{
// return new LogObjectBuilder()
// .AddIfNotEmpty("Name", criteria.Name)
// .AddIfNotEmpty("Status", criteria.Status?.ToString())
// .Build();
//}
//public static object ToLogObject(this TagSearchCriteria criteria)
//{
// return new LogObjectBuilder()
// .AddIfNotEmpty("Name", criteria.Name)
// .Build();
//}
//public static object ToLogObject(this CreatorSearchCriteria criteria)
//{
// return new LogObjectBuilder()
// .AddIfNotEmpty("Name", criteria.Name)
// .Build();
//}
}

View File

@@ -0,0 +1,59 @@
using Microsoft.Extensions.Logging;
namespace JSMR.Application.Logging;
public static partial class LogEvents
{
public static void SearchCompleted(ILogger logger, long Elapsed, int Items, int Total, int Page, int Size, object Sort, object Criteria)
{
logger.LogInformation(
new EventId(1000, "SearchCompleted"),
"Search returned {Items} items (out of {Total} total) results in {Elapsed} ms on page {Page} with Size {Size}, sorted by {@Sort}, based on the following criteria: {@Criteria}",
Items,
Total,
Elapsed,
Page,
Size,
Sort,
Criteria
);
}
// Search
[LoggerMessage(EventId = 1000, Level = LogLevel.Information,
Message = "Search started Page={Page} Size={Size} Criteria={@Criteria}")]
public static partial void SearchStart(ILogger logger, int Page, int Size, object Criteria);
//[LoggerMessage(EventId = 1001, Level = LogLevel.Information,
// Message = "Search completed in {ElapsedMs} ms, Total={Total}")]
//public static partial void SearchComplete(ILogger logger, long ElapsedMs, int Total);
[LoggerMessage(EventId = 1001, Level = LogLevel.Information,
Message = "Search returned {Items} items (out of {Total} total) results in {Elapsed} ms on page {Page} with Size {Size}, sorted by {@Sort}, based on the following criteria: {@Criteria}")]
public static partial void SearchComplete(ILogger logger, long Elapsed, int Items, int Total, int Page, int Size, object Sort, object Criteria);
[LoggerMessage(EventId = 1002, Level = LogLevel.Warning,
Message = "External search provider timeout after {ElapsedMs} ms")]
public static partial void ExternalSearchTimeout(ILogger logger, long ElapsedMs);
[LoggerMessage(EventId = 1003, Level = LogLevel.Warning,
Message = "Slow Search Detected: Elapsed {ElapsedMs} ms (threshold {ThresholdMs} ms) Path={Path}")]
public static partial void SlowSearchDetected(ILogger logger, long ElapsedMs, long ThresholdMs, string Path);
[LoggerMessage(EventId = 1004, Level = LogLevel.Error,
Message = "Very Slow Search Detected: Elapsed {ElapsedMs} ms (threshold {ThresholdMs} ms) Path={Path}")]
public static partial void VerySlowSearchDetected(ILogger logger, long ElapsedMs, long ThresholdMs, string Path);
// Worker scan batch
[LoggerMessage(EventId = 2000, Level = LogLevel.Information,
Message = "Scan batch {BatchId} started")]
public static partial void ScanBatchStart(ILogger logger, string BatchId);
[LoggerMessage(EventId = 2001, Level = LogLevel.Information,
Message = "Scan batch {BatchId} completed in {ElapsedMs} ms. Processed={Processed} Failures={Failures}")]
public static partial void ScanBatchComplete(ILogger logger, string BatchId, int Processed, int Failures, long ElapsedMs);
[LoggerMessage(EventId = 2002, Level = LogLevel.Error,
Message = "Scan batch {BatchId} failed")]
public static partial void ScanBatchFailed(ILogger logger, string BatchId, Exception ex);
}

View File

@@ -0,0 +1,36 @@
namespace JSMR.Application.Logging;
public sealed class LogObjectBuilder
{
private readonly Dictionary<string, object> _d = [];
public LogObjectBuilder Add(string key, object? value)
{
if (value is null)
return this;
_d[key] = value;
return this;
}
public LogObjectBuilder AddIfNotEmpty(string key, string? value)
=> string.IsNullOrWhiteSpace(value) ? this : Add(key, value);
public LogObjectBuilder AddIfNotEmpty<T>(string key, IReadOnlyCollection<T>? value, int? preview = null)
{
if (value is null || value.Count == 0)
return this;
if (preview is null || value.Count <= preview)
return Add(key, value);
// Store small preview + count so logs stay compact
return Add(key, new { Preview = value.Take(preview.Value), value.Count });
}
public LogObjectBuilder AddIfNotDefault<T>(string key, T value) where T : struct, IEquatable<T>
=> value.Equals(default) ? this : Add(key, value);
public object Build() => _d;
}

View File

@@ -0,0 +1,87 @@
using JSMR.Application.Circles.Queries.Search;
using JSMR.Application.Common.Search;
using JSMR.Application.Creators.Queries.Search.Contracts;
using JSMR.Application.Tags.Queries.Search.Contracts;
using JSMR.Application.VoiceWorks.Queries.Search;
namespace JSMR.Application.Logging;
public static class LoggingExtensions
{
public static object ToLogObject<TCriteria, TSortField>(this SearchOptions<TCriteria, TSortField> searchOptions)
where TCriteria : new()
where TSortField : struct, Enum
{
return new LogObjectBuilder()
.Add("PageNumber", searchOptions.PageNumber)
.Add("PageSize", searchOptions.PageSize)
.Add("Criteria", MapCriteriaToLogObject(searchOptions.Criteria))
//.AddIfNotEmpty("Sort", searchOptions.SortOptions.ToLogObject())
.Build();
}
private static object MapCriteriaToLogObject<TCriteria>(TCriteria criteria) => criteria switch
{
VoiceWorkSearchCriteria voiceWorkSearchCriteria => voiceWorkSearchCriteria.ToLogObject(),
CircleSearchCriteria circleSearchCriteria => circleSearchCriteria.ToLogObject(),
TagSearchCriteria tagSearchCriteria => tagSearchCriteria.ToLogObject(),
CreatorSearchCriteria creatorSearchCriteria => creatorSearchCriteria.ToLogObject(),
_ => criteria!
};
public record SortLog(int Index, string Field, string Direction);
public static object ToLogObject<TSortField>(this IEnumerable<SortOption<TSortField>> sort)
where TSortField : struct, Enum
{
return sort.Select((sortOption, index) =>
new {
Index = index,
Field = sortOption.Field.ToString(),
Direction = sortOption.Direction.ToString()
}
).ToList();
}
public static object ToLogObject(this VoiceWorkSearchCriteria criteria)
{
return new LogObjectBuilder()
.AddIfNotEmpty("Keywords", criteria.Keywords)
.AddIfNotEmpty("Title", criteria.Title)
.AddIfNotEmpty("Circle", criteria.Circle)
.Add("Locale", criteria.Locale)
.AddIfNotEmpty("AgeRatings", criteria.AgeRatings)
.AddIfNotEmpty("Languages", criteria.SupportedLanguages)
.AddIfNotEmpty("TagIds", criteria.TagIds, preview: 5)
.AddIfNotEmpty("CreatorIds", criteria.CreatorIds, preview: 5)
.Add("IncludeAllTags", criteria.IncludeAllTags ? true : null)
.Add("IncludeAllCreators", criteria.IncludeAllCreators ? true : null)
.Add("MinDownloads", criteria.MinDownloads)
.Add("MaxDownloads", criteria.MaxDownloads)
.Add("ReleaseDateStart", criteria.ReleaseDateStart)
.Add("ReleaseDateEnd", criteria.ReleaseDateEnd)
.Build();
}
public static object ToLogObject(this CircleSearchCriteria criteria)
{
return new LogObjectBuilder()
.AddIfNotEmpty("Name", criteria.Name)
.AddIfNotEmpty("Status", criteria.Status?.ToString())
.Build();
}
public static object ToLogObject(this TagSearchCriteria criteria)
{
return new LogObjectBuilder()
.AddIfNotEmpty("Name", criteria.Name)
.Build();
}
public static object ToLogObject(this CreatorSearchCriteria criteria)
{
return new LogObjectBuilder()
.AddIfNotEmpty("Name", criteria.Name)
.Build();
}
}

View File

@@ -38,7 +38,7 @@ public sealed class ScanVoiceWorksHandler(
string[] productIds = [.. works.Where(x => !string.IsNullOrWhiteSpace(x.ProductId)).Select(x => x.ProductId!)];
VoiceWorkDetailCollection voiceWorkDetails = await dlsiteClient.GetVoiceWorkDetailsAsync(productIds, cancellationToken);
List<VoiceWorkIngest> ingests = [.. works.Select(work =>
VoiceWorkIngest[] ingests = [.. works.Select(work =>
{
voiceWorkDetails.TryGetValue(work.ProductId!, out VoiceWorkDetails? value);
return new VoiceWorkIngest(work, value);

View File

@@ -1,31 +1,32 @@
using JSMR.Application.Common.Caching;
using JSMR.Application.Common.Search;
using JSMR.Application.Common.Search;
using JSMR.Application.Logging;
using JSMR.Application.Tags.Queries.Search.Contracts;
using JSMR.Application.Tags.Queries.Search.Ports;
using Microsoft.Extensions.Logging;
using System.Diagnostics;
namespace JSMR.Application.Tags.Queries.Search;
public sealed class SearchTagsHandler(ITagSearchProvider searchProvider)
public sealed class SearchTagsHandler(ITagSearchProvider searchProvider, ILogger<SearchTagsHandler> logger)
{
public async Task<SearchTagsResponse> HandleAsync(SearchTagsRequest request, CancellationToken cancellationToken)
{
SearchOptions<TagSearchCriteria, TagSortField> searchOptions = request.Options;
//string cacheKey = $"tag:{searchOptions.GetHashCode()}";
//TagSearchResults? cachedResults = await cache.GetAsync<TagSearchResults>(cacheKey, cancellationToken);
//if (cachedResults != null)
// return new SearchTagsResponse(cachedResults);
Stopwatch stopWatch = Stopwatch.StartNew();
SearchResult<TagSearchItem> results = await searchProvider.SearchAsync(searchOptions, cancellationToken);
long elapsedMilliseconds = stopWatch.ElapsedMilliseconds;
//CacheEntryOptions cacheEntryOptions = new()
//{
// SlidingExpiration = TimeSpan.FromMinutes(10)
//};
//await cache.SetAsync(cacheKey, results, cacheEntryOptions, cancellationToken);
LogEvents.SearchCompleted(
logger,
Elapsed: elapsedMilliseconds,
Items: results.Items.Length,
Total: results.TotalItems,
Page: searchOptions.PageNumber,
Size: searchOptions.PageSize,
Sort: searchOptions.SortOptions.ToLogObject(),
Criteria: searchOptions.Criteria.ToLogObject()
);
return new SearchTagsResponse(results);
}

View File

@@ -1,30 +1,31 @@
using JSMR.Application.Common.Caching;
using JSMR.Application.VoiceWorks.Ports;
using JSMR.Application.Common.Search;
using JSMR.Application.Logging;
using Microsoft.Extensions.Logging;
using System.Diagnostics;
namespace JSMR.Application.VoiceWorks.Queries.Search;
// TODO: Caching?
public sealed class SearchVoiceWorksHandler(IVoiceWorkSearchProvider provider)
public sealed class SearchVoiceWorksHandler(IVoiceWorkSearchProvider provider, ILogger<SearchVoiceWorksHandler> logger)
{
public async Task<SearchVoiceWorksResponse> HandleAsync(SearchVoiceWorksRequest request, CancellationToken cancellationToken)
{
var searchOptions = request.Options;
//string cacheKey = $"vw:{searchOptions.GetHashCode()}";
Stopwatch stopWatch = Stopwatch.StartNew();
SearchResult<VoiceWorkSearchResult> results = await provider.SearchAsync(searchOptions, cancellationToken);
long elapsedMilliseconds = stopWatch.ElapsedMilliseconds;
//VoiceWorkSearchResults? cachedResults = await cache.GetAsync<VoiceWorkSearchResults>(cacheKey, cancellationToken);
//if (cachedResults != null)
// return new SearchVoiceWorksResponse(cachedResults);
var results = await provider.SearchAsync(searchOptions, cancellationToken);
//CacheEntryOptions cacheEntryOptions = new()
//{
// SlidingExpiration = TimeSpan.FromMinutes(10)
//};
//await cache.SetAsync(cacheKey, results, cacheEntryOptions, cancellationToken);
LogEvents.SearchCompleted(
logger,
Elapsed: elapsedMilliseconds,
Items: results.Items.Length,
Total: results.TotalItems,
Page: searchOptions.PageNumber,
Size: searchOptions.PageSize,
Sort: searchOptions.SortOptions.ToLogObject(),
Criteria: searchOptions.Criteria.ToLogObject()
);
return new SearchVoiceWorksResponse(results);
}

View File

@@ -16,8 +16,8 @@ public class VoiceWorkSearchCriteria
public int[] CreatorIds { get; init; } = [];
public bool IncludeAllCreators { get; init; }
public Locale Locale { get; init; } = Locale.Japanese;
public DateTime? ReleaseDateStart { get; init; }
public DateTime? ReleaseDateEnd { get; init; }
public DateOnly? ReleaseDateStart { get; init; }
public DateOnly? ReleaseDateEnd { get; init; }
public AgeRating[] AgeRatings { get; init; } = [];
public Language[] SupportedLanguages { get; init; } = [];
public AIGeneration[] AIGenerationOptions { get; init; } = [];