Skip to content

8.7 - Logging với Serilog ​

Tóm tắt: Khác biệt giữa log dùng được và log vô dụng nằm ở đúng một ký tự: dấu $. LogInformation($"Customer {id} created") tạo ra một chuỗi đã nấu chín — bạn chỉ tìm kiếm được bằng văn bản. LogInformation("Customer {CustomerId} created", id) giữ CustomerId thành một trường có cấu trúc, nên bạn truy vấn được CustomerId = 42 trên hàng triệu dòng log. Đó là toàn bộ ý nghĩa của structured logging. Serilog thêm vào: sink (ghi đi đâu), enricher (thêm ngữ cảnh chung), và UseSerilogRequestLogging() — thứ thay mười dòng log framework mỗi request bằng một dòng tóm tắt có đủ thông tin.

Mục tiêu bài học ​

Sau bài này bạn có thể:

  • Giải thích vì sao nội suy chuỗi phá hỏng log có cấu trúc.
  • Chọn đúng log level cho từng tình huống.
  • Dùng scope để gắn ngữ cảnh cho một nhóm log.
  • Cấu hình Serilog từ appsettings.json.
  • Biết chắc những gì không bao giờ được ghi vào log.

Nội dung bài học ​

8.7.1 — Message template, không phải nội suy chuỗi ​

csharp
// SAI — chuỗi đã nấu chín, không còn trường nào
logger.LogInformation($"Customer {customer.Id} created by {userId}");

// ĐÚNG — CustomerId và UserId là trường truy vấn được
logger.LogInformation("Customer {CustomerId} created by {UserId}",
    customer.Id, userId);

Dòng thứ hai sinh ra JSON:

json
{
  "@t": "2026-09-24T10:15:00Z",
  "@mt": "Customer {CustomerId} created by {UserId}",
  "CustomerId": 42,
  "UserId": "u-77"
}

Bạn truy vấn được CustomerId = 42 trong Seq, Elasticsearch hay Loki. Với bản nội suy, bạn chỉ có một chuỗi và phải tìm bằng văn bản — đúng lúc đang cần tìm nhanh nhất.

Còn hai lý do nữa:

  • Hiệu năng. Nội suy chuỗi luôn chạy, kể cả khi Debug đang tắt. Message template chỉ nối khi log thật sự được ghi.
  • Gom nhóm. Cùng một @mt là cùng một loại sự kiện, nên hệ thống log đếm được "sự kiện này xảy ra 12.000 lần hôm nay".

Hai quy ước đặt tên: PascalCase cho placeholder, và tiền tố @ khi muốn Serilog ghi cả đối tượng thay vì gọi ToString():

csharp
logger.LogInformation("Nhận đơn hàng {@Order}", order);   // ghi cả object

Cẩn thận với @: nó ghi toàn bộ thuộc tính, nên rất dễ đưa dữ liệu nhạy cảm vào log.

Bật analyzer để không ai quên:

xml
<PackageReference Include="SerilogAnalyzer" Version="*" PrivateAssets="all" />

8.7.2 — Log level ​

LevelNghĩaVí dụ
TraceChi tiết tới mức lộ nội bộGiá trị biến trong vòng lặp
DebugThông tin chẩn đoán cho devCache hit/miss, SQL sinh ra
InformationSự kiện nghiệp vụ đáng ghiĐơn hàng được tạo, người dùng đăng nhập
WarningBất thường nhưng đã xử lý đượcRetry lần 2, cache không kết nối được nên đi thẳng DB
ErrorMột thao tác thất bạiKhông lưu được đơn hàng
CriticalỨng dụng không phục vụ đượcMất kết nối database, sắp hết bộ nhớ

Ba nhầm lẫn hay gặp:

  • Error cho mọi exception. Người dùng nhập sai dữ liệu là chuyện bình thường, không phải sự cố. OperationCanceledException do client ngắt kết nối cũng vậy (bài 6.7). Log mức Error cho chúng làm dashboard đầy báo động giả cho tới khi không ai nhìn nó nữa.
  • Information cho mọi thứ. Log mức Information trong một vòng lặp 10.000 phần tử là chi phí thật và là nhiễu thật.
  • Critical không bao giờ dùng. Nếu Critical không nối với cảnh báo gọi điện cho ai đó, nó chỉ là Error viết hoa.

Đặt mức theo namespace:

json
{
  "Serilog": {
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft.AspNetCore": "Warning",
        "Microsoft.EntityFrameworkCore.Database.Command": "Warning",
        "System.Net.Http.HttpClient": "Warning"
      }
    }
  }
}

Ba dòng Override này cắt phần lớn nhiễu: EF Core log mọi câu SQL ở mức Information, và HttpClient log mọi request đi ra.

8.7.3 — Cấu hình ​

bash
dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Sinks.Seq
csharp
builder.Host.UseSerilog((ctx, services, config) => config
    .ReadFrom.Configuration(ctx.Configuration)   // doc tu appsettings.json
    .ReadFrom.Services(services)                 // enricher dang ky trong DI
    .Enrich.FromLogContext()                     // BAT BUOC cho scope
    .Enrich.WithMachineName()
    .Enrich.WithEnvironmentName());

Đặt cấu hình trong appsettings.json để đổi được theo môi trường mà không build lại:

json
{
  "Serilog": {
    "Using": ["Serilog.Sinks.Console", "Serilog.Sinks.Seq"],
    "MinimumLevel": { "Default": "Information" },
    "WriteTo": [
      { "Name": "Console", "Args": { "formatter": "Serilog.Formatting.Compact.CompactJsonFormatter, Serilog.Formatting.Compact" } },
      { "Name": "Seq", "Args": { "serverUrl": "http://seq:5341" } }
    ]
  }
}

Chọn sink theo nơi triển khai:

  • Trong container → chỉ ghi ra Console dạng JSON. Docker, Kubernetes và cloud đều thu thập stdout. Ghi ra file trong container là ghi vào một lớp sẽ biến mất, và nó còn làm image phình ra.
  • Ngoài container → thêm Serilog.Sinks.File với rollingInterval và retainedFileCountLimit.
  • Tập trung → Seq (nhẹ, dễ dựng), Elasticsearch, hoặc sink của nhà cung cấp cloud.

CompactJsonFormatter gọn hơn đáng kể so với JsonFormatter mặc định — đáng dùng khi log đi qua mạng.

Đường đi của một sự kiện log qua cấu hình ở trên:

8.7.4 — UseSerilogRequestLogging ​

csharp
app.UseSerilogRequestLogging(options =>
{
    options.MessageTemplate =
        "HTTP {RequestMethod} {RequestPath} -> {StatusCode} trong {Elapsed:0.0}ms";

    options.GetLevel = (httpContext, elapsed, ex) =>
        ex is not null                          ? LogEventLevel.Error
        : httpContext.Response.StatusCode > 499 ? LogEventLevel.Error
        : elapsed > 3000                        ? LogEventLevel.Warning
        : httpContext.Request.Path.StartsWithSegments("/health")
                                                ? LogEventLevel.Verbose
                                                : LogEventLevel.Information;

    options.EnrichDiagnosticContext = (diag, http) =>
    {
        diag.Set("UserId", http.User.FindFirst("sub")?.Value);
        diag.Set("UserAgent", http.Request.Headers.UserAgent.ToString());
    };
});

Nó thay khoảng mười dòng log mà ASP.NET Core sinh ra cho mỗi request bằng một dòng có đủ method, path, status, thời gian và những gì bạn thêm vào.

GetLevel là phần đáng giá nhất: health check mỗi 10 giây sẽ chiếm phần lớn log của bạn nếu không hạ mức nó xuống, còn request chậm tự nổi lên Warning mà không cần middleware riêng.

Đặt nó sớm trong pipeline, ngay sau middleware xử lý lỗi và correlation ID (bài 8.2).

8.7.5 — Scope ​

csharp
using (logger.BeginScope(new Dictionary<string, object>
{
    ["OrderId"] = order.Id,
    ["TenantId"] = tenant.Id
}))
{
    logger.LogInformation("Bắt đầu xử lý");
    await ValidateAsync(order, ct);      // mọi log bên trong đều có OrderId và TenantId
    await ChargeAsync(order, ct);
    logger.LogInformation("Hoàn tất");
}

Scope gắn thuộc tính vào mọi log phát sinh bên trong nó, kể cả từ lớp khác ở sâu bên dưới. Đó là cách bạn lọc ra toàn bộ dấu vết của một đơn hàng qua nhiều lớp mà không phải truyền orderId vào từng lời gọi log.

Cần Enrich.FromLogContext() trong cấu hình, nếu không scope bị bỏ qua trong im lặng.

Serilog còn có LogContext.PushProperty(...) làm điều tương tự ở tầng Serilog — dùng nó trong middleware, như ở CorrelationIdMiddleware.

8.7.6 — Không bao giờ ghi vào log ​

Log thường được gửi sang hệ thống bên thứ ba, giữ nhiều tháng, và nhiều người đọc được hơn database. Không bao giờ ghi:

  • Mật khẩu, kể cả mật khẩu sai người dùng vừa gõ.
  • Token, API key, connection string.
  • Số thẻ đầy đủ, CVV.
  • Số CMND/CCCD, thông tin y tế.
  • Toàn bộ request body của endpoint đăng nhập hoặc thanh toán.
csharp
// SAI — @ ghi toàn bộ thuộc tính, kể cả Password
logger.LogInformation("Dang nhap {@Request}", loginRequest);

// DUNG
logger.LogInformation("Dang nhap {Email}", loginRequest.Email);

Một tầng bảo vệ đáng thêm là destructuring policy, để cùng một quy tắc áp cho mọi chỗ:

csharp
config.Destructure.ByTransforming<LoginRequest>(r => new { r.Email });

Và nhớ rằng URL cũng vào log: đừng đặt token trong query string.

8.7.7 — Rà lại code của bạn ​

Danh sách rà soát logging

  • Không còn lời gọi log nào dùng nội suy chuỗi với dấu $.
  • Placeholder đặt tên PascalCase và nhất quán giữa các lớp.
  • MinimumLevel có Override cho Microsoft.AspNetCore và EF Core Database.Command.
  • Lỗi do người dùng nhập sai không được log ở mức Error.
  • OperationCanceledException do client ngắt không log ở mức Error.
  • Trong container chỉ ghi Console dạng JSON, không ghi file.
  • UseSerilogRequestLogging được bật và hạ mức cho health check.
  • Enrich.FromLogContext() có trong cấu hình để scope hoạt động.
  • Không có mật khẩu, token hay số thẻ nào xuất hiện trong log.
  • Không dùng {@Object} cho model có chứa trường nhạy cảm.

Bài tập áp dụng ​

Bài 1 — Message template so với nội suy chuỗi ​

Ghi cùng một sự kiện bằng nội suy chuỗi và bằng message template, rồi thử truy vấn theo CustomerId. Ghi lại cách nào tìm được.

Tiêu chí hoàn thành: bạn truy vấn được theo giá trị trường, và nêu được vì sao cách kia không thể.

Gợi ý và lời giải — Bài 1

Gợi ý. Nội suy chuỗi được thực hiện bởi trình biên dịch, trước khi ILogger nhìn thấy gì. Vậy thứ ILogger nhận được là gì?

Lời giải.

csharp
var customerId = 42;
var tong = 1_500_000m;

// Cách 1 — nội suy chuỗi
_logger.LogInformation($"Đã tạo đơn cho khách {customerId}, tổng {tong}");

// Cách 2 — message template
_logger.LogInformation("Đã tạo đơn cho khách {CustomerId}, tổng {Tong}", customerId, tong);

Cùng một dòng hiện ra trên console. Nhưng JSON gửi tới hệ thống log hoàn toàn khác:

json
// Cách 1
{
  "@t": "2026-09-24T09:12:03.121Z",
  "@l": "Information",
  "@m": "Đã tạo đơn cho khách 42, tổng 1500000"
}

// Cách 2
{
  "@t": "2026-09-24T09:12:03.121Z",
  "@l": "Information",
  "@mt": "Đã tạo đơn cho khách {CustomerId}, tổng {Tong}",
  "CustomerId": 42,
  "Tong": 1500000
}

Truy vấn trong Seq:

text
Cách 1: CustomerId = 42        -> KHÔNG TÌM THẤY (trường không tồn tại)
        @m like '%khách 42%'   -> tìm được, nhưng cũng khớp "khách 420", "khách 421"

Cách 2: CustomerId = 42        -> chính xác
        Tong > 1000000         -> so sánh SỐ được

Vì sao cách 1 không thể. Trình biên dịch nối chuỗi trước khi gọi ILogger, nên logger chỉ nhận được một chuỗi phẳng. Không có gì để tách ra thành trường. Thông tin về cấu trúc đã mất vĩnh viễn ngay tại dòng code đó.

Bốn lợi ích của message template, ngoài việc truy vấn được:

  1. Nhóm được các dòng log cùng loại. Trường @mt giống nhau cho mọi lần gọi, nên đếm được "sự kiện này xảy ra bao nhiêu lần" dù giá trị khác nhau.
  2. So sánh theo kiểu dữ liệu. Tong > 1000000 hoạt động vì Tong được lưu là số, không phải chuỗi.
  3. Không tính chuỗi khi mức log bị tắt. Với nội suy, chuỗi luôn được nối kể cả khi Debug đang tắt — lãng phí trong vòng lặp nóng.
  4. Bảo vệ khỏi log injection. Giá trị được lưu thành trường riêng chứ không trộn vào thông điệp, nên không ai chèn được dòng log giả bằng cách nhập ký tự xuống dòng.

Quy ước đặt tên. Dùng PascalCase cho tên trường trong template và giữ nhất quán toàn hệ thống. Nếu chỗ này ghi {CustomerId} và chỗ kia ghi {customerId}, chúng thành hai trường khác nhau và truy vấn sẽ sót.

Bật cảnh báo tự động. Gói SerilogAnalyzer hoặc quy tắc CA2254 phát hiện nội suy chuỗi trong lời gọi log:

xml
<WarningsAsErrors>$(WarningsAsErrors);CA2254</WarningsAsErrors>

Đây là một trong những quy tắc đáng biến thành lỗi nhất, vì hậu quả chỉ lộ ra khi bạn cần điều tra sự cố — lúc đã quá muộn để sửa.

Bài 2 — Đếm nhiễu từ health check ​

Chạy ứng dụng 5 phút với health check mỗi 10 giây và không cấu hình mức log. Đếm số dòng log. Bật cấu hình hạ health check xuống mức thấp và đếm lại.

Tiêu chí hoàn thành: bạn có hai con số và nêu được vì sao nhiễu trong log là một vấn đề thật.

Gợi ý và lời giải — Bài 2

Gợi ý. Kubernetes gọi cả liveness và readiness probe. Với hai probe mỗi 10 giây trên 3 thể hiện, hãy tính xem một ngày có bao nhiêu lần gọi.

Lời giải — bản mặc định:

csharp
app.UseSerilogRequestLogging();

Sau 5 phút với hai probe mỗi 10 giây:

text
5 phút = 300 giây
300 / 10 = 30 lần mỗi probe
2 probe  = 60 dòng log

Quy ra một ngày, với 3 thể hiện:

text
60 dòng / 5 phút × 288 (số khoảng 5 phút trong ngày) × 3 thể hiện
= khoảng 51.840 dòng log mỗi ngày, chỉ riêng health check

Bản có cấu hình:

csharp
app.UseSerilogRequestLogging(o =>
{
    o.GetLevel = (ctx, elapsed, ex) =>
        ex is not null                                   ? LogEventLevel.Error
        : ctx.Response.StatusCode >= 500                 ? LogEventLevel.Error
        : ctx.Request.Path.StartsWithSegments("/health") ? LogEventLevel.Verbose
        : elapsed > 3000                                 ? LogEventLevel.Warning
        : LogEventLevel.Information;
});

Với mức tối thiểu là Information, các dòng Verbose bị loại bỏ trước khi ghi:

text
60 dòng -> 0 dòng

Vì sao nhiễu là vấn đề thật — bốn lý do:

  1. Chi phí lưu trữ. Các dịch vụ log tính tiền theo dung lượng. 50.000 dòng vô nghĩa mỗi ngày là chi phí thuần, và nó nhân lên theo số môi trường.
  2. Che lấp dòng quan trọng. Khi cuộn log tìm một lỗi, 95% những gì bạn thấy là health check. Điều này làm chậm việc điều tra đúng lúc cần nhanh nhất.
  3. Làm hỏng thống kê. Tính độ trễ trung bình mà gộp cả health check — vốn luôn nhanh — sẽ cho con số đẹp hơn thực tế đáng kể.
  4. Tạo thói quen bỏ qua log. Đây là hậu quả lâu dài và tệ nhất: khi log đầy nhiễu, người ta ngừng đọc nó.

Cấu hình mức log theo nguồn — quan trọng không kém:

json
{
  "Serilog": {
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft.AspNetCore": "Warning",
        "Microsoft.EntityFrameworkCore.Database.Command": "Warning",
        "System.Net.Http.HttpClient": "Warning"
      }
    }
  }
}

Dòng thứ hai trong Override đặc biệt đáng giá: ở mức Information, EF Core ghi toàn bộ câu SQL của mọi truy vấn. Rất hữu ích khi phát triển, và là một nguồn nhiễu khổng lồ trên production — chưa kể nó có thể ghi cả giá trị tham số, tức là dữ liệu người dùng.

Quy tắc chọn mức log:

MứcDùng choCó nên bật ở production
Verbose / TraceChi tiết gỡ rối sâuKhông
DebugThông tin cho lập trình viênKhông
InformationSự kiện nghiệp vụ đáng lưuCó
WarningBất thường nhưng xử lý đượcCó
ErrorThất bại cần người xemCó
FatalỨng dụng không chạy tiếp đượcCó

Phép thử cho mức Information: dòng này có giúp tôi hiểu chuyện gì đã xảy ra với một request cụ thể không? Không thì hạ xuống Debug.

Bài 3 — Rò rỉ dữ liệu nhạy cảm qua toán tử @ ​

Ghi log một LoginRequest bằng {@Request} và xác nhận mật khẩu xuất hiện trong output. Sửa bằng chính sách phân rã và kiểm chứng lại.

Tiêu chí hoàn thành: bạn nêu được vì sao đây là vi phạm nghiêm trọng, không chỉ là một bất cẩn.

Gợi ý và lời giải — Bài 3

Gợi ý. Toán tử @ trong Serilog yêu cầu phân rã đối tượng thành các trường thay vì gọi ToString(). Nó phân rã mọi thuộc tính công khai.

Lời giải — bản rò rỉ:

csharp
public record LoginRequest(string Email, string Password);

_logger.LogInformation("Đăng nhập: {@Request}", req);

Output:

json
{
  "@mt": "Đăng nhập: {@Request}",
  "Request": {
    "Email": "[email protected]",
    "Password": "MatKhauThat@123",
    "$type": "LoginRequest"
  }
}

Mật khẩu nguyên văn nằm trong log.

Vì sao đây là vi phạm nghiêm trọng, không chỉ bất cẩn:

  1. Log được lưu lâu hơn bạn nghĩ. Thường 30 tới 90 ngày, sao lưu có thể lâu hơn nữa.
  2. Log được nhiều người xem hơn database. Lập trình viên, vận hành, hỗ trợ — nhiều người có quyền đọc log mà không có quyền đọc bảng người dùng.
  3. Log thường rời khỏi hệ thống của bạn. Gửi tới dịch vụ bên thứ ba, đi qua nhiều tầng thu thập, và có thể nằm ở vùng địa lý khác.
  4. Vi phạm quy định về dữ liệu cá nhân. Ghi mật khẩu dạng thô vi phạm mọi tiêu chuẩn bảo mật, và là điểm bị ghi nhận ngay trong mọi cuộc kiểm toán.
  5. Không thể xoá chọn lọc. Khi phát hiện, bạn phải xoá cả khối log của khoảng thời gian đó — mất luôn thông tin cần cho việc điều tra.

Và hậu quả trực tiếp: mọi mật khẩu đã lọt vào log phải được coi là đã lộ, nên phải buộc toàn bộ người dùng đổi mật khẩu.

Sửa bằng chính sách phân rã:

csharp
public class SensitiveDataPolicy : IDestructuringPolicy
{
    private static readonly HashSet<string> MaskedFields = new(StringComparer.OrdinalIgnoreCase)
    {
        "Password", "Token", "AccessToken", "RefreshToken",
        "Secret", "ApiKey", "CreditCard", "Cvv", "BankCardNumber", "NationalId"
    };

    public bool TryDestructure(object value, ILogEventPropertyValueFactory f,
                               out LogEventPropertyValue? result)
    {
        var props = value.GetType().GetProperties()
            .Select(p => new LogEventProperty(
                p.Name,
                MaskedFields.Contains(p.Name)
                    ? new ScalarValue("***")
                    : f.CreatePropertyValue(p.GetValue(value))));

        result = new StructureValue(props);
        return true;
    }
}

Log.Logger = new LoggerConfiguration()
    .Destructure.With<SensitiveDataPolicy>()
    .CreateLogger();
json
{ "Request": { "Email": "[email protected]", "Password": "***" } }

Ba lớp phòng vệ, nên có cả ba:

  1. Đừng ghi log cả đối tượng. Ghi đúng những trường cần:

    csharp
    _logger.LogInformation("Đăng nhập: {Email}", req.Email);

    Đây là lớp tốt nhất — không có gì để rò rỉ.

  2. Chính sách phân rã như trên, làm lưới an toàn cho những chỗ ai đó quên.

  3. Quét log định kỳ tìm mẫu giống mật khẩu, số thẻ, token. Nhiều dịch vụ log có sẵn tính năng này.

Danh sách không bao giờ được ghi vào log:

LoạiVí dụ
Thông tin xác thựcmật khẩu, token, khoá API, chuỗi kết nối
Dữ liệu tài chínhsố thẻ, mã CVV, số tài khoản
Định danh cá nhâncăn cước, hộ chiếu, mã số thuế cá nhân
Dữ liệu sức khoẻbất kỳ thông tin y tế nào
Nội dung riêng tưnội dung tin nhắn, email của người dùng

Một chi tiết dễ quên: chuỗi kết nối trong thông báo lỗi. Khi kết nối database thất bại, một số driver đưa nguyên chuỗi kết nối — gồm cả mật khẩu — vào thông báo ngoại lệ. Nếu bạn ghi ex.ToString() vào log, mật khẩu database nằm trong log. Hãy đặt Persist Security Info=false trong chuỗi kết nối và rà lại các chỗ ghi ngoại lệ.

Tự kiểm tra ​

Vì sao không dùng nội suy chuỗi khi log?

Vì nó tạo ra một chuỗi đã nấu chín nên bạn chỉ tìm kiếm được bằng văn bản, không truy vấn được theo trường. Nó cũng luôn chạy kể cả khi mức log đó đang tắt, và làm mất khả năng gom nhóm các sự kiện cùng loại theo message template.

Tiền tố @ trong placeholder có tác dụng gì?

Nó bảo Serilog ghi cả đối tượng thành các thuộc tính thay vì gọi ToString. Nhưng nó ghi toàn bộ thuộc tính nên rất dễ đưa dữ liệu nhạy cảm như mật khẩu hay token vào log; với model có trường nhạy cảm thì nên dùng destructuring policy.

Những nhầm lẫn nào hay gặp với log level?

Ba cái. Dùng Error cho mọi exception, trong khi người dùng nhập sai hay client ngắt kết nối là chuyện bình thường, và làm vậy khiến dashboard đầy báo động giả. Dùng Information cho mọi thứ kể cả trong vòng lặp lớn. Và không bao giờ dùng Critical, trong khi nếu Critical không nối với cảnh báo thật thì nó chỉ là Error viết hoa.

Nên ghi log ra đâu khi chạy trong container?

Chỉ ghi ra Console dạng JSON, vì Docker, Kubernetes và cloud đều thu thập stdout. Ghi ra file trong container là ghi vào một lớp sẽ biến mất khi container bị thay, và nó còn làm image phình ra.

UseSerilogRequestLogging mang lại gì?

Nó thay khoảng mười dòng log mà ASP.NET Core sinh cho mỗi request bằng một dòng có đủ method, path, status và thời gian. Phần đáng giá nhất là GetLevel, cho phép hạ mức health check xuống rất thấp và tự nâng request chậm lên Warning mà không cần middleware riêng.

Scope trong logging để làm gì?

Nó gắn thuộc tính vào mọi log phát sinh bên trong nó, kể cả từ lớp khác ở sâu bên dưới, nên bạn lọc được toàn bộ dấu vết của một đơn hàng qua nhiều lớp mà không phải truyền id vào từng lời gọi log. Cần Enrich.FromLogContext trong cấu hình, nếu không scope bị bỏ qua trong im lặng.

Kết luận ​

Ba điều đáng nhớ nhất:

  1. Dấu $ phá hỏng log. Dùng message template với placeholder có tên.
  2. Error là cho sự cố, không phải cho người dùng nhập sai hay client ngắt kết nối.
  3. Log được nhiều người đọc hơn database. Không có mật khẩu, token hay số thẻ trong đó.

Tham khảo ​

Điều hướng ​

Bài liên quan ​

Phát hành theo giấy phép MIT.