diff --git a/backend/src/Hookline.Infrastructure/Caching/RedisCachePurge.cs b/backend/src/Hookline.Infrastructure/Caching/RedisCachePurge.cs index c1b3040..11182ea 100644 --- a/backend/src/Hookline.Infrastructure/Caching/RedisCachePurge.cs +++ b/backend/src/Hookline.Infrastructure/Caching/RedisCachePurge.cs @@ -1,5 +1,7 @@ using Hookline.SharedKernel.Caching; +using Microsoft.Extensions.Logging; + using StackExchange.Redis; namespace Hookline.Infrastructure.Caching; @@ -10,7 +12,7 @@ namespace Hookline.Infrastructure.Caching; /// runs noeviction but every app key self-expires, so a missed purge only delays cleanup — it must /// never fail a data reset). /// -public sealed class RedisCachePurge(IConnectionMultiplexer redis) : ICachePurge +public sealed class RedisCachePurge(IConnectionMultiplexer redis, ILogger logger) : ICachePurge { public async Task PurgeByPrefixAsync(string prefix, CancellationToken ct = default) { @@ -41,9 +43,10 @@ public async Task PurgeByPrefixAsync(string prefix, CancellationToken ct = } } } - catch (Exception) when (!ct.IsCancellationRequested) + catch (Exception ex) when (!ct.IsCancellationRequested) { // Best-effort: a cache outage must never fail the surrounding data reset. + logger.LogWarning(ex, "Cache purge failed for prefix '{Prefix}'", prefix); } return removed; diff --git a/backend/src/Modules/Hookline.Modules.YouTubeUploads/Endpoints/YouTubeUploadsProviderEndpoints.cs b/backend/src/Modules/Hookline.Modules.YouTubeUploads/Endpoints/YouTubeUploadsProviderEndpoints.cs index 6ed6dab..5b5ce3d 100644 --- a/backend/src/Modules/Hookline.Modules.YouTubeUploads/Endpoints/YouTubeUploadsProviderEndpoints.cs +++ b/backend/src/Modules/Hookline.Modules.YouTubeUploads/Endpoints/YouTubeUploadsProviderEndpoints.cs @@ -212,7 +212,7 @@ private static Task Respond(SlackClient slack, string? responseUrl, string text) private static void MapGoogleEndpoints(IEndpointRouteBuilder app) { app.MapGet("/google/youtube-uploads/oauth/start", async ( - Guid? projectId, GoogleAccountsService oauth, IOptions opt, HttpContext http, CancellationToken ct) => + Guid? projectId, GoogleAccountsService oauth, IOptions opt, HttpContext http, ILoggerFactory loggerFactory, CancellationToken ct) => { var panel = opt.Value.AdminPanelUrl.TrimEnd('/'); if (projectId is null) @@ -221,7 +221,11 @@ private static void MapGoogleEndpoints(IEndpointRouteBuilder app) var state = GenerateState(); string consentUrl; try { consentUrl = await oauth.BuildConsentUrlAsync(projectId.Value, state, ct); } - catch (Exception ex) { return Results.Redirect($"{panel}/connections/google?error={Uri.EscapeDataString(ex.Message)}"); } + catch (Exception ex) + { + loggerFactory.CreateLogger("YouTubeUploads.Google.OAuth").LogError(ex, "Google OAuth start failed for project {ProjectId}", projectId.Value); + return Results.Redirect($"{panel}/connections/google?error={Uri.EscapeDataString(ex.Message)}"); + } http.Response.Cookies.Append(GoogleStateCookie, state, StateCookie(http)); http.Response.Cookies.Append(GoogleClientCookie, projectId.Value.ToString(), StateCookie(http)); diff --git a/backend/src/Modules/Hookline.Modules.YouTubeUploads/Infrastructure/GoogleAccountsService.cs b/backend/src/Modules/Hookline.Modules.YouTubeUploads/Infrastructure/GoogleAccountsService.cs index 2f2ba84..f6b8488 100644 --- a/backend/src/Modules/Hookline.Modules.YouTubeUploads/Infrastructure/GoogleAccountsService.cs +++ b/backend/src/Modules/Hookline.Modules.YouTubeUploads/Infrastructure/GoogleAccountsService.cs @@ -2,6 +2,7 @@ using Hookline.SharedKernel.Connections; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Hookline.Modules.YouTubeUploads.Infrastructure; @@ -35,7 +36,8 @@ public sealed class GoogleAccountsService( YouTubeUploadsDbContext db, IGoogleConnections googleAccounts, YouTubeUploadService youtube, - IQuotaService quota) + IQuotaService quota, + ILogger logger) { private string RedirectUri => options.Value.Google.RedirectUri; @@ -73,7 +75,7 @@ public async Task ExchangeAndStoreAsync(Guid projectId, string code, Cance (channelId, channelTitle, avatarUrl) = await youtube.GetChannelInfoAsync(creds.ClientId, creds.ClientSecret, token.RefreshToken, ct); await quota.ChargeUnitsAsync(projectId, 1); // channels.list ≈ 1 unit against the non-upload pool } - catch { /* channel lookup is best-effort; the account still works for upload */ } + catch (Exception ex) { logger.LogWarning(ex, "Channel lookup failed during OAuth exchange for project {ProjectId}", projectId); } var scopes = string.Join(' ', GoogleCredentialFactory.Scopes); var label = channelTitle ?? "YouTube account"; diff --git a/backend/src/Modules/Hookline.Modules.YouTubeUploads/Infrastructure/JobService.cs b/backend/src/Modules/Hookline.Modules.YouTubeUploads/Infrastructure/JobService.cs index 6a79094..d9a3cdf 100644 --- a/backend/src/Modules/Hookline.Modules.YouTubeUploads/Infrastructure/JobService.cs +++ b/backend/src/Modules/Hookline.Modules.YouTubeUploads/Infrastructure/JobService.cs @@ -3,6 +3,7 @@ using Hookline.SharedKernel.Connections; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; namespace Hookline.Modules.YouTubeUploads.Infrastructure; @@ -71,7 +72,7 @@ public interface IJobService Task<(int UploadsToday, int UploadsLast24h, int ErrorsLast24h)> GetDashboardCountsAsync(CancellationToken ct = default); } -public sealed class JobService(YouTubeUploadsDbContext db, IGoogleConnections googleAccounts, IAuditLog audit) : IJobService +public sealed class JobService(YouTubeUploadsDbContext db, IGoogleConnections googleAccounts, IAuditLog audit, ILogger logger) : IJobService { public async Task CreateAsync(NewJob i, CancellationToken ct = default) { @@ -147,6 +148,7 @@ await audit.WriteAsync( catch (Exception ex) when (ex is not OperationCanceledException) { // swallow: the transition already committed; losing one audit row is not worth failing the job + logger.LogWarning(ex, "Audit write failed for upload.{State} on job {JobId}", to, job.Id); } } diff --git a/backend/src/Modules/Hookline.Modules.YouTubeUploads/Infrastructure/QuotaService.cs b/backend/src/Modules/Hookline.Modules.YouTubeUploads/Infrastructure/QuotaService.cs index f01e0a4..2d047e5 100644 --- a/backend/src/Modules/Hookline.Modules.YouTubeUploads/Infrastructure/QuotaService.cs +++ b/backend/src/Modules/Hookline.Modules.YouTubeUploads/Infrastructure/QuotaService.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StackExchange.Redis; @@ -39,7 +40,7 @@ public interface IQuotaService Task ChargeUnitsAsync(Guid projectId, int units); } -public sealed class QuotaService(IConnectionMultiplexer redis, IOptions options) : IQuotaService +public sealed class QuotaService(IConnectionMultiplexer redis, IOptions options, ILogger logger) : IQuotaService { private readonly YouTubeUploadsOptions _opt = options.Value; @@ -91,6 +92,6 @@ public async Task ChargeUnitsAsync(Guid projectId, int units) if (after == units) // first charge of this PT day -> bound the key's lifetime await db.KeyExpireAsync(key, PacificTime.UntilMidnight() + TimeSpan.FromHours(1)); } - catch { /* best-effort meter — a Redis hiccup must never break the calling request */ } + catch (Exception ex) { logger.LogWarning(ex, "Quota unit charge failed for project {ProjectId}", projectId); } } } diff --git a/backend/src/Modules/Hookline.Modules.YouTubeUploads/Jobs/UploadJobHandler.cs b/backend/src/Modules/Hookline.Modules.YouTubeUploads/Jobs/UploadJobHandler.cs index 52041fb..523f968 100644 --- a/backend/src/Modules/Hookline.Modules.YouTubeUploads/Jobs/UploadJobHandler.cs +++ b/backend/src/Modules/Hookline.Modules.YouTubeUploads/Jobs/UploadJobHandler.cs @@ -217,7 +217,8 @@ await drive.DownloadAsync(driveService, job.DriveFileId, fileOut, bytes => // doesn't strand the project's daily upload bucket on an upload that produced nothing. if (reservedProjectId is not null && job.YouTubeVideoId is null) { - try { await quota.ReleaseUploadAsync(reservedProjectId.Value); } catch { /* best effort */ } + try { await quota.ReleaseUploadAsync(reservedProjectId.Value); } + catch (Exception qex) { logger.LogDebug(qex, "Best-effort quota release failed for project {ProjectId}", reservedProjectId.Value); } } // Before the YouTube upload starts nothing exists on YouTube → re-queue so startup // recovery resumes it from scratch. Once Uploading/Processing it is the point of no @@ -236,12 +237,14 @@ await drive.DownloadAsync(driveService, job.DriveFileId, fileOut, bytes => // failed attempt doesn't burn the project's daily upload bucket (the counter is an estimate). if (reservedProjectId is not null && job.YouTubeVideoId is null) { - try { await quota.ReleaseUploadAsync(reservedProjectId.Value); } catch { /* best effort */ } + try { await quota.ReleaseUploadAsync(reservedProjectId.Value); } + catch (Exception qex) { logger.LogDebug(qex, "Best-effort quota release failed for project {ProjectId}", reservedProjectId.Value); } } // Revoked / wrong-client / unauthorized → flag the account so rotation skips it next time. if (activeAccountId is not null && job.YouTubeVideoId is null && IsAuthError(ex)) { - try { await oauth.MarkAccountErrorAsync(activeAccountId.Value); } catch { /* best effort */ } + try { await oauth.MarkAccountErrorAsync(activeAccountId.Value); } + catch (Exception aex) { logger.LogWarning(aex, "Best-effort MarkAccountError failed for account {AccountId}", activeAccountId.Value); } } await FailAsync(job, Summarize(ex)); await status.RefreshQueueAsync(CancellationToken.None); diff --git a/backend/tests/Hookline.Modules.YouTubeUploads.Tests/JobAuditTests.cs b/backend/tests/Hookline.Modules.YouTubeUploads.Tests/JobAuditTests.cs index 4eb360d..1790b4b 100644 --- a/backend/tests/Hookline.Modules.YouTubeUploads.Tests/JobAuditTests.cs +++ b/backend/tests/Hookline.Modules.YouTubeUploads.Tests/JobAuditTests.cs @@ -4,6 +4,7 @@ using Hookline.SharedKernel.Secrets; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; namespace Hookline.Modules.YouTubeUploads.Tests; @@ -62,7 +63,7 @@ public async Task Cancel_while_queued_transition_emits_an_audit_entry_and_keeps_ using var db = NewDb(); var audit = new RecordingAuditLog(); // googleAccounts is unused by TransitionAsync — only the db + audit collaborators matter here. - var jobs = new JobService(db, googleAccounts: null!, audit); + var jobs = new JobService(db, googleAccounts: null!, audit, NullLogger.Instance); var job = NewQueuedJob(); db.Jobs.Add(job); @@ -91,7 +92,7 @@ public async Task Transition_action_name_tracks_the_target_state(JobState to, st { using var db = NewDb(); var audit = new RecordingAuditLog(); - var jobs = new JobService(db, googleAccounts: null!, audit); + var jobs = new JobService(db, googleAccounts: null!, audit, NullLogger.Instance); var job = NewQueuedJob(); db.Jobs.Add(job); diff --git a/backend/tests/Hookline.Modules.YouTubeUploads.Tests/QuotaRotationTests.cs b/backend/tests/Hookline.Modules.YouTubeUploads.Tests/QuotaRotationTests.cs index 9e5b777..ad0727f 100644 --- a/backend/tests/Hookline.Modules.YouTubeUploads.Tests/QuotaRotationTests.cs +++ b/backend/tests/Hookline.Modules.YouTubeUploads.Tests/QuotaRotationTests.cs @@ -2,6 +2,7 @@ using Hookline.Modules.YouTubeUploads.Infrastructure; using Hookline.Modules.YouTubeUploads.Jobs; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; namespace Hookline.Modules.YouTubeUploads.Tests; @@ -55,7 +56,7 @@ public void UnitMeterIsIndependentOfUploads() public async Task GetStatusNullProjectReportsZeroCap() { // An unbound account (null project) must not look like it has a full daily quota available. - var quota = new QuotaService(null!, Options.Create(new YouTubeUploadsOptions())); + var quota = new QuotaService(null!, Options.Create(new YouTubeUploadsOptions()), NullLogger.Instance); var status = await quota.GetStatusAsync(null); Assert.Equal(0, status.UploadLimit); Assert.Equal(0, status.CapUnits);