# Логирование

Source: https://docs.sisk-framework.org/ru/docs/features/logging.html

Вы можете настроить Sisk для автоматической записи журналов доступа и ошибок. Можно задать ротацию журналов, расширения и частоту.

Класс [LogStream](https://docs.sisk-framework.org/api/Sisk.Core.Http.LogStream.md) предоставляет асинхронный способ записи журналов и хранит их в очереди записи, которую можно ожидать. Класс `LogStream` реализует `IAsyncDisposable`, гарантируя, что все ожидающие записи будут записаны до закрытия потока.

В этой статье мы покажем, как настроить журналирование для вашего приложения.

## Файловые журналы доступа

Запись журналов в файлы открывает файл, записывает строку текста и затем закрывает файл после каждой записи. Эта процедура была принята для поддержания отзывчивости записи в журналах.

```cs {title="Program.cs"}
class Program
{
    static async Task Main(string[] args)
    {
        using var app = HttpServer.CreateBuilder()
            .UseConfiguration(config => {
                config.AccessLogsStream = new LogStream("logs/access.log");
            })
            .Build();
        
        ...
        
        await app.StartAsync();
    }
}
```

Приведённый код будет записывать все входящие запросы в файл `logs/access.log`. Обратите внимание, что файл создаётся автоматически, если его нет, однако папка перед ним не создаётся. Не требуется создавать каталог `logs/`, так как класс LogStream создаёт его автоматически.

## Потоковое журналирование

Вы можете записывать журналы в объекты `TextWriter`, такие как `Console.Out`, передавая объект `TextWriter` в конструктор:

```cs {title="Program.cs"}
using var app = HttpServer.CreateBuilder()
    .UseConfiguration(config => {
        config.AccessLogsStream = new LogStream(Console.Out);
    })
    .Build();
```

Для каждого сообщения, записываемого в потоковый журнал, вызывается метод `TextWriter.Flush()`.

## Форматирование журнала доступа

Вы можете настроить формат журнала доступа с помощью предопределённых переменных. Рассмотрите следующую строку:

```cs
config.AccessLogsFormat = "%dd/%dmm/%dy %tH:%ti:%ts %tz %ls %ri %rs://%ra%rz%rq [%sc %sd] %lin -> %lou in %lmsms [%{user-agent}]";
```

Он запишет сообщение, похожее на:

    29/mar./2023 15:21:47 -0300 Executed ::1 http://localhost:5555/ [200 OK] 689B -> 707B in 84ms [Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/111.0.0.0 Safari/537.36]

Вы можете форматировать ваш журнал, используя формат, описанный в таблице:

| Значение | Что представляет | Пример |
|----------|------------------|--------|
| %dd    | День месяца (в виде двух цифр) | 05 |
| %dmmm  | Полное название месяца | July |
| %dmm   | Сокращённое название месяца (три буквы) | Jul |
| %dm    | Номер месяца (в виде двух цифр) | 07 |
| %dy    | Год (в виде четырёх цифр) | 2023 |
| %th    | Час в 12-часовом формате | 03 |
| %tH    | Час в 24-часовом формате (HH) | 15 |
| %ti    | Минуты (в виде двух цифр) | 30 |
| %ts    | Секунды (в виде двух цифр) | 45 |
| %tm    | Миллисекунды (в виде трёх цифр) | 123 |
| %tz    | Смещение часового пояса (общее количество часов в UTC) | +03:00 |
| %ri    | Удалённый IP-адрес клиента, определённый в начале запроса | 192.168.1.100 |
| %rm    | HTTP-метод (в верхнем регистре) | GET |
| %rs    | Схема URI (http/https) | https |
| %ra    | Авторитет URI (домен) | example.com |
| %rh    | Хост запроса | www.example.com |
| %rp    | Порт запроса | 443 |
| %rz    | Путь запроса | /path/to/resource |
| %rq    | Строка запроса | ?key=value&another=123 |
| %sc    | Код статуса HTTP-ответа | 200 |
| %sd    | Описание статуса HTTP-ответа | OK |
| %lin   | Размер запроса в человекочитаемом виде | 1.2 KB |
| %linr  | Необработанный размер запроса (байты) | 1234 |
| %lou   | Размер ответа в человекочитаемом виде | 2.5 KB |
| %lour  | Необработанный размер ответа (байты) | 2560 |
| %lms   | Прошедшее время в миллисекундах | 120 |
| %ls    | Статус выполнения | Executed |
| %{header-name} | Представляет заголовок `header-name` запроса. | `Mozilla/5.0 (platform; rv:gecko [...]` |
| %{:header-name} | Представляет заголовок `header-name` ответа. | `application/json` |
| %{$key} | Представляет значение, хранящееся под `key` в [HttpContext.RequestBag](https://docs.sisk-framework.org/api/Sisk.Core.Http.HttpContext.RequestBag.md), преобразованное с помощью `ToString()`. Пусто, если ключ не установлен. | `42` |

Вы также можете использовать `HttpServerConfiguration.DefaultAccessLogFormat` для применения формата журнала доступа по умолчанию.

Термин `%{$key}` позволяет обработчикам запросов и маршрутам добавлять свои собственные значения в журнал доступа:

```cs
config.AccessLogsFormat = "%dd/%dmm/%dy %tH:%ti:%ts %rm %rz [%sc] user=%{$UserId}";

router.MapGet("/profile", request =>
{
    request.Bag["UserId"] = "42";
    return new HttpResponse("ok");
});
```

## Ротация журналов

Вы можете настроить HTTP‑сервер так, чтобы он вращал файлы журналов в сжатый .gz файл, когда они достигают определённого размера. Размер проверяется периодически согласно заданному порогу.

```cs
LogStream errorLog = new LogStream("logs/error.log")
    .ConfigureRotatingPolicy(
        maximumSize: 64 * SizeHelper.UnitMb,
        dueTime: TimeSpan.FromHours(6));
```

Приведённый код будет каждые шесть часов проверять, достиг ли файл LogStream лимита в 64 МБ. Если да, файл сжимается в .gz и затем `access.log` очищается.

Во время этого процесса запись в файл блокируется до завершения сжатия и очистки. Все строки, поступающие на запись в этот период, будут помещены в очередь, ожидая завершения сжатия.

Эта функция работает только с файловыми LogStream.

## Журналирование ошибок

Когда сервер не бросает ошибки в отладчик, он перенаправляет их в журнал, если они есть. Вы можете настроить запись ошибок с помощью:

```cs
config.ThrowExceptions = false;
config.ErrorsLogsStream = new LogStream("error.log");
```

Это свойство будет записывать в журнал только в том случае, если ошибка не перехвачена обратным вызовом или свойством [Router.CallbackErrorHandler](https://docs.sisk-framework.org/api/Sisk.Core.Routing.Router.CallbackErrorHandler.md).

Ошибка, записываемая сервером, всегда включает дату и время, заголовки запроса (не тело), трассировку ошибки и трассировку внутреннего исключения, если они есть.

## Другие экземпляры журналов

Ваше приложение может иметь ноль или несколько LogStream, нет ограничения на количество каналов журналов. Поэтому можно направлять журнал вашего приложения в файл, отличный от стандартного AccessLog или ErrorLog.

```cs
LogStream appMessages = new LogStream("messages.log");
appMessages.WriteLine("Application started at {0}", DateTime.Now);
```

## Безопасная запись файлов

Для файлов, которые не являются потоками журналов, например отчётов или дампов состояния, [LogStream](https://docs.sisk-framework.org/api/Sisk.Core.Http.LogStream.md) предоставляет статические вспомогательные методы, которые создают недостающий родительский каталог, сериализуют конкурентные записи в один и тот же файл внутри процесса и возвращают `false` вместо выброса исключения при неудачной операции:

- [LogStream.SafeWriteToFile](https://docs.sisk-framework.org/api/Sisk.Core.Http.LogStream.SafeWriteToFile.md) и [LogStream.SafeWriteToFileAsync](https://docs.sisk-framework.org/api/Sisk.Core.Http.LogStream.SafeWriteToFileAsync.md) заменяют содержимое файла.
- [LogStream.SafeAppendToFile](https://docs.sisk-framework.org/api/Sisk.Core.Http.LogStream.SafeAppendToFile.md) и [LogStream.SafeAppendToFileAsync](https://docs.sisk-framework.org/api/Sisk.Core.Http.LogStream.SafeAppendToFileAsync.md) добавляют данные в конец файла.

Каждый метод принимает строку или массив строк и имеет перегрузки, принимающие `Encoding`.

```cs
bool written = await LogStream.SafeAppendToFileAsync("logs/audit.log", $"{DateTime.Now:s} user signed in\n");

if (!written)
{
    Console.WriteLine("Could not write the audit entry.");
}
```

Блокировка действует в пределах процесса: записи из других процессов в тот же файл не координируются. Если вам нужно лишь убедиться, что каталог существует, используйте [PathHelper.EnsureDirectoryExistance](https://docs.sisk-framework.org/api/Sisk.Core.Helpers.PathHelper.EnsureDirectoryExistance.md) или [PathHelper.EnsureFileDirectoryExistance](https://docs.sisk-framework.org/api/Sisk.Core.Helpers.PathHelper.EnsureFileDirectoryExistance.md), которые возвращают `true`, если каталог уже существовал, и `false`, если он был создан.

## Расширение LogStream

Вы можете расширить класс `LogStream` для записи пользовательских форматов, совместимых с текущим движком журналов Sisk. Пример ниже позволяет выводить цветные сообщения в консоль с помощью библиотеки Spectre.Console:

```cs {title="CustomLogStream.cs"}
public class CustomLogStream : LogStream
{
    protected override void WriteLineInternal(string line)
    {
        base.WriteLineInternal($"[{DateTime.Now:g}] {line}");
    }
}
```

Другой способ автоматически записывать пользовательские журналы для каждого запроса/ответа — создать [HttpServerHandler](https://docs.sisk-framework.org/api/Sisk.Core.Http.Handlers.HttpServerHandler.md). Пример ниже более полный. Он записывает тело запроса и ответа в формате JSON в консоль. Это может быть полезно для отладки запросов в целом. В примере используется ContextBag и HttpServerHandler.

```cs {title="Program.cs"}
class Program
{
    static async Task Main(string[] args)
    {
        var app = HttpServer.CreateBuilder(host =>
        {
            host.UseListeningPort(5555);
            host.UseHandler<JsonMessageHandler>();
        });

        app.Router.MapAny("/json", request =>
        {
            return new HttpResponse()
                .WithContent(JsonContent.Create(new
                {
                    method = request.Method.Method,
                    path = request.Path,
                    specialMessage = "Hello, world!!"
                }));
        });

        await app.StartAsync();
    }
}
```

```cs {title="JsonMessageHandler.cs"}
class JsonMessageHandler : HttpServerHandler
{
    protected override void OnHttpRequestOpen(HttpRequest request)
    {
        if (request.Method != HttpMethod.Get && request.Headers["Content-Type"]?.Contains("json", StringComparison.InvariantCultureIgnoreCase) == true)
        {
            // На данном этапе соединение открыто, и клиент отправил заголовок, указывающий
            // что содержимое является JSON. Следующая строка читает содержимое и сохраняет его в запросе.
            //
            // Если содержимое не будет прочитано в обработчике запроса, сборщик мусора, вероятно, соберёт его
            // после отправки ответа клиенту, поэтому содержимое может быть недоступно после закрытия ответа.
            //
            _ = request.RawBody;

            // добавить подсказку в контекст, указывающую, что у этого запроса есть тело JSON
            request.Bag.Add("IsJsonRequest", true);
        }
    }

    protected override async void OnHttpRequestClose(HttpServerExecutionResult result)
    {
        string? requestJson = null,
                responseJson = null,
                responseMessage;

        if (result.Request.Bag.ContainsKey("IsJsonRequest"))
        {
            // переформатировать JSON с использованием библиотеки CypherPotato.LightJson
            var content = result.Request.Body;
            requestJson = JsonValue.Deserialize(content, new JsonOptions() { WriteIndented = true }).ToString();
        }
        
        if (result.Response is { } response)
        {
            var content = response.Content;
            responseMessage = $"{(int)response.Status} {HttpStatusInformation.GetStatusCodeDescription(response.Status)}";
            
            if (content is HttpContent httpContent &&
                // проверить, является ли ответ JSON
                httpContent.Headers.ContentType?.MediaType?.Contains("json", StringComparison.InvariantCultureIgnoreCase) == true)
            {
                string json = await httpContent.ReadAsStringAsync();
                responseJson = JsonValue.Deserialize(json, new JsonOptions() { WriteIndented = true }).ToString();
            }
        }
        else
        {
            // получить статус внутренней обработки сервером
            responseMessage = result.Status.ToString();
        }
        
        StringBuilder outputMessage = new StringBuilder();

        if (requestJson != null)
        {
            outputMessage.AppendLine("-----");
            outputMessage.AppendLine($">>> {result.Request.Method} {result.Request.Path}");

            if (requestJson is not null)
                outputMessage.AppendLine(requestJson);
        }

        outputMessage.AppendLine($"<<< {responseMessage}");

        if (responseJson != null)
            outputMessage.AppendLine(responseJson);

        outputMessage.AppendLine("-----");

        await Console.Out.WriteLineAsync(outputMessage.ToString());
    }
}
```

</document>
