لاگ ساخت‌یافته چیست و چرا متن ساده دیگر جواب نمی‌دهد

لاگ ساخت‌یافته چیست و چرا متن ساده دیگر جواب نمی‌دهد
در این مقاله می‌خوانید
  1. متن ساده دقیقاً کجا می‌شکند
  2. قالب پیام در برابر درون‌یابی رشته
  3. JSON: قالب انتقال، نه خود ایده
  4. جاوا: SLF4J و Logback
  5. پایتون: logging استاندارد یا structlog
  6. نام‌گذاری فیلدها: قراردادی که بعداً شکرش را می‌کنید
  7. از کجا شروع کنیم
  8. منابع و مطالعهٔ بیشتر

اولین باری که واقعاً فهمیدم لاگ متنی کم می‌آورد، شبی بود که باید پیدا می‌کردم سفارش‌های یک مشتری مشخص در سه روز گذشته کجا گیر کرده‌اند. لاگ کم نبود؛ برعکس، پر بود از خط‌هایی مثل Order 84213 for customer 1907 failed: timeout. مشکل این بود که هر سرویس همین جمله را کمی متفاوت نوشته بود: یکی «customer» نوشته بود، یکی «cust»، یکی شناسه را اول جمله گذاشته بود و یکی آخر. دو ساعت از آن شب صرف نوشتن regex شد، نه حل مشکل.

لاگ ساخت‌یافته (structured logging) جواب همین وضعیت است. به‌جای یک رشتهٔ متنی که بعداً باید تجزیه‌اش کرد، هر رویداد لاگ مجموعه‌ای از فیلدهای نام‌دار است: زمان، سطح، پیام، و مقدارهایی مثل OrderId و CustomerId که جدا از متن نگه داشته می‌شوند. متن هنوز هست، چون آدم‌ها باید بخوانندش؛ ولی ماشین دیگر لازم نیست حدس بزند کدام کلمه شناسهٔ سفارش است.

اگر هنوز تصویر کلی را ندارید که چه چیزی را، کجا و با چه سطحی لاگ کنید، اول راهنمای جامع لاگ‌نویسی در بک‌اند را ببینید. اینجا فقط روی شکل لاگ تمرکز می‌کنم: چرا ساخت‌یافته، چطور در ⁦C#⁩ و جاوا و پایتون، و چه قراردادهایی بعداً نجاتتان می‌دهد.

متن ساده دقیقاً کجا می‌شکند

این خط را در نظر بگیرید:

2026-09-20 10:14:03 ERROR Payment failed for order 84213 (customer 1907), gateway=saman, took 3012ms

برای خواندن با چشم عالی است. حالا سؤالی بپرسید که در یک حادثهٔ واقعی می‌پرسید: «همهٔ پرداخت‌های ناموفق درگاه سامان در یک ساعت گذشته که بیش از دو ثانیه طول کشیده‌اند». با متن ساده باید عدد را با regex از وسط جمله بیرون بکشید، آن را از رشته به عدد تبدیل کنید و امیدوار باشید که هیچ‌کس متن پیام را عوض نکرده باشد. کافی است برنامه‌نویسی در یک refactor بنویسد took 3.0s تا همهٔ جستجوها و داشبوردهایی که روی این الگو ساخته‌اید بی‌صدا خراب شوند.

همین رویداد به شکل ساخت‌یافته (اینجا در قالب CLEF که Serilog تولید می‌کند):

{
  "@t": "2026-09-20T06:44:03.512Z",
  "@l": "Error",
  "@mt": "Payment failed for order {OrderId} (customer {CustomerId}), gateway={Gateway}, took {ElapsedMs}ms",
  "OrderId": 84213,
  "CustomerId": 1907,
  "Gateway": "saman",
  "ElapsedMs": 3012
}

حالا آن سؤال یک فیلتر ساده است: Gateway = 'saman' and ElapsedMs > 2000. عدد عدد مانده، نام فیلد صریح است و تغییر متن پیام چیزی را خراب نمی‌کند. یک مزیت کمتر دیده‌شده هم دارد: خود قالب پیام (@mt) یک کلید است. همهٔ رویدادهایی که از همین خط کد آمده‌اند قالب یکسان دارند، هر چقدر هم مقدارهایشان فرق کند. پس می‌توانید بپرسید «این خطا امروز چند بار رخ داده؟» بی آنکه پیام‌ها را با هم مقایسه کنید.

قالب پیام در برابر درون‌یابی رشته

رایج‌ترین اشتباهی که در کد ⁦C#⁩ می‌بینم این است که برنامه‌نویس از Serilog یا ILogger استفاده می‌کند، یعنی ابزار ساخت‌یافته دارد، ولی با string interpolation همه را دوباره به متن ساده تبدیل می‌کند:

// بد: پیام پیش از رسیدن به logger ساخته می‌شود و مقدارها در متن گم می‌شوند
_logger.LogInformation($"Order {orderId} placed by {customerId}");

// خوب: قالب ثابت است و مقدارها جدا ثبت می‌شوند
_logger.LogInformation("Order {OrderId} placed by {CustomerId}", orderId, customerId);

این دو خط در کنسول تقریباً یک خروجی دارند، ولی تفاوتشان چهارگانه است:

  • ویژگی‌ها حفظ می‌شوند. در خط دوم، OrderId و CustomerId فیلدهای جدا با نوع اصلی‌شان هستند. در خط اول فقط یک رشته به logger می‌رسد.
  • قالب ثابت است. هر سفارش در خط اول یک رشتهٔ یکتا می‌سازد؛ گروه‌بندی و شمارش رویدادهای هم‌نوع عملاً ناممکن می‌شود.
  • هزینه وقتی سطح خاموش است. درون‌یابی همیشه رشته را می‌سازد، حتی اگر آن سطح لاگ غیرفعال باشد. با قالب، رندر پیام به بعد از بررسی سطح موکول می‌شود.
  • امنیت. اگر مقدار از ورودی کاربر بیاید و شامل کاراکتر خط جدید باشد، در حالت درون‌یابی بخشی از متن پیام می‌شود و می‌تواند یک خط لاگ جعلی بسازد. با قالب، مقدار یک فیلد جداست. این موضوع را در مقالهٔ تزریق لاگ مفصل‌تر باز کرده‌ام.

تحلیل‌گر خود ⁦.NET⁩ هم این را می‌داند: قاعدهٔ CA2254 وقتی قالب پیام عبارت ثابت نباشد هشدار می‌دهد. پیشنهاد می‌کنم در پروژه سطحش را به warning یا error ببرید تا در code review لازم نباشد کسی به آن فکر کند.

یک نکتهٔ ظریف دربارهٔ Microsoft.Extensions.Logging: جای‌نگه‌دارها بر اساس ترتیب به آرگومان‌ها وصل می‌شوند، نه بر اساس نام. یعنی {OrderId} همیشه اولین آرگومان را می‌گیرد، حتی اگر نام متغیر چیز دیگری باشد. پس جابه‌جا کردن آرگومان‌ها خطای کامپایل نمی‌دهد ولی داده را اشتباه ثبت می‌کند.

برای مسیرهای پرتکرار، مولد کد LoggerMessage هم تمیزتر است و هم سریع‌تر، چون از boxing و ساختن آرایهٔ آرگومان جلوگیری می‌کند و نوع هر فیلد را در زمان کامپایل ثابت می‌کند:

public static partial class OrderLog
{
    [LoggerMessage(EventId = 1001, Level = LogLevel.Information,
        Message = "Order {OrderId} placed by {CustomerId}")]
    public static partial void OrderPlaced(ILogger logger, int orderId, int customerId);
}

// استفاده
OrderLog.OrderPlaced(_logger, order.Id, order.CustomerId);

Serilog دو عملگر اضافه هم دارد: {@Order} شیء را به ساختار تبدیل می‌کند (destructuring) و {$Order} فقط ToString() آن را ثبت می‌کند. destructuring قدرتمند است و همان‌قدر خطرناک: شیء کاملی که امروز فقط شناسه و مبلغ دارد، فردا فیلد شماره کارت یا ایمیل می‌گیرد و بی‌خبر وارد لاگ می‌شود. دربارهٔ این خطر در مقالهٔ چه چیزی را هرگز نباید لاگ کرد نوشته‌ام. قاعدهٔ من ساده است: فیلدهایی را که لازم دارید صریحاً نام ببرید.

JSON: قالب انتقال، نه خود ایده

لاگ ساخت‌یافته یک مدل است؛ JSON رایج‌ترین شکل انتقال آن. چند قالب جاافتاده وجود دارد: CLEF که Serilog و Seq به‌کار می‌برند، ECS که استاندارد Elastic است، و مدل دادهٔ لاگ OpenTelemetry که پیام را در body و بقیه را در attributes می‌گذارد. انتخاب بین آن‌ها کمتر از این مهم است که یکی را انتخاب کنید و همه جا رعایتش کنید.

عادتی که در پروژه‌ها جا انداخته‌ام: در محیط توسعه، کنسول متن خوانا نشان دهد؛ در پروداکشن، خروجی JSON باشد، هر رویداد در یک خط (NDJSON). در Serilog این فقط عوض کردن formatter است:

using Serilog;
using Serilog.Formatting.Compact;

Log.Logger = new LoggerConfiguration()
    .Enrich.FromLogContext()
    .WriteTo.Console(new CompactJsonFormatter())
    .CreateLogger();

یک مزیت عملی JSON که کمتر به آن اشاره می‌شود: stack trace. در لاگ متنی، stack trace چند خط است و هر ابزاری که خط‌به‌خط می‌خواند آن را به ده رویداد جدا تبدیل می‌کند. در JSON، کل stack داخل یک فیلد رشته‌ای است و با رویداد اصلی می‌ماند. جزئیات راه‌اندازی کامل Serilog را در پیکربندی Serilog در ⁦ASP.NET Core⁩ آورده‌ام.

جاوا: SLF4J و Logback

SLF4J از قدیم جای‌نگه‌دار {} داشته و همین باعث می‌شود بسیاری فکر کنند لاگشان ساخت‌یافته است. نیست: جای‌نگه‌دارهای SLF4J بی‌نام‌اند و در خروجی استاندارد Logback مقدارها فقط در متن رندرشده می‌مانند. برای داشتن فیلد واقعی دو راه رایج هست. هر دو به logstash-logback-encoder (گروه net.logstash.logback) نیاز دارند که خروجی را JSON می‌کند:

<configuration>
  <appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
    <encoder class="net.logstash.logback.encoder.LogstashEncoder" />
  </appender>
  <root level="INFO">
    <appender-ref ref="JSON" />
  </root>
</configuration>

راه اول، StructuredArguments همین کتابخانه است. مقدار هم در پیام رندر می‌شود و هم به‌عنوان فیلد JSON می‌آید:

import static net.logstash.logback.argument.StructuredArguments.kv;

log.info("Order placed {} {}", kv("orderId", orderId), kv("customerId", customerId));

راه دوم، API روان SLF4J 2 است که وابستگی کدتان را فقط به SLF4J نگه می‌دارد. LogstashEncoder به‌طور پیش‌فرض هر key-value را یک فیلد جدا می‌نویسد:

log.atInfo()
   .setMessage("Order placed")
   .addKeyValue("orderId", orderId)
   .addKeyValue("customerId", customerId)
   .log();

اگر با Spring Boot کار می‌کنید، از نسخهٔ 3.4 به بعد لاگ ساخت‌یافته در خود فریم‌ورک هست و بدون وابستگی اضافه با یک خط تنظیم فعال می‌شود: logging.structured.format.console=ecs (یا logstash و gelf). برای زمینهٔ مشترک یک درخواست هم MDC را فراموش نکنید؛ LogstashEncoder مقدارهای MDC را خودش در هر خط می‌آورد.

پایتون: logging استاندارد یا structlog

در پایتون همان دام ⁦C#⁩ با f-string تکرار می‌شود. logger.info(f"Order {order_id} placed") رشته را همیشه می‌سازد؛ logger.info("Order %s placed", order_id) دست‌کم ساختن رشته را تا روشن بودن سطح عقب می‌اندازد. برای فیلد واقعی، ماژول استاندارد پارامتر extra دارد که ویژگی‌ها را به رکورد اضافه می‌کند، ولی Formatter پیش‌فرض آن‌ها را نمی‌نویسد و باید یک formatter JSON جدا اضافه کنید.

راه تمیزتر که خودم ترجیح می‌دهم structlog است، که از اول برای همین ساخته شده:

import structlog

structlog.configure(
    processors=[
        structlog.contextvars.merge_contextvars,
        structlog.processors.add_log_level,
        structlog.processors.TimeStamper(fmt="iso", utc=True),
        structlog.processors.format_exc_info,
        structlog.processors.JSONRenderer(),
    ]
)

log = structlog.get_logger()
log.info("order_placed", order_id=84213, customer_id=1907)
# {"order_id": 84213, "customer_id": 1907, "event": "order_placed", "level": "info", "timestamp": "..."}

در structlog پیام در کلید event می‌نشیند و رسم رایج این است که خودش یک نام رویداد کوتاه و ثابت باشد، نه جمله. برای زمینهٔ درخواست هم structlog.contextvars.bind_contextvars(request_id=...) را در ابتدای درخواست صدا بزنید تا در همهٔ خط‌های بعدی بیاید.

نام‌گذاری فیلدها: قراردادی که بعداً شکرش را می‌کنید

لاگ ساخت‌یافته با نام‌گذاری بی‌نظم، فقط مشکل را از متن به نام فیلدها منتقل می‌کند. سیستمی دیده‌ام که شناسهٔ کاربر در آن با پنج نام آمده بود: userId، user_id، UserID، uid و customer. جستجو روی هر کدام، بقیه را از دست می‌داد. این‌ها قاعده‌هایی است که امروز از روز اول می‌گذارم:

  1. یک قرارداد برای کل سازمان. در ⁦.NET⁩ و Serilog، PascalCase طبیعی است (OrderId). اگر چند زبان دارید، قراردادهای معنایی OpenTelemetry (نام‌های نقطه‌دار با حروف کوچک مثل http.request.method و user.id) زبان مشترک خوبی‌اند. مهم این است که یک مفهوم در همهٔ سرویس‌ها یک نام داشته باشد.
  2. واحد در نام. ElapsedMs و SizeBytes، نه Elapsed و Size. شش ماه بعد هیچ‌کس یادش نیست ثانیه بود یا میلی‌ثانیه.
  3. نوع پایدار. اگر OrderId در یک سرویس عدد است و در دیگری رشتهٔ "84213"، مقایسه و فیلتر در بسیاری از انباره‌ها رفتار عجیبی پیدا می‌کند.
  4. مقدار هرگز در نام فیلد نیاید. Retry3: true غلط است؛ RetryAttempt: 3 درست. نام‌های پویا تعداد فیلدها را منفجر می‌کنند و ایندکس را از کار می‌اندازند.
  5. شناسهٔ درخواست در هر خط. بدون trace_id یا شناسهٔ مشابه، نمی‌توانید خط‌های یک درخواست را کنار هم بچینید. این موضوع مقالهٔ جدای خودش را دارد: trace_id و correlation id.
به‌جای این این را بنویسید
$"User {id} logged in" "User {UserId} logged in", id
Elapsed: 3.012 ElapsedMs: 3012
Error_Timeout: 1 ErrorKind: "timeout"
{@Request} کل شیء {Path}، {StatusCode} به‌صورت صریح

از کجا شروع کنیم

برای کدی که سال‌ها با لاگ متنی نوشته شده، بازنویسی یک‌جا لازم نیست. ترتیبی که جواب داده:

  1. تحلیل‌گر CA2254 را روشن کنید (یا در جاوا و پایتون، جستجوی ساده برای f-string و الحاق رشته در فراخوانی‌های لاگ) و از کدهای جدید شروع کنید.
  2. خروجی پروداکشن را JSON کنید. همین یک تغییر، stack traceها را یکپارچه می‌کند.
  3. روی ده فیلد مشترک توافق کنید: سرویس، محیط، میزبان، شناسهٔ درخواست، شناسهٔ کاربر، و چند مفهوم اصلی دامنهٔ خودتان.
  4. لاگ‌ها را به جایی بفرستید که فیلدها را واقعاً ایندکس کند؛ وگرنه JSON فقط متن طولانی‌تری است.

بند آخر همان جایی است که LogMug را برایش ساختم: هر فیلد JSON که بفرستید ویژگی قابل‌فیلتر می‌شود، اشیای تودرتو با نقطه تخت می‌شوند و قالب پیام Serilog جدا نگه داشته می‌شود. اگر Serilog یا OpenTelemetry ندارید، ارسال لاگ با HTTP و JSON ساده‌ترین مسیر است. ولی حتی اگر هرگز سراغ هیچ ابزاری نروید، همین که امروز یک $"..." را به قالب پیام تبدیل کنید، شب حادثهٔ بعدی دو ساعت کمتر با regex کلنجار می‌روید.

منابع و مطالعهٔ بیشتر

لاگ همهٔ سرویس‌هایتان را در یک جا جستجو کنید

C#، Java یا هر زبان دیگر — با چند خط پیکربندی وصل می‌شود. پلن رایگان کارت بانکی نمی‌خواهد.

شروع رایگان