Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.template
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
SERVICE_AUTH_SECRET=change-me-to-a-long-random-string
17 changes: 12 additions & 5 deletions OBSERVABILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,16 @@
на своём эндпоинте `/metrics`, а Prometheus скрейпит их напрямую.

- Scrapper: `http://scrapper:8081/metrics`
- Bot: `http://bot:8011/metrics` (отдельный Kestrel-эндпоинт, наружу не публикуется)
- Bot: `http://bot:8011/metrics` (отдельный Kestrel-эндпоинт)
- AI-Agent: `http://aiagent:8102/metrics` (отдельный Kestrel-эндпоинт)

Эндпоинт `/metrics` исключён из общего rate limiter — иначе скрейп раз в 15 секунд
конкурировал бы за лимит с прикладным трафиком и метрики выглядели бы «пропавшими».
Порты приложений не публикуются на хост — Prometheus скрейпит цели изнутри
сети compose.

Rate limiter применяется точечно, политикой `public-api` на прикладных маршрутах,
поэтому `/metrics` и gRPC-сервисы под лимит не попадают в принципе.
Партиционирование идёт по `Tg-Chat-Id`, а не по IP: в Docker весь трафик бота
приходит с одного адреса, и лимит по IP отсекал бы легитимные запросы.

Имена серий, которые видит Prometheus, зафиксированы тестом
`MetricsEndpointTests` — экспортёр переименовывает инструменты, и панели Grafana
Expand All @@ -23,7 +29,7 @@
| Метрика | Тип | Лейблы | Описание |
|---|---|---|---|
| `links_on_track_total` | Gauge | `tracked_source` | Количество ссылок в БД на мониторинге |
| `api_requests_total` | Counter | `source` | Счётчик запросов к API |
| `api_requests_total` | Counter | `source` | Счётчик запросов к API (лейбл — шаблон маршрута, не сырой путь) |
| `request_duration_ms_total` | Histogram | `scope`, `scope_type` | Длительность операции в мс (RED: Duration) |
| `errors_total` | Counter | `scope`, `scope_type`, `reason` | **Ошибки (RED: Errors)** |
| `sent_updates_total` | Counter | — | Количество обновлений, отправленных в Bot |
Expand Down Expand Up @@ -58,12 +64,13 @@

Настраивается на стороне Prometheus в `monitoring/prometheus.yml` — приложениям
никакой конфигурации телеметрии не требуется. Лейбл `job` берётся из имени scrape-job
(`scrapper` / `bot`), `instance` — из адреса цели.
(`scrapper` / `bot` / `aiagent`), `instance` — из адреса цели.

| Job | Цель | Интервал |
|---|---|---|
| `scrapper` | `scrapper:8081` | 15s (`global.scrape_interval`) |
| `bot` | `bot:8011` | 15s (`global.scrape_interval`) |
| `aiagent` | `aiagent:8102` | 15s (`global.scrape_interval`) |

## Grafana

Expand Down
18 changes: 13 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,23 @@ cp src/LinkTracker.Bot.Api/.env.template src/LinkTracker.Bot.Api/.env
cp src/LinkTracker.Scrapper.Api/.env.template src/LinkTracker.Scrapper.Api/.env
```
4. Поставить действительные параметры в .env.
5. Создать копию [.env.template](src/LinkTracker.AiAgent/.env.template) с именем .env в каталоге ии-агента.
5. Создать копию [.env.template](src/LinkTracker.AiAgent.Api/.env.template) с именем .env в каталоге ии-агента.
```
cp src/LinkTracker.AiAgent.Api/.env.template src/LinkTracker.AiAgent.Api/.env
```
6. Поставить действительные параметры в .env.
7. Задать общий сервисный секрет. Bot и Scrapper аутентифицируют друг друга по нему,
поэтому значение должно совпадать в обоих сервисах.
```
cp .env.template .env
```
8. Поставить в корневой .env своё значение `SERVICE_AUTH_SECRET`, а в
`src/LinkTracker.Bot.Api/.env` и `src/LinkTracker.Scrapper.Api/.env` — то же самое
значение в `ServiceAuth__Secret` (нужно для локального запуска без Docker).

### Для локального запуска
7. В [bot.Api.appesettings](src/LinkTracker.Bot.Api/appsettings.json), [scrapper.Api.appsettings](src/LinkTracker.Scrapper.Api/appsettings.json) и [aiagent.appsettings](src/LinkTracker.AiAgent.Api/appsettings.json) в ветках Scrapper, Bot и AiAgent соответственно выбрать валидные параметры.
8. Выполнить поочердено запуск сначала [docker-compose](docker-compose.yml), [Scrapper](src/LinkTracker.Scrapper.Api/Program.cs), [Bot](src/LinkTracker.Bot.Api/Program.cs) и [AiAgent](src/LinkTracker.AiAgent.Api/Program.cs) с помощью команд.
9. В [bot.Api.appsettings](src/LinkTracker.Bot.Api/appsettings.json), [scrapper.Api.appsettings](src/LinkTracker.Scrapper.Api/appsettings.json) и [aiagent.Api.appsettings](src/LinkTracker.AiAgent.Api/appsettings.json) в ветках Scrapper, Bot и AiAgent соответственно выбрать валидные параметры.
10. Выполнить поочердено запуск сначала [docker-compose](docker-compose.yml), [Scrapper](src/LinkTracker.Scrapper.Api/Program.cs), [Bot](src/LinkTracker.Bot.Api/Program.cs) и [AiAgent](src/LinkTracker.AiAgent.Api/Program.cs) с помощью команд.
```
docker compose -f docker-compose.yml up
dotnet run --project src/LinkTracker.Scrapper.Api
Expand All @@ -78,8 +86,8 @@ dotnet run --project src/LinkTracker.AiAgent.Api
```

### Для запуска в контейнерах
7. В [bot.Docker.appsettings](src/LinkTracker.Bot.Api/appsettings.Docker.json), [scrapper.Docker.appsettings](src/LinkTracker.Scrapper.Api/appsettings.Docker.json) и [aiagent.Docker.appesettings](src/LinkTracker.AiAgent.Api/appsettings.Docker.json) в ветках Scrapper, Bot и AiAgent соответственно выбрать валидные параметры.
8. Выполнить поочердено запуск сначала [docker-compose](docker-compose.yml), [Scrapper](src/LinkTracker.Scrapper.Api/Program.cs), а затем [Bot](src/LinkTracker.Bot.Api/Program.cs) с помощью команд.
9. В [bot.Docker.appesettings](src/LinkTracker.Bot.Api/appsettings.Docker.json), [scrapper.Docker.appsettings](src/LinkTracker.Scrapper.Api/appsettings.Docker.json) и [aiagent.Docker.appesettings](src/LinkTracker.AiAgent.Api/appsettings.Docker.json) в ветках Scrapper, Bot и AiAgent соответственно выбрать валидные параметры.
10. Выполнить поочердено запуск сначала [docker-compose](docker-compose.yml), [Scrapper](src/LinkTracker.Scrapper.Api/Program.cs), а затем [Bot](src/LinkTracker.Bot.Api/Program.cs) с помощью команд.
```
docker compose -f docker-compose.yml -f docker-compose.apps.yml up
```
Expand Down
27 changes: 16 additions & 11 deletions docker-compose.apps.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
x-service-auth-env: &service-auth-env
ServiceAuth__Secret: "${SERVICE_AUTH_SECRET:?SERVICE_AUTH_SECRET must be set in the root .env file}"

services:
scrapper:
build:
Expand All @@ -15,12 +18,13 @@ services:
condition: service_started
valkey-cluster-init:
condition: service_completed_successfully
ports:
- "8081:8081"
- "8082:8082"
expose:
- "8081"
- "8082"
env_file:
- src/LinkTracker.Scrapper.Api/.env
environment:
<<: *service-auth-env
ASPNETCORE_ENVIRONMENT: "Docker"

bot:
Expand All @@ -37,13 +41,14 @@ services:
condition: service_completed_successfully
schema-registry:
condition: service_started
ports:
- "8091:8091"
- "8092:8092"
- "8011:8011"
expose:
- "8091"
- "8092"
- "8011"
env_file:
- src/LinkTracker.Bot.Api/.env
environment:
<<: *service-auth-env
ASPNETCORE_ENVIRONMENT: "Docker"

aiagent:
Expand All @@ -64,10 +69,10 @@ services:
condition: service_healthy
kafka-3:
condition: service_healthy
ports:
- "8101:8101"
- "8102:8102"
expose:
- "8101"
- "8102"
env_file:
- src/LinkTracker.AiAgent.Api/.env
environment:
ASPNETCORE_ENVIRONMENT: "Docker"
ASPNETCORE_ENVIRONMENT: "Docker"
2 changes: 2 additions & 0 deletions src/LinkTracker.AiAgent.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
using LinkTracker.AiAgent.Infrastructure.Clients.Registration;
using LinkTracker.AiAgent.Infrastructure.Telemetry.Registration;
using LinkTracker.EnvReader;
using LinkTracker.Shared.Infrastructure.Logging;
using LinkTracker.Shared.Infrastructure.Telemetry;

var builder = WebApplication.CreateBuilder(args);

builder.AddLocalDotEnv();
builder.AddSharedSerilog("aiagent");

builder.Services.AddAiAgentApplication();
builder.Services.AddAiAgentInfrastructure(builder.Configuration);
Expand Down
3 changes: 2 additions & 1 deletion src/LinkTracker.Bot.Api/.env.template
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
Bot__Token=123:ABC
Bot__Token=123:ABC
ServiceAuth__Secret=change-me-to-a-long-random-string
35 changes: 19 additions & 16 deletions src/LinkTracker.Bot.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,29 @@
using LinkTracker.Bot.Application.Dialogs.Registration;
using LinkTracker.Bot.Application.Routing.Registration;
using LinkTracker.Bot.Infrastructure.Clients.Registration;
using LinkTracker.Bot.Infrastructure.Logging;
using LinkTracker.Bot.Infrastructure.Storage.Registration;
using LinkTracker.Bot.Infrastructure.Telemetry.Registration;
using LinkTracker.Bot.Presentation.BotApi.Endpoints;
using LinkTracker.Bot.Presentation.BotApi.Registration;
using LinkTracker.Bot.Presentation.Grpc;
using LinkTracker.Bot.Presentation.Telegram.Registration;
using LinkTracker.EnvReader;
using LinkTracker.Shared.Infrastructure.Authentication;
using LinkTracker.Shared.Infrastructure.Logging;
using LinkTracker.Shared.Infrastructure.RateLimiting;
using LinkTracker.Shared.Infrastructure.Resilience;
using LinkTracker.Shared.Infrastructure.Telemetry;
using Serilog;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSerilog((sp, lc) => SerilogConfig.Configure(lc));
builder.AddLocalDotEnv();
builder.AddSharedSerilog("bot");

builder.Services.AddGrpc();
builder.Services.AddHttpResilienceOptions(builder.Configuration);
builder.Services.AddIpRateLimiting(builder.Configuration);
builder.Services.AddApiRateLimiting(builder.Configuration);
builder.Services.AddServiceAuthentication(builder.Configuration);
builder.Services.AddServiceAuthClients(builder.Configuration);

builder.Services.AddCommands();
builder.Services.AddUpdateRouting();
Expand All @@ -36,17 +38,18 @@

var app = builder.Build();

app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();

app.MapBotApi();
app.MapGrpcService<BotUpdatesGrpcService>();

try
{
app.MapMetricsEndpoint().RequireHost("*:8011");
await app.RunAsync();
}
finally
{
Log.CloseAndFlush();
}
app.MapBotApi()
.RequireServiceAuthorization()
.RequireRateLimiting(RateLimitingPolicies.PublicApi);

app.MapGrpcService<BotUpdatesGrpcService>()
.RequireServiceAuthorization();

app.MapMetricsEndpoint().RequireHost("*:8011");

await app.RunAsync();
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,13 @@ public async Task<string> CancelAsync(long chatId, CancellationToken ct)
return "Ок, отменил. Напиши /help";
}

public async Task ResetAsync(long chatId, CancellationToken ct)
public async Task<bool> ResetAsync(long chatId, CancellationToken ct)
{
var ctx = await store.GetOrCreateAsync(chatId, ct);
var hadActiveDialog = ctx.HasActiveDialog;

await store.ResetAsync(chatId, ct);

return hadActiveDialog;
}
}
12 changes: 9 additions & 3 deletions src/LinkTracker.Bot.Application/Routing/UpdateRouter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,20 @@ public sealed class UpdateRouter(
DialogManager dialogManager,
IBotMetrics metrics)
{
private const string DialogInterruptedNotice = "Прерван незавершённый диалог, введённые данные не сохранены.";

public async Task<OutgoingMessage> RouteAsync(BotRequest request, CancellationToken ct)
{
metrics.IncrementRequest(request.Type.ToString());

if (IsCommand(request))
{
await dialogManager.ResetAsync(request.ChatId, ct);
return await RouteCommandAsync(request, ct);
var dialogInterrupted = await dialogManager.ResetAsync(request.ChatId, ct);
var message = await RouteCommandAsync(request, ct);

return dialogInterrupted
? message with { Text = $"{DialogInterruptedNotice}{Environment.NewLine}{Environment.NewLine}{message.Text}" }
: message;
}

var (handled, replyText) = await dialogManager.TryHandleAsync(request, ct);
Expand All @@ -45,4 +51,4 @@ private static string ToCommandText(BotRequest request)
{
return request.Command is null ? string.Empty : $"/{request.Command}";
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Confluent.Kafka;
using Confluent.SchemaRegistry;
using Confluent.SchemaRegistry.Serdes;
using Grpc.Core.Interceptors;
using Grpc.Net.Client;
using LinkTracker.Bot.Application.Clients.Scrapper;
using LinkTracker.Bot.Infrastructure.Abstractions.Kafka;
Expand All @@ -13,6 +14,7 @@
using LinkTracker.Bot.Infrastructure.Kafka.Deserialization;
using LinkTracker.Grpc;
using LinkTracker.Shared.Infrastructure;
using LinkTracker.Shared.Infrastructure.Authentication;
using LinkTracker.Shared.Infrastructure.Resilience;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
Expand Down Expand Up @@ -50,6 +52,7 @@ public static IServiceCollection AddClients(
var options = sp.GetRequiredService<IOptions<ScrapperOptions>>().Value;
client.BaseAddress = new Uri(options.BaseUrl);
})
.AddHttpMessageHandler<ServiceAuthHeaderHandler>()
.AddConfiguredHttpResilience("bot-to-scrapper", httpResilienceOptions);

services.AddSingleton(sp =>
Expand All @@ -60,8 +63,11 @@ public static IServiceCollection AddClients(

services.AddSingleton(sp =>
{
var channel = sp.GetRequiredService<GrpcChannel>();
return new ScrapperGrpc.ScrapperGrpcClient(channel);
var invoker = sp.GetRequiredService<GrpcChannel>()
.CreateCallInvoker()
.Intercept(sp.GetRequiredService<ServiceAuthClientInterceptor>());

return new ScrapperGrpc.ScrapperGrpcClient(invoker);
});

services.AddSingleton<ScrapperGrpcClient>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,6 @@
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.5"/>
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.5"/>
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1"/>
<PackageReference Include="Serilog" Version="4.3.0"/>
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0"/>
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1"/>
<PackageReference Include="Grpc.Net.Client" Version="2.76.0"/>
<PackageReference Include="Google.Protobuf" Version="3.34.0"/>
<PackageReference Include="Confluent.Kafka" Version="2.14.0"/>
Expand Down
13 changes: 0 additions & 13 deletions src/LinkTracker.Bot.Infrastructure/Logging/SerilogConfig.cs

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ namespace LinkTracker.Bot.Presentation.BotApi.Endpoints;

public static class LinkUpdateEndpoints
{
public static IEndpointRouteBuilder MapBotApi(this IEndpointRouteBuilder app)
public static RouteGroupBuilder MapBotApi(this IEndpointRouteBuilder builder)
{
var app = builder.MapGroup(string.Empty);

app.MapPost("/updates", HandleUpdateAsync)
.WithName("HandleUpdate")
.WithSummary("Отправить обновление")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
var receiverOptions = new ReceiverOptions { AllowedUpdates = Array.Empty<UpdateType>() };

var me = await bot.GetMe(stoppingToken);
logger.LogInformation("Бот {Username} успешно запущен. Нажми Enter чтобы остановить.", me.Username);
logger.LogInformation("Бот {Username} успешно запущен, начат приём обновлений.", me.Username);

try
{
Expand Down
31 changes: 29 additions & 2 deletions src/LinkTracker.EnvReader/DotEnvConfigurationExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration.EnvironmentVariables;
using Microsoft.Extensions.Configuration.Memory;

namespace LinkTracker.EnvReader;

Expand All @@ -15,6 +17,31 @@ public static IConfigurationBuilder AddDotEnv(
return builder;
}

return builder.AddInMemoryCollection(pairs);
var source = new MemoryConfigurationSource { InitialData = pairs };
var environmentVariablesIndex = IndexOfEnvironmentVariables(builder.Sources);

if (environmentVariablesIndex < 0)
{
builder.Sources.Add(source);
}
else
{
builder.Sources.Insert(environmentVariablesIndex, source);
}

return builder;
}

private static int IndexOfEnvironmentVariables(IList<IConfigurationSource> sources)
{
for (var index = 0; index < sources.Count; index++)
{
if (sources[index] is EnvironmentVariablesConfigurationSource)
{
return index;
}
}

return -1;
}
}
}
Loading
Loading