Добавить актора в access-лог HTTP
Строка access-лога включает actor (login пользователя/оператора) и tenant из HttpContext.Items. Закрывает BL-LOG-ACTOR.
This commit is contained in:
+1
-1
@@ -43,7 +43,7 @@
|
|||||||
| ID | Пункт | Источник | Приоритет | Статус |
|
| ID | Пункт | Источник | Приоритет | Статус |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| BL-ALERT-BUDGET | **Сделано (2026-09-11):** метрика `deal.ai.budget.used.ratio{tenant}` (доля израсходованного ИИ-бюджета периода, 0..1) в `DealMetrics` + сбор в `RuntimeDepthsCollector`/`DealMetricsCollector`; на её основе оператор настраивает алерт в Prometheus/Grafana | этап 12, A | P2 | DONE |
|
| BL-ALERT-BUDGET | **Сделано (2026-09-11):** метрика `deal.ai.budget.used.ratio{tenant}` (доля израсходованного ИИ-бюджета периода, 0..1) в `DealMetrics` + сбор в `RuntimeDepthsCollector`/`DealMetricsCollector`; на её основе оператор настраивает алерт в Prometheus/Grafana | этап 12, A | P2 | DONE |
|
||||||
| BL-LOG-ACTOR | Идентичность актора в access-логах (сейчас login/tenant только в `audit_log`) | этап 12, T6 | P3 | TECHDEBT |
|
| BL-LOG-ACTOR | **Сделано (2026-09-11):** access-лог HTTP core (`HttpAccessLogMiddleware`) включает `actor` (login пользователя тенанта либо оператора) и `tenant` (id тенанта) — их берут из `HttpContext.Items` (Session/OperatorSession middleware) | этап 12, T6 | P3 | DONE |
|
||||||
| BL-GRACEFUL | Дополнительные проверки устойчивости/ретраев (по результатам нагрузочного прогона) | этап 12, C | P2 | BACKLOG |
|
| BL-GRACEFUL | Дополнительные проверки устойчивости/ретраев (по результатам нагрузочного прогона) | этап 12, C | P2 | BACKLOG |
|
||||||
| BL-SUSPICIOUS | Расширение детектора подозрительной активности (правила/пороги по логам безопасности) | ТЗ §10.5, этап 12 | P3 | BACKLOG |
|
| BL-SUSPICIOUS | Расширение детектора подозрительной активности (правила/пороги по логам безопасности) | ТЗ §10.5, этап 12 | P3 | BACKLOG |
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,10 @@
|
|||||||
> `SourceIngressGrpcService`), `PushMessage` из telegram.proto удалён. Сухой прогон текста по конвейеру
|
> `SourceIngressGrpcService`), `PushMessage` из telegram.proto удалён. Сухой прогон текста по конвейеру
|
||||||
> (стоп-правила → ML → ИИ) без записи: `POST /api/admin/check-message` + UI настроек. Remote-просмотр
|
> (стоп-правила → ML → ИИ) без записи: `POST /api/admin/check-message` + UI настроек. Remote-просмотр
|
||||||
> исходника: `TelegramService.ReadSource` + `TelegramSourceContentProvider` + UI «Обновить из источника».
|
> исходника: `TelegramService.ReadSource` + `TelegramSourceContentProvider` + UI «Обновить из источника».
|
||||||
> Метрика алертинга `deal.ai.budget.used.ratio{tenant}`. Hardening контейнеров (non-root/read-only/limits),
|
> Метрика алертинга `deal.ai.budget.used.ratio{tenant}`; actor/tenant в access-логе. Hardening контейнеров
|
||||||
> шардированная пакетная миграция схем. Ядро: build 5 sln 0/0, `Deal.Tests.Unit` **1315/1315 PASS**,
|
> (non-root/read-only/limits), шардированная пакетная миграция схем, единый CI (`scripts/ci.sh`).
|
||||||
> telegram **130/130**, фронт `build` + `lint:i18n` зелёные.
|
> Ядро: build 5 sln 0/0, `Deal.Tests.Unit` **1321/1321 PASS**, telegram **130/130**, ai **52/52**,
|
||||||
|
> ml **38/38**, storage **9/9**, фронт `build` + `lint:i18n` зелёные.
|
||||||
> Детали — `docs/superpowers/specs/2026-09-11-source-contract-design.md`.
|
> Детали — `docs/superpowers/specs/2026-09-11-source-contract-design.md`.
|
||||||
> Осталось (в backlog): `GET /api/cards/{id}/source` + `ISourceContentProvider`, выгрузка вложений
|
> Осталось (в backlog): `GET /api/cards/{id}/source` + `ISourceContentProvider`, выгрузка вложений
|
||||||
> telegram-адаптером в Storage, `TelegramSourceContentProvider`, перенос оставшейся Telegram-специфики
|
> telegram-адаптером в Storage, `TelegramSourceContentProvider`, перенос оставшейся Telegram-специфики
|
||||||
|
|||||||
@@ -227,7 +227,8 @@ settings(Key varchar(200) PK, ValueJson text, UpdatedAt timestamptz) --
|
|||||||
(30 дней; env `DEAL_LOG_LEVEL`/`DEAL_LOGS_DIR`). Секреты/пароли/ключи не логируются;
|
(30 дней; env `DEAL_LOG_LEVEL`/`DEAL_LOGS_DIR`). Секреты/пароли/ключи не логируются;
|
||||||
gRPC-health не логируется.
|
gRPC-health не логируется.
|
||||||
- **Access-логи**: HTTP — `HttpAccessLogMiddleware` (первый в конвейере после ForwardedHeaders —
|
- **Access-логи**: HTTP — `HttpAccessLogMiddleware` (первый в конвейере после ForwardedHeaders —
|
||||||
длительность и статус всего пути); gRPC-ингресс — `RpcCallLoggingInterceptor` (health освобождён).
|
длительность и статус всего пути; с BL-LOG-ACTOR — `actor` и `tenant` из сессии); gRPC-ингресс —
|
||||||
|
`RpcCallLoggingInterceptor` (health освобождён).
|
||||||
- **PROD-стек логов**: docker-логи контейнеров → `promtail` → `loki` (retention 7 суток) → `grafana`
|
- **PROD-стек логов**: docker-логи контейнеров → `promtail` → `loki` (retention 7 суток) → `grafana`
|
||||||
(`127.0.0.1:3001` — только оператору по SSH-туннелю). Поднимается профилем `observability`
|
(`127.0.0.1:3001` — только оператору по SSH-туннелю). Поднимается профилем `observability`
|
||||||
файла `deploy/compose.prod.yml` (живой подъём — ⚠ Manual):
|
файла `deploy/compose.prod.yml` (живой подъём — ⚠ Manual):
|
||||||
@@ -251,9 +252,9 @@ settings(Key varchar(200) PK, ValueJson text, UpdatedAt timestamptz) --
|
|||||||
- `Deal-Errors` — HTTP 5xx, необработанные исключения (`@x`), Error/Fatal, ошибки gRPC и общая лента;
|
- `Deal-Errors` — HTTP 5xx, необработанные исключения (`@x`), Error/Fatal, ошибки gRPC и общая лента;
|
||||||
- `Deal-Rps` — нагрузка HTTP+gRPC (RPS), top-путей/методов и p50/p95 длительности запроса;
|
- `Deal-Rps` — нагрузка HTTP+gRPC (RPS), top-путей/методов и p50/p95 длительности запроса;
|
||||||
- `Deal-Logs` — обзор логов с фильтрами по сервису и уровню, активность по тенантам (AI/ML/Telegram).
|
- `Deal-Logs` — обзор логов с фильтрами по сервису и уровню, активность по тенантам (AI/ML/Telegram).
|
||||||
**Кто входил/что делал — в аудите, не в логах**: access-лог пишет только метод/путь/код (без
|
**Актор в логах:** с BL-LOG-ACTOR access-лог включает `actor` (login пользователя/оператора) и
|
||||||
логина/тенанта/IP), поэтому входы в Loki различаются лишь по контуру. Полная лента с актором —
|
`tenant`; полная лента действий с деталями — `public.audit_log` (append-only) через
|
||||||
`public.audit_log` (append-only) через `GET /api/operator/audit` / экран «Аудит» оператор-консоли.
|
`GET /api/operator/audit` / экран «Аудит» оператор-консоли.
|
||||||
- Алерты Prometheus (этап 12) — правила `deploy/observability/prometheus-rules.yml` (см. подраздел
|
- Алерты Prometheus (этап 12) — правила `deploy/observability/prometheus-rules.yml` (см. подраздел
|
||||||
«Метрики»); исчерпание ИИ-бюджета по-прежнему доставляется SSE-тостом тенанту — отдельной
|
«Метрики»); исчерпание ИИ-бюджета по-прежнему доставляется SSE-тостом тенанту — отдельной
|
||||||
бюджетной метрики в Prometheus нет (метки метрик низкокардинальные, без tenantId/бюджета).
|
бюджетной метрики в Prometheus нет (метки метрик низкокардинальные, без tenantId/бюджета).
|
||||||
@@ -1310,8 +1311,8 @@ docker compose -f deploy/compose.dev.yml start core telegram-service ml-service
|
|||||||
→ **оператор:** `http://localhost:5173/#/operator`, вход `operator`/`operator` (dev-дефолт; в Production —
|
→ **оператор:** `http://localhost:5173/#/operator`, вход `operator`/`operator` (dev-дефолт; в Production —
|
||||||
env `DEAL_OPERATOR_*`); **активация:** `http://localhost:5173/#/join?code=<код>`; основное приложение —
|
env `DEAL_OPERATOR_*`); **активация:** `http://localhost:5173/#/join?code=<код>`; основное приложение —
|
||||||
`http://localhost:5173/#/`. Prod-сценарий — §13.8.
|
`http://localhost:5173/#/`. Prod-сценарий — §13.8.
|
||||||
- **Ограничения:** реальные Telegram/LLM-креды — ⚠ Manual (по решению владельца); identity в логах
|
- **Ограничения:** реальные Telegram/LLM-креды — ⚠ Manual (по решению владельца); access-лог с
|
||||||
ограничена (access-лог без логина) — полный аудит с актором в `public.audit_log`.
|
BL-LOG-ACTOR содержит `actor`/`tenant` (IP в строке нет) — полный аудит действий в `public.audit_log`.
|
||||||
|
|
||||||
## 14. Локализация интерфейса (i18n, этап 11)
|
## 14. Локализация интерфейса (i18n, этап 11)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using Deal.Api.Extensions;
|
||||||
|
using Deal.Api.Models;
|
||||||
|
|
||||||
namespace Deal.Api.Middleware;
|
namespace Deal.Api.Middleware;
|
||||||
|
|
||||||
@@ -53,7 +55,7 @@ public sealed class HttpAccessLogMiddleware
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Пишет одну строку access-лога запроса (успех либо исключение).
|
// Пишет одну строку access-лога запроса (успех либо исключение).
|
||||||
// context: Контекст запроса (метод/путь/статус ответа).
|
// context: Контекст запроса (метод/путь/статус ответа, актор из HttpContext.Items).
|
||||||
// startedAt: Метка времени старта запроса (Stopwatch.GetTimestamp).
|
// startedAt: Метка времени старта запроса (Stopwatch.GetTimestamp).
|
||||||
// exception: Необработанное исключение (null — запрос завершился штатно).
|
// exception: Необработанное исключение (null — запрос завершился штатно).
|
||||||
private void LogCall(
|
private void LogCall(
|
||||||
@@ -64,23 +66,46 @@ public sealed class HttpAccessLogMiddleware
|
|||||||
long elapsedMs = (long)Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds;
|
long elapsedMs = (long)Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds;
|
||||||
string method = context.Request.Method;
|
string method = context.Request.Method;
|
||||||
string path = context.Request.Path.Value ?? "/";
|
string path = context.Request.Path.Value ?? "/";
|
||||||
|
(string actor, string tenantId) = ResolveActor(context);
|
||||||
if (exception is null)
|
if (exception is null)
|
||||||
{
|
{
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"HTTP {Method} {Path} -> {StatusCode} за {DurationMs} мс",
|
"HTTP {Method} {Path} -> {StatusCode} за {DurationMs} мс; actor={Actor} tenant={TenantId}",
|
||||||
method,
|
method,
|
||||||
path,
|
path,
|
||||||
context.Response.StatusCode,
|
context.Response.StatusCode,
|
||||||
elapsedMs);
|
elapsedMs,
|
||||||
|
actor,
|
||||||
|
tenantId);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogError(
|
_logger.LogError(
|
||||||
exception,
|
exception,
|
||||||
"HTTP {Method} {Path} -> исключение за {DurationMs} мс",
|
"HTTP {Method} {Path} -> исключение за {DurationMs} мс; actor={Actor} tenant={TenantId}",
|
||||||
method,
|
method,
|
||||||
path,
|
path,
|
||||||
elapsedMs);
|
elapsedMs,
|
||||||
|
actor,
|
||||||
|
tenantId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Актор запроса: пользователь тенанта либо оператор; нет сессии — "-".
|
||||||
|
// context: Контекст запроса (Items заполняют Session/OperatorSession middleware ниже по конвейеру).
|
||||||
|
// Возвращает: Пару (актор, id тенанта) для структурированного access-лога.
|
||||||
|
private static (string Actor, string TenantId) ResolveActor(HttpContext context)
|
||||||
|
{
|
||||||
|
if (context.GetCurrentUser() is { } user)
|
||||||
|
{
|
||||||
|
return (user.Login, user.TenantId.ToString("N"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.GetCurrentOperator() is { } operatorIdentity)
|
||||||
|
{
|
||||||
|
return (operatorIdentity.Login, "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ("-", "-");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
using Deal.Api.Extensions;
|
||||||
|
using Deal.Api.Middleware;
|
||||||
|
using Deal.Api.Models;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Deal.Tests.Unit.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Юнит-тесты <see cref="HttpAccessLogMiddleware"/>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HttpAccessLogMiddlewareTests
|
||||||
|
{
|
||||||
|
private static readonly Guid TenantId = Guid.Parse("11112222-3333-4444-5555-666677778888");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_UserInItems_LogsActorLoginAndTenantId()
|
||||||
|
{
|
||||||
|
RecordingLogger logger = new();
|
||||||
|
var context = new DefaultHttpContext();
|
||||||
|
context.SetCurrentUser(new CurrentUser(Guid.NewGuid(), "user.login", TenantId, "active"));
|
||||||
|
HttpAccessLogMiddleware middleware = new(_ => Task.CompletedTask, logger);
|
||||||
|
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
LogEntry entry = Assert.Single(logger.Entries);
|
||||||
|
Assert.Equal(LogLevel.Information, entry.Level);
|
||||||
|
Assert.Contains($"actor=user.login tenant={TenantId:N}", entry.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_OperatorInItems_LogsActorLoginAndDashTenant()
|
||||||
|
{
|
||||||
|
RecordingLogger logger = new();
|
||||||
|
var context = new DefaultHttpContext();
|
||||||
|
context.SetCurrentOperator(new CurrentOperator(Guid.NewGuid(), "operator.login", "active"));
|
||||||
|
HttpAccessLogMiddleware middleware = new(_ => Task.CompletedTask, logger);
|
||||||
|
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
LogEntry entry = Assert.Single(logger.Entries);
|
||||||
|
Assert.Equal(LogLevel.Information, entry.Level);
|
||||||
|
Assert.Contains("actor=operator.login tenant=-", entry.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_NoSession_LogsDashActorAndTenant()
|
||||||
|
{
|
||||||
|
RecordingLogger logger = new();
|
||||||
|
var context = new DefaultHttpContext();
|
||||||
|
HttpAccessLogMiddleware middleware = new(_ => Task.CompletedTask, logger);
|
||||||
|
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
LogEntry entry = Assert.Single(logger.Entries);
|
||||||
|
Assert.Equal(LogLevel.Information, entry.Level);
|
||||||
|
Assert.Contains("actor=- tenant=-", entry.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_ResponseStatusCode_IsLogged()
|
||||||
|
{
|
||||||
|
RecordingLogger logger = new();
|
||||||
|
var context = new DefaultHttpContext();
|
||||||
|
HttpAccessLogMiddleware middleware = new(
|
||||||
|
ctx =>
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = StatusCodes.Status204NoContent;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
},
|
||||||
|
logger);
|
||||||
|
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
LogEntry entry = Assert.Single(logger.Entries);
|
||||||
|
Assert.Contains("-> 204", entry.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_NextThrows_LogsErrorWithActorAndRethrows()
|
||||||
|
{
|
||||||
|
RecordingLogger logger = new();
|
||||||
|
var context = new DefaultHttpContext();
|
||||||
|
context.SetCurrentUser(new CurrentUser(Guid.NewGuid(), "user.login", TenantId, "active"));
|
||||||
|
InvalidOperationException failure = new("ошибка конвейера");
|
||||||
|
HttpAccessLogMiddleware middleware = new(_ => throw failure, logger);
|
||||||
|
|
||||||
|
InvalidOperationException thrown = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => middleware.InvokeAsync(context));
|
||||||
|
|
||||||
|
Assert.Same(failure, thrown);
|
||||||
|
LogEntry entry = Assert.Single(logger.Entries);
|
||||||
|
Assert.Equal(LogLevel.Error, entry.Level);
|
||||||
|
Assert.Same(failure, entry.Exception);
|
||||||
|
Assert.Contains($"actor=user.login tenant={TenantId:N}", entry.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_GrpcContentType_SkipsLogging()
|
||||||
|
{
|
||||||
|
RecordingLogger logger = new();
|
||||||
|
var context = new DefaultHttpContext();
|
||||||
|
context.Request.ContentType = "application/grpc";
|
||||||
|
bool nextCalled = false;
|
||||||
|
HttpAccessLogMiddleware middleware = new(
|
||||||
|
_ =>
|
||||||
|
{
|
||||||
|
nextCalled = true;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
},
|
||||||
|
logger);
|
||||||
|
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
Assert.True(nextCalled);
|
||||||
|
Assert.Empty(logger.Entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory логгер: копит записи access-лога с уровнем, сообщением, исключением и полями.
|
||||||
|
private sealed class RecordingLogger : ILogger<HttpAccessLogMiddleware>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Записи лога в порядке поступления.
|
||||||
|
/// </summary>
|
||||||
|
public List<LogEntry> Entries { get; } = [];
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IDisposable? BeginScope<TState>(TState state)
|
||||||
|
where TState : notnull => null;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool IsEnabled(LogLevel logLevel) => true;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Log<TState>(
|
||||||
|
LogLevel logLevel,
|
||||||
|
EventId eventId,
|
||||||
|
TState state,
|
||||||
|
Exception? exception,
|
||||||
|
Func<TState, Exception?, string> formatter)
|
||||||
|
{
|
||||||
|
Dictionary<string, object?> fields = new(StringComparer.Ordinal);
|
||||||
|
if (state is IEnumerable<KeyValuePair<string, object?>> pairs)
|
||||||
|
{
|
||||||
|
foreach ((string key, object? value) in pairs)
|
||||||
|
{
|
||||||
|
fields[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Entries.Add(new LogEntry(logLevel, formatter(state, exception), exception, fields));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запись access-лога, снятая in-memory логгером.
|
||||||
|
private sealed record LogEntry(
|
||||||
|
LogLevel Level,
|
||||||
|
string Message,
|
||||||
|
Exception? Exception,
|
||||||
|
IReadOnlyDictionary<string, object?> Fields);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user