10

Lab 10 - Many-to-Many, Middleware și Serilog

Partea 3 din 4

Parte 3 — Serilog: logging structurat în fișier

Până în Partea 2, log-urile zboară doar în consolă. La oprirea aplicației se pierd. Pentru orice deploy real e nevoie de persistență: fișiere rolling pe zi, sau sink-uri către sisteme dedicate (Seq, Elasticsearch). Serilog e standardul de-facto pentru asta.

Pasul 15 — Pachetele (deja preinstalate)

În Lab10.csproj aveți deja:

<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />

Roluri:

Pachet Ce face
Serilog.AspNetCore Integrarea cu host-ul ASP.NET Core (UseSerilog) + RequestLoggingMiddleware (opțional)
Serilog.Sinks.Console Sink către stdout cu coloring și template configurabil
Serilog.Sinks.File Sink către fișier, cu rolling pe zi/oră/dimensiune

Pasul 16 — Configurare în Program.cs

În Lab10_start/Program.cs, înlocuiți TODO-ul de Serilog cu următoarele, înainte de // DbContext:

using Serilog;

var builder = WebApplication.CreateBuilder(args);

// Serilog
Log.Logger = new LoggerConfiguration()
    .MinimumLevel.Information()
    .Enrich.FromLogContext()
    .WriteTo.Console(
        outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
    .WriteTo.File(
        path: "logs/newsportal-.txt",
        rollingInterval: RollingInterval.Day,
        outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}")
    .CreateLogger();

builder.Host.UseSerilog();

// DbContext
// ... restul ramane neschimbat ...

Detalii:

  • logs/newsportal-.txt — Serilog adaugă data între - și .txt, deci fișierele vor fi logs/newsportal-20260428.txt, logs/newsportal-20260429.txt, …
  • {Properties:j} la file template — serializează toate proprietățile structurate (inclusiv {Method}, {Path}, etc. din middleware-ul nostru) ca JSON. Asta e diferența: în consolă citiți log-ul, în fișier îl puteți interoga.
  • Enrich.FromLogContext — necesar pentru LogContext.PushProperty(...) (nu îl folosim explicit, dar e bun să fie acolo).
  • builder.Host.UseSerilog() — schimbă logger-ul global. Toate ILogger<T> injectate (controller-e, service-uri, middleware-urile noastre) merg automat prin Serilog.

Pasul 17 — Adăugați logs/ în .gitignore

Fișierele de log nu sunt parte din cod. În .gitignore (la rădăcina repo-ului sau în Lab10_start/):

logs/

Pasul 18 — Structured logging — diferența care contează

Comparați cele două apeluri:

// DA structured logging - placeholder nominal
_logger.LogInformation("Article created: {ArticleId} by {UserId}", article.Id, userId);

// NU string interpolation - placeholder pierdut
_logger.LogInformation($"Article created: {article.Id} by {userId}");

Output în consolă, ambele variante arată la fel. Dar în fișier, varianta structurată produce:

2026-04-28 10:32:11.456 [INF] Article created: 42 by 7 {"ArticleId": 42, "UserId": 7}

Iar varianta interpolată produce doar mesajul, fără proprietăți. Beneficiul: la o investigație, puteți filtra fișierul după ArticleId=42 cu grep sau jq. La sink-uri către Seq / Elasticsearch, e ireductibil — căutarea după proprietăți e singura mod de scaling.

Pasul 19 — Adăugați logging structurat în service

Deschideți Services/ArticleService.cs și injectați ILogger<ArticleService>:

public class ArticleService : IArticleService
{
    private readonly IUnitOfWork _unitOfWork;
    private readonly ILogger<ArticleService> _logger;

    public ArticleService(IUnitOfWork unitOfWork, ILogger<ArticleService> logger)
    {
        _unitOfWork = unitOfWork;
        _logger = logger;
    }

    public async Task AddAsync(Article article, CancellationToken cancellationToken = default)
    {
        _logger.LogInformation("Creating article {Title} by author {AuthorId}",
            article.Title, article.AuthorId);

        article.PublishedAt = DateTime.Now;
        await _unitOfWork.ArticleRepository.AddAsync(article, cancellationToken);
        await _unitOfWork.SaveChangesAsync(cancellationToken);

        _logger.LogInformation("Article created with id {ArticleId}", article.Id);
    }

    public async Task UpdateAsync(Article article, CancellationToken cancellationToken = default)
    {
        _logger.LogInformation("Updating article {ArticleId} ({Title})", article.Id, article.Title);
        _unitOfWork.ArticleRepository.Update(article);
        await _unitOfWork.SaveChangesAsync(cancellationToken);
    }

    public async Task DeleteAsync(int id, CancellationToken cancellationToken = default)
    {
        _logger.LogInformation("Deleting article {ArticleId}", id);
        var article = await _unitOfWork.ArticleRepository.GetByIdAsync(id, cancellationToken);
        if (article != null)
        {
            _unitOfWork.ArticleRepository.Delete(article);
            await _unitOfWork.SaveChangesAsync(cancellationToken);
        }
    }
}

Toate cele trei metode care modifică starea (AddAsync, UpdateAsync, DeleteAsync) primesc LogInformation cu placeholder-e nominale. Read-only metodele (GetAllAsync, GetByIdAsync, CountAsync, GetPagedAsync) nu necesită log — generează zgomot pentru fiecare request GET.

Atenție la testele Lab 9. ArticleServiceTests instanțiază ArticleService cu un singur parametru. Dacă adăugați al doilea parametru (ILogger<ArticleService> logger), testele nu mai compilează. Două soluții:

  1. Faceți logger-ul opțional: ILogger<ArticleService>? logger = null și folosiți _logger?.LogInformation(...) (nu rupe testele vechi).
  2. Treceți un NullLogger<ArticleService>.Instance (din Microsoft.Extensions.Logging.Abstractions) în testul CreateService.

Recomandare: opțiunea 2 — testele rămân stricte, semnătura constructorului rămâne curată.

Pasul 20 — Verificare

Reporniți aplicația și faceți câteva request-uri (mai ales create-uri de articole). Verificați:

  1. În consolă vedeți log-urile cu format Serilog ([10:32:11 INF] ...)
  2. În folder-ul logs/ apare un fișier newsportal-YYYYMMDD.txt
  3. Conținutul fișierului include proprietățile structurate ca JSON la finalul fiecărei linii — {"ArticleId": 42, "Method": "POST", ...}
# Pe Windows (PowerShell)
Get-Content -Tail 10 logs/newsportal-20260428.txt

Exercițiul 3 — Serilog cu sinks Console + File și structured logging (2p)

  • [ ] Serilog configurat în Program.cs cu sinks Console + File rolling pe zi (Pasul 16)
  • [ ] builder.Host.UseSerilog() apelat — logger-ul built-in înlocuit (Pasul 16)
  • [ ] logs/ în .gitignore (Pasul 17)
  • [ ] ArticleService injectează ILogger<ArticleService> și folosește structured logging (placeholder-e nominale, nu interpolare) în toate cele 3 metode care modifică starea: AddAsync, UpdateAsync, DeleteAsync (Pasul 19)
  • [ ] Testele din Lab10.Tests/ continuă să compileze și să treacă (rezolvați conflictul cu constructor-ul service-ului prin una din cele două căi din Pasul 19)
  • [ ] Verificat manual: fișier de log creat, proprietăți structurate vizibile în el (Pasul 20)

Validare: după un POST /api/articlesapi cu un articol nou, în logs/newsportal-YYYYMMDD.txt găsiți o linie de forma:

2026-04-28 10:32:11.456 [INF] Article created: Titlu test by author 1 {"Title": "Titlu test", "AuthorId": 1, ...}
2026-04-28 10:32:11.789 [INF] Article created with id 42 {"ArticleId": 42, ...}

dotnet test continuă să fie verde.