Add Serilog console logging across the API

Information at endpoint and service-method boundaries, Debug in deeper
helpers, Warning before expected/recoverable failures (not-found,
validation, agent tool errors), Error on caught exceptions. Serilog
wraps the exception handler so request-completion logs report the
resolved status code rather than the raw exception. Never logs prose
bodies or the Anthropic API key.
This commit is contained in:
James Wampler
2026-08-06 12:11:20 -07:00
parent 4f396bb5f9
commit 04917fa09e
34 changed files with 790 additions and 147 deletions
+52 -10
View File
@@ -11,10 +11,12 @@ namespace Novelly.Api.Beats;
/// Beats are a chapter's outline: a flat, ordered table rather than a tree. Everything
/// here is scoped to one chapter.
/// </summary>
public class BeatService(INovelDbContext db, TagService tags)
public class BeatService(INovelDbContext db, TagService tags, ILogger<BeatService> logger)
{
public async Task<IReadOnlyList<BeatDto>> ListAsync(Guid chapterId, CancellationToken ct = default)
{
logger.LogInformation("Listing beats for chapter {ChapterId}", chapterId);
var beats = await Query()
.Where(b => b.ChapterId == chapterId)
.OrderBy(b => b.SortOrder)
@@ -23,8 +25,11 @@ public class BeatService(INovelDbContext db, TagService tags)
return [.. beats.Select(b => b.ToDto())];
}
public async Task<BeatDto> GetAsync(Guid id, CancellationToken ct = default) =>
(await FindAsync(id, ct)).ToDto();
public async Task<BeatDto> GetAsync(Guid id, CancellationToken ct = default)
{
logger.LogInformation("Getting beat {BeatId}", id);
return (await FindAsync(id, ct)).ToDto();
}
/// <summary>
/// Every beat this character appears in, in manuscript order. This is the character
@@ -34,8 +39,11 @@ public class BeatService(INovelDbContext db, TagService tags)
public async Task<IReadOnlyList<CharacterBeatDto>> ListForCharacterAsync(
Guid characterId, CancellationToken ct = default)
{
logger.LogInformation("Listing beats for character {CharacterId}", characterId);
if (!await db.Characters.AnyAsync(c => c.Id == characterId, ct))
{
logger.LogWarning("Character {CharacterId} not found", characterId);
throw new NotFoundException(nameof(Character), characterId);
}
@@ -66,8 +74,14 @@ public class BeatService(INovelDbContext db, TagService tags)
public async Task<BeatDto> CreateAsync(Guid chapterId, CreateBeatRequest request, CancellationToken ct = default)
{
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct)
?? throw new NotFoundException(nameof(Chapter), chapterId);
logger.LogInformation("Creating beat {Title} for chapter {ChapterId}", request.Title, chapterId);
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct);
if (chapter is null)
{
logger.LogWarning("Chapter {ChapterId} not found", chapterId);
throw new NotFoundException(nameof(Chapter), chapterId);
}
await ValidateReferencesAsync(chapter, request.CharacterId, request.SceneId, ct);
@@ -94,9 +108,15 @@ public class BeatService(INovelDbContext db, TagService tags)
public async Task<BeatDto> UpdateAsync(Guid id, UpdateBeatRequest request, CancellationToken ct = default)
{
logger.LogInformation("Updating beat {BeatId}", id);
var beat = await FindAsync(id, ct);
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct)
?? throw new NotFoundException(nameof(Chapter), beat.ChapterId);
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct);
if (chapter is null)
{
logger.LogWarning("Chapter {ChapterId} not found", beat.ChapterId);
throw new NotFoundException(nameof(Chapter), beat.ChapterId);
}
await ValidateReferencesAsync(chapter, request.CharacterId, request.SceneId, ct);
@@ -119,6 +139,8 @@ public class BeatService(INovelDbContext db, TagService tags)
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{
logger.LogInformation("Deleting beat {BeatId}", id);
var beat = await FindAsync(id, ct);
db.Beats.Remove(beat);
await db.SaveChangesAsync(ct);
@@ -131,11 +153,14 @@ public class BeatService(INovelDbContext db, TagService tags)
public async Task<IReadOnlyList<BeatDto>> ReorderAsync(
Guid chapterId, ReorderBeatsRequest request, CancellationToken ct = default)
{
logger.LogInformation("Reordering {Count} beats for chapter {ChapterId}", request.BeatIds.Count, chapterId);
var beats = await db.Beats.Where(b => b.ChapterId == chapterId).ToListAsync(ct);
var missing = request.BeatIds.Where(id => beats.All(b => b.Id != id)).ToList();
if (missing.Count > 0)
{
logger.LogWarning("Reorder for chapter {ChapterId} referenced missing beat {BeatId}", chapterId, missing[0]);
throw new NotFoundException(nameof(Beat), missing[0]);
}
@@ -159,6 +184,8 @@ public class BeatService(INovelDbContext db, TagService tags)
private async Task ValidateReferencesAsync(
Chapter chapter, Guid? characterId, Guid? sceneId, CancellationToken ct)
{
logger.LogDebug("Validating beat references for chapter {ChapterId}: character {CharacterId}, scene {SceneId}", chapter.Id, characterId, sceneId);
if (characterId is { } cid)
{
var belongs = await db.Characters
@@ -166,6 +193,7 @@ public class BeatService(INovelDbContext db, TagService tags)
if (!belongs)
{
logger.LogWarning("Rejected beat reference: character {CharacterId} does not belong to project {ProjectId}", cid, chapter.ProjectId);
throw new InvalidOperationException(
"A beat's character must belong to the same project as its chapter.");
}
@@ -177,6 +205,7 @@ public class BeatService(INovelDbContext db, TagService tags)
if (!belongs)
{
logger.LogWarning("Rejected beat reference: scene {SceneId} does not belong to chapter {ChapterId}", sid, chapter.Id);
throw new InvalidOperationException(
"A beat can only be grouped under a scene in the same chapter.");
}
@@ -185,6 +214,8 @@ public class BeatService(INovelDbContext db, TagService tags)
private async Task<int> NextSortOrderAsync(Guid chapterId, CancellationToken ct)
{
logger.LogDebug("Computing next sort order for chapter {ChapterId}", chapterId);
var max = await db.Beats
.Where(b => b.ChapterId == chapterId)
.MaxAsync(b => (int?)b.SortOrder, ct);
@@ -198,7 +229,18 @@ public class BeatService(INovelDbContext db, TagService tags)
.Include(b => b.Scene)
.Include(b => b.Tags);
private async Task<Beat> FindAsync(Guid id, CancellationToken ct) =>
await Query().FirstOrDefaultAsync(b => b.Id == id, ct)
?? throw new NotFoundException(nameof(Beat), id);
private async Task<Beat> FindAsync(Guid id, CancellationToken ct)
{
logger.LogDebug("Finding beat {BeatId}", id);
var beat = await Query().FirstOrDefaultAsync(b => b.Id == id, ct);
if (beat is null)
{
logger.LogWarning("Beat {BeatId} not found", id);
throw new NotFoundException(nameof(Beat), id);
}
logger.LogDebug("Found beat {BeatId}", id);
return beat;
}
}