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);