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
@@ -13,10 +13,12 @@ namespace Novelly.Api.Characters;
/// on a supporting character. Demoting someone should not delete work, and a character
/// who turns out to matter gets promoted after the arc is already sketched.
/// </remarks>
public class CharacterArcService(INovelDbContext db)
public class CharacterArcService(INovelDbContext db, ILogger<CharacterArcService> logger)
{
public async Task<IReadOnlyList<ArcStageDto>> ListAsync(Guid characterId, CancellationToken ct = default)
{
logger.LogInformation("Listing arc stages for character {CharacterId}", characterId);
var stages = await Query()
.Where(s => s.CharacterId == characterId)
.OrderBy(s => s.SortOrder)
@@ -25,14 +27,23 @@ public class CharacterArcService(INovelDbContext db)
return [.. stages.Select(s => s.ToDto())];
}
public async Task<ArcStageDto> GetAsync(Guid id, CancellationToken ct = default) =>
(await FindAsync(id, ct)).ToDto();
public async Task<ArcStageDto> GetAsync(Guid id, CancellationToken ct = default)
{
logger.LogInformation("Getting arc stage {ArcStageId}", id);
return (await FindAsync(id, ct)).ToDto();
}
public async Task<ArcStageDto> CreateAsync(
Guid characterId, CreateArcStageRequest request, CancellationToken ct = default)
{
var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == characterId, ct)
?? throw new NotFoundException(nameof(Character), characterId);
logger.LogInformation("Creating arc stage {Title} for character {CharacterId}", request.Title, characterId);
var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == characterId, ct);
if (character is null)
{
logger.LogWarning("Character {CharacterId} not found", characterId);
throw new NotFoundException(nameof(Character), characterId);
}
await EnsureChapterIsInSameProjectAsync(character, request.ChapterId, ct);
@@ -53,10 +64,16 @@ public class CharacterArcService(INovelDbContext db)
public async Task<ArcStageDto> UpdateAsync(
Guid id, UpdateArcStageRequest request, CancellationToken ct = default)
{
logger.LogInformation("Updating arc stage {ArcStageId}", id);
var stage = await FindAsync(id, ct);
var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == stage.CharacterId, ct)
?? throw new NotFoundException(nameof(Character), stage.CharacterId);
var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == stage.CharacterId, ct);
if (character is null)
{
logger.LogWarning("Character {CharacterId} not found", stage.CharacterId);
throw new NotFoundException(nameof(Character), stage.CharacterId);
}
await EnsureChapterIsInSameProjectAsync(character, request.ChapterId, ct);
@@ -72,6 +89,8 @@ public class CharacterArcService(INovelDbContext db)
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{
logger.LogInformation("Deleting arc stage {ArcStageId}", id);
var stage = await FindAsync(id, ct);
db.CharacterArcStages.Remove(stage);
await db.SaveChangesAsync(ct);
@@ -84,6 +103,8 @@ public class CharacterArcService(INovelDbContext db)
public async Task<IReadOnlyList<ArcStageDto>> ReorderAsync(
Guid characterId, ReorderArcStagesRequest request, CancellationToken ct = default)
{
logger.LogInformation("Reordering {Count} arc stages for character {CharacterId}", request.StageIds.Count, characterId);
var stages = await db.CharacterArcStages
.Where(s => s.CharacterId == characterId)
.ToListAsync(ct);
@@ -91,6 +112,7 @@ public class CharacterArcService(INovelDbContext db)
var missing = request.StageIds.Where(id => stages.All(s => s.Id != id)).ToList();
if (missing.Count > 0)
{
logger.LogWarning("Reorder for character {CharacterId} referenced missing arc stage {ArcStageId}", characterId, missing[0]);
throw new NotFoundException(nameof(CharacterArcStage), missing[0]);
}
@@ -117,10 +139,13 @@ public class CharacterArcService(INovelDbContext db)
return;
}
logger.LogDebug("Checking chapter {ChapterId} belongs to project {ProjectId}", id, character.ProjectId);
var belongs = await db.Chapters.AnyAsync(c => c.Id == id && c.ProjectId == character.ProjectId, ct);
if (!belongs)
{
logger.LogWarning("Rejected arc stage: chapter {ChapterId} does not belong to project {ProjectId}", id, character.ProjectId);
throw new InvalidOperationException(
"An arc stage can only point at a chapter in the same project as its character.");
}
@@ -128,6 +153,8 @@ public class CharacterArcService(INovelDbContext db)
private async Task<int> NextSortOrderAsync(Guid characterId, CancellationToken ct)
{
logger.LogDebug("Computing next sort order for character {CharacterId}", characterId);
var max = await db.CharacterArcStages
.Where(s => s.CharacterId == characterId)
.MaxAsync(s => (int?)s.SortOrder, ct);
@@ -137,7 +164,18 @@ public class CharacterArcService(INovelDbContext db)
private IQueryable<CharacterArcStage> Query() => db.CharacterArcStages.Include(s => s.Chapter);
private async Task<CharacterArcStage> FindAsync(Guid id, CancellationToken ct) =>
await Query().FirstOrDefaultAsync(s => s.Id == id, ct)
?? throw new NotFoundException(nameof(CharacterArcStage), id);
private async Task<CharacterArcStage> FindAsync(Guid id, CancellationToken ct)
{
logger.LogDebug("Finding arc stage {ArcStageId}", id);
var stage = await Query().FirstOrDefaultAsync(s => s.Id == id, ct);
if (stage is null)
{
logger.LogWarning("CharacterArcStage {ArcStageId} not found", id);
throw new NotFoundException(nameof(CharacterArcStage), id);
}
logger.LogDebug("Found arc stage {ArcStageId}", id);
return stage;
}
}