From df007cafde3d749a799e44ed0f8bf9cfa3b21325 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Wed, 14 Jan 2026 11:37:28 +0700 Subject: [PATCH] chore: Update compatibility for skills to .NET 10+ - Revised compatibility specifications in multiple skill documentation files to reflect support for .NET 10+, ensuring alignment with the latest development standards. - Updated files include API design, error handling patterns, project rules, repository pattern, security, and testing patterns. - This change enhances the relevance and usability of the skills for current and future development projects. --- .agent/skills/api-design/SKILL.md | 2 +- .agent/skills/cqrs-mediatr/SKILL.md | 454 +++++++++++ .../cqrs-mediatr/references/REFERENCE.md | 764 ++++++++++++++++++ .agent/skills/docker-traefik/SKILL.md | 401 +++++++++ .../docker-traefik/references/REFERENCE.md | 560 +++++++++++++ .../skills/error-handling-patterns/SKILL.md | 2 +- .agent/skills/observability/SKILL.md | 451 +++++++++++ .../observability/references/REFERENCE.md | 570 +++++++++++++ .agent/skills/project-rules/SKILL.md | 2 +- .agent/skills/repository-pattern/SKILL.md | 2 +- .agent/skills/security/SKILL.md | 2 +- .agent/skills/testing-patterns/SKILL.md | 2 +- 12 files changed, 3206 insertions(+), 6 deletions(-) create mode 100644 .agent/skills/cqrs-mediatr/SKILL.md create mode 100644 .agent/skills/cqrs-mediatr/references/REFERENCE.md create mode 100644 .agent/skills/docker-traefik/SKILL.md create mode 100644 .agent/skills/docker-traefik/references/REFERENCE.md create mode 100644 .agent/skills/observability/SKILL.md create mode 100644 .agent/skills/observability/references/REFERENCE.md diff --git a/.agent/skills/api-design/SKILL.md b/.agent/skills/api-design/SKILL.md index 3b70d822..e92529db 100644 --- a/.agent/skills/api-design/SKILL.md +++ b/.agent/skills/api-design/SKILL.md @@ -1,7 +1,7 @@ --- name: api-design description: RESTful API design standards for GoodGo microservices. Use for new API endpoints, DTOs, controllers, OpenAPI documentation, or standardized responses. -compatibility: ".NET 8+, ASP.NET Core, MediatR, Swashbuckle, Asp.Versioning" +compatibility: ".NET 10+, ASP.NET Core, MediatR, Swashbuckle, Asp.Versioning" metadata: author: Velik Ho version: "2.0" diff --git a/.agent/skills/cqrs-mediatr/SKILL.md b/.agent/skills/cqrs-mediatr/SKILL.md new file mode 100644 index 00000000..2280f8ee --- /dev/null +++ b/.agent/skills/cqrs-mediatr/SKILL.md @@ -0,0 +1,454 @@ +--- +name: cqrs-mediatr +description: CQRS pattern với MediatR. Use for Commands, Queries, Handlers, Pipeline Behaviors, và Idempotency. +compatibility: ".NET 10+, MediatR 12+, FluentValidation" +metadata: + author: Velik Ho + version: "1.0" +--- + +# CQRS & MediatR Patterns / Mẫu CQRS & MediatR + +CQRS pattern với MediatR cho GoodGo microservices. + +## When to Use This Skill / Khi Nào Sử Dụng + +Use this skill when: +- Separating read/write operations / Tách biệt operations đọc/ghi +- Creating Commands and Queries / Tạo Commands và Queries +- Implementing MediatR handlers / Triển khai MediatR handlers +- Adding cross-cutting concerns via Behaviors / Thêm behaviors xuyên suốt +- Ensuring idempotency / Đảm bảo tính idempotent + +## Core Concepts / Khái Niệm Cốt Lõi + +### CQRS Overview / Tổng Quan CQRS + +``` +┌─────────────────────────────────────────────────────────────┐ +│ API Controller │ +└─────────────────────┬───────────────────────────┬───────────┘ + │ │ + ┌───────▼───────┐ ┌───────▼───────┐ + │ Commands │ │ Queries │ + │ (Write/Ghi) │ │ (Read/Đọc) │ + └───────┬───────┘ └───────┬───────┘ + │ │ + ┌───────▼───────┐ ┌───────▼───────┐ + │ MediatR │ │ MediatR │ + │ Handler │ │ Handler │ + └───────┬───────┘ └───────┬───────┘ + │ │ + ┌───────▼───────┐ ┌───────▼───────┐ + │ Domain Model │ │ Dapper │ + │ EF Core │ │ Raw SQL │ + └───────┬───────┘ └───────┬───────┘ + │ │ + └───────────────┬───────────────────┘ + │ + ┌────────▼────────┐ + │ Database │ + └─────────────────┘ +``` + +### Command vs Query / Lệnh vs Truy Vấn + +| Aspect | Command | Query | +|--------|---------|-------| +| **Purpose** | Modify state | Read data | +| **Returns** | Result/void | Data/DTO | +| **Model** | Full Domain Model | Lightweight DTO | +| **ORM** | EF Core | Dapper/Raw SQL | +| **Validation** | Full business rules | Minimal | + +### MediatR Flow / Luồng MediatR + +``` +Request → Pipeline Behaviors → Handler → Response + ├── LoggingBehavior + ├── ValidationBehavior + └── TransactionBehavior +``` + +## Key Patterns / Mẫu Chính + +### Command Definition / Định Nghĩa Command + +```csharp +/// +/// EN: Command to create a new order. +/// VI: Command tạo order mới. +/// +public record CreateOrderCommand( + string UserId, + Address ShippingAddress, + List Items) : IRequest; + +/// +/// EN: Command result. +/// VI: Kết quả command. +/// +public record OrderResult(Guid OrderId); +``` + +### Query Definition / Định Nghĩa Query + +```csharp +/// +/// EN: Query to get user orders. +/// VI: Query lấy orders của user. +/// +public record GetUserOrdersQuery( + string UserId, + int Skip = 0, + int Take = 20) : IRequest>; + +/// +/// EN: Lightweight DTO for query results. +/// VI: DTO nhẹ cho kết quả query. +/// +public record OrderSummaryDto( + Guid Id, + string Status, + decimal TotalAmount, + DateTime CreatedAt, + int ItemCount); +``` + +### Command Handler / Handler Command + +```csharp +/// +/// EN: Handler for CreateOrderCommand. +/// VI: Handler cho CreateOrderCommand. +/// +public class CreateOrderCommandHandler + : IRequestHandler +{ + private readonly IOrderRepository _orderRepository; + private readonly ILogger _logger; + + public CreateOrderCommandHandler( + IOrderRepository orderRepository, + ILogger logger) + { + _orderRepository = orderRepository; + _logger = logger; + } + + public async Task Handle( + CreateOrderCommand request, + CancellationToken ct) + { + // EN: Create order through domain model + // VI: Tạo order qua domain model + var order = new Order(request.UserId, request.ShippingAddress); + + foreach (var item in request.Items) + { + order.AddItem(item.ProductId, item.Quantity, item.UnitPrice); + } + + await _orderRepository.AddAsync(order, ct); + await _orderRepository.UnitOfWork.SaveChangesAsync(ct); + + _logger.LogInformation( + "EN: Order created / VI: Order đã tạo: {OrderId}", + order.Id); + + return new OrderResult(order.Id); + } +} +``` + +### Query Handler with Dapper / Handler Query với Dapper + +```csharp +/// +/// EN: Query handler using Dapper for optimized reads. +/// VI: Query handler dùng Dapper cho đọc tối ưu. +/// +public class GetUserOrdersQueryHandler + : IRequestHandler> +{ + private readonly IDbConnection _connection; + + public GetUserOrdersQueryHandler(IDbConnection connection) + { + _connection = connection; + } + + public async Task> Handle( + GetUserOrdersQuery request, + CancellationToken ct) + { + const string countSql = "SELECT COUNT(*) FROM Orders WHERE UserId = @UserId"; + var total = await _connection.ExecuteScalarAsync(countSql, new { request.UserId }); + + const string sql = @" + SELECT o.Id, o.Status, o.TotalAmount, o.CreatedAt, + COUNT(oi.Id) as ItemCount + FROM Orders o + LEFT JOIN OrderItems oi ON o.Id = oi.OrderId + WHERE o.UserId = @UserId + GROUP BY o.Id, o.Status, o.TotalAmount, o.CreatedAt + ORDER BY o.CreatedAt DESC + OFFSET @Skip ROWS FETCH NEXT @Take ROWS ONLY"; + + var orders = await _connection.QueryAsync(sql, new + { + request.UserId, + request.Skip, + request.Take + }); + + return new PagedResult(orders.ToList(), total); + } +} +``` + +### Validation Behavior / Behavior Validation + +```csharp +/// +/// EN: Pipeline behavior for FluentValidation. +/// VI: Pipeline behavior cho FluentValidation. +/// +public class ValidationBehavior + : IPipelineBehavior + where TRequest : IRequest +{ + private readonly IEnumerable> _validators; + + public ValidationBehavior(IEnumerable> validators) + { + _validators = validators; + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken ct) + { + if (!_validators.Any()) + return await next(); + + var context = new ValidationContext(request); + var failures = (await Task.WhenAll( + _validators.Select(v => v.ValidateAsync(context, ct)))) + .SelectMany(r => r.Errors) + .Where(f => f != null) + .ToList(); + + if (failures.Count != 0) + throw new ValidationException(failures); + + return await next(); + } +} +``` + +### Logging Behavior / Behavior Logging + +```csharp +/// +/// EN: Pipeline behavior for logging. +/// VI: Pipeline behavior cho logging. +/// +public class LoggingBehavior + : IPipelineBehavior + where TRequest : IRequest +{ + private readonly ILogger> _logger; + + public LoggingBehavior(ILogger> logger) + { + _logger = logger; + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken ct) + { + var requestName = typeof(TRequest).Name; + + _logger.LogInformation( + "EN: Handling {RequestName} / VI: Xử lý {RequestName}", + requestName); + + var stopwatch = Stopwatch.StartNew(); + var response = await next(); + stopwatch.Stop(); + + _logger.LogInformation( + "EN: Handled {RequestName} in {ElapsedMs}ms / VI: Đã xử lý {RequestName} trong {ElapsedMs}ms", + requestName, stopwatch.ElapsedMilliseconds); + + return response; + } +} +``` + +### Controller with MediatR / Controller với MediatR + +```csharp +/// +/// EN: Slim controller using MediatR. +/// VI: Controller gọn nhẹ với MediatR. +/// +[ApiController] +[Route("api/v{version:apiVersion}/orders")] +public class OrdersController : ControllerBase +{ + private readonly IMediator _mediator; + + public OrdersController(IMediator mediator) + { + _mediator = mediator; + } + + [HttpPost] + public async Task>> CreateOrder( + CreateOrderRequest request, + CancellationToken ct) + { + var command = new CreateOrderCommand( + GetUserId(), + request.ShippingAddress, + request.Items); + + var result = await _mediator.Send(command, ct); + + return CreatedAtAction( + nameof(GetOrder), + new { orderId = result.OrderId }, + ApiResponse.Ok(result)); + } + + [HttpGet] + public async Task>>> GetOrders( + [FromQuery] int skip = 0, + [FromQuery] int take = 20, + CancellationToken ct = default) + { + var query = new GetUserOrdersQuery(GetUserId(), skip, take); + var result = await _mediator.Send(query, ct); + return Ok(ApiResponse>.Ok(result)); + } + + private string GetUserId() => + User.FindFirstValue(ClaimTypes.NameIdentifier) ?? throw new UnauthorizedAccessException(); +} +``` + +## Common Mistakes / Lỗi Thường Gặp + +### 1. Using Domain Model for Queries + +```csharp +// ❌ BAD: Using EF Core for simple reads +public async Task> Handle(GetOrdersQuery query, CancellationToken ct) +{ + return await _context.Orders + .Include(o => o.OrderItems) + .Where(o => o.UserId == query.UserId) + .ToListAsync(ct); +} + +// ✅ GOOD: Using Dapper for optimized reads +public async Task> Handle(GetOrdersQuery query, CancellationToken ct) +{ + const string sql = "SELECT Id, Status, TotalAmount FROM Orders WHERE UserId = @UserId"; + return await _connection.QueryAsync(sql, new { query.UserId }); +} +``` + +### 2. Missing Pipeline Behaviors + +```csharp +// ❌ BAD: Validation in handler +public async Task Handle(CreateOrderCommand request, CancellationToken ct) +{ + if (string.IsNullOrEmpty(request.UserId)) + throw new ValidationException("UserId required"); + // ... +} + +// ✅ GOOD: Validation via Behavior +builder.Services.AddMediatR(cfg => +{ + cfg.RegisterServicesFromAssembly(typeof(Program).Assembly); + cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); +}); +``` + +### 3. Fat Controllers + +```csharp +// ❌ BAD: Logic in controller +[HttpPost] +public async Task CreateOrder(CreateOrderRequest request) +{ + var order = new Order(request.UserId, request.Address); + foreach (var item in request.Items) + order.AddItem(item.ProductId, item.Quantity, item.UnitPrice); + await _repository.AddAsync(order); + await _repository.UnitOfWork.SaveChangesAsync(); + return Ok(order.Id); +} + +// ✅ GOOD: Delegate to MediatR +[HttpPost] +public async Task CreateOrder(CreateOrderRequest request) +{ + var command = request.ToCommand(GetUserId()); + var result = await _mediator.Send(command); + return CreatedAtAction(nameof(GetOrder), new { orderId = result.OrderId }, result); +} +``` + +## Quick Reference / Tham Chiếu Nhanh + +### MediatR Request Types + +| Interface | Purpose | Example | +|-----------|---------|---------| +| `IRequest` | Request with response | Commands, Queries | +| `IRequest` | Request without response | Fire-and-forget | +| `INotification` | Event notification | Domain events | + +### Pipeline Behavior Order + +```csharp +// EN: Registration order = execution order +// VI: Thứ tự đăng ký = thứ tự thực thi +cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>)); // 1st +cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); // 2nd +cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(TransactionBehavior<,>)); // 3rd +``` + +### DI Registration + +```csharp +// EN: Register MediatR with behaviors +// VI: Đăng ký MediatR với behaviors +builder.Services.AddMediatR(cfg => +{ + cfg.RegisterServicesFromAssembly(typeof(Program).Assembly); + cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>)); + cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); +}); + +// EN: Register FluentValidation +// VI: Đăng ký FluentValidation +builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly); +``` + +## Resources / Tài Nguyên + +- [Detailed Examples](./references/REFERENCE.md) - Full code examples +- [Repository Pattern](../repository-pattern/SKILL.md) - Data access +- [Error Handling](../error-handling-patterns/SKILL.md) - Validation errors +- [Testing Patterns](../testing-patterns/SKILL.md) - Handler testing +- [API Design](../api-design/SKILL.md) - Controller patterns diff --git a/.agent/skills/cqrs-mediatr/references/REFERENCE.md b/.agent/skills/cqrs-mediatr/references/REFERENCE.md new file mode 100644 index 00000000..be937a39 --- /dev/null +++ b/.agent/skills/cqrs-mediatr/references/REFERENCE.md @@ -0,0 +1,764 @@ +# CQRS-MediatR - Detailed Reference + +Detailed code examples for CQRS pattern với MediatR trong ASP.NET Core. + +## Table of Contents + +1. [Project Setup](#project-setup) +2. [Command Patterns](#command-patterns) +3. [Query Patterns](#query-patterns) +4. [Pipeline Behaviors](#pipeline-behaviors) +5. [Idempotency](#idempotency) +6. [Domain Events](#domain-events) +7. [Transaction Management](#transaction-management) + +--- + +## Project Setup + +### Package Dependencies + +```xml + + + + + + +``` + +### DI Registration + +```csharp +/// +/// EN: Configure MediatR with all behaviors. +/// VI: Cấu hình MediatR với tất cả behaviors. +/// + +// EN: Register MediatR / VI: Đăng ký MediatR +builder.Services.AddMediatR(cfg => +{ + cfg.RegisterServicesFromAssembly(typeof(Program).Assembly); + + // EN: Pipeline behaviors (order matters!) + // VI: Pipeline behaviors (thứ tự quan trọng!) + cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>)); + cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); + cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(TransactionBehavior<,>)); +}); + +// EN: Register FluentValidation validators +// VI: Đăng ký FluentValidation validators +builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly); + +// EN: Register Dapper connection for queries +// VI: Đăng ký Dapper connection cho queries +builder.Services.AddScoped(_ => + new NpgsqlConnection(builder.Configuration.GetConnectionString("DefaultConnection"))); +``` + +--- + +## Command Patterns + +### Immutable Command Records + +```csharp +/// +/// EN: Command to create an order. Commands should be immutable. +/// VI: Command tạo order. Commands nên là immutable. +/// +public record CreateOrderCommand : IRequest +{ + public string UserId { get; init; } + public Address ShippingAddress { get; init; } + public IReadOnlyList Items { get; init; } + + public CreateOrderCommand( + string userId, + Address shippingAddress, + IReadOnlyList items) + { + UserId = userId; + ShippingAddress = shippingAddress; + Items = items; + } +} + +/// +/// EN: Command to update order status. +/// VI: Command cập nhật trạng thái order. +/// +public record UpdateOrderStatusCommand( + Guid OrderId, + string UserId, + OrderStatus NewStatus) : IRequest; + +/// +/// EN: Command to cancel order. +/// VI: Command hủy order. +/// +public record CancelOrderCommand( + Guid OrderId, + string UserId, + string Reason) : IRequest; +``` + +### Command Handler with Full Domain Logic + +```csharp +/// +/// EN: Handler for CreateOrderCommand with domain validation. +/// VI: Handler cho CreateOrderCommand với domain validation. +/// +public class CreateOrderCommandHandler : IRequestHandler +{ + private readonly IOrderRepository _orderRepository; + private readonly IProductService _productService; + private readonly ILogger _logger; + + public CreateOrderCommandHandler( + IOrderRepository orderRepository, + IProductService productService, + ILogger logger) + { + _orderRepository = orderRepository; + _productService = productService; + _logger = logger; + } + + public async Task Handle( + CreateOrderCommand request, + CancellationToken ct) + { + // EN: Validate products exist and get prices + // VI: Xác thực products tồn tại và lấy giá + var productIds = request.Items.Select(i => i.ProductId).ToList(); + var products = await _productService.GetByIdsAsync(productIds, ct); + + if (products.Count != productIds.Count) + { + var missingIds = productIds.Except(products.Select(p => p.Id)); + throw new NotFoundException("Products", string.Join(", ", missingIds)); + } + + // EN: Create order aggregate + // VI: Tạo order aggregate + var order = new Order(request.UserId, request.ShippingAddress); + + foreach (var item in request.Items) + { + var product = products.First(p => p.Id == item.ProductId); + order.AddItem(product.Id, item.Quantity, product.Price); + } + + // EN: Persist via repository + // VI: Lưu qua repository + await _orderRepository.AddAsync(order, ct); + await _orderRepository.UnitOfWork.SaveChangesAsync(ct); + + _logger.LogInformation( + "EN: Order {OrderId} created for user {UserId} / " + + "VI: Order {OrderId} đã tạo cho user {UserId}", + order.Id, request.UserId); + + return new OrderResult(order.Id, order.Status.ToString(), order.TotalAmount); + } +} +``` + +### Command Validator + +```csharp +/// +/// EN: FluentValidation validator for CreateOrderCommand. +/// VI: FluentValidation validator cho CreateOrderCommand. +/// +public class CreateOrderCommandValidator : AbstractValidator +{ + public CreateOrderCommandValidator() + { + RuleFor(x => x.UserId) + .NotEmpty().WithMessage("User ID is required"); + + RuleFor(x => x.ShippingAddress) + .NotNull().WithMessage("Shipping address is required") + .SetValidator(new AddressValidator()!); + + RuleFor(x => x.Items) + .NotEmpty().WithMessage("At least one item is required") + .Must(items => items.Count <= 100) + .WithMessage("Maximum 100 items per order"); + + RuleForEach(x => x.Items) + .SetValidator(new OrderItemDtoValidator()); + } +} + +public class AddressValidator : AbstractValidator
+{ + public AddressValidator() + { + RuleFor(x => x.Street).NotEmpty().MaximumLength(200); + RuleFor(x => x.City).NotEmpty().MaximumLength(100); + RuleFor(x => x.PostalCode).NotEmpty().Matches(@"^\d{5}(-\d{4})?$"); + RuleFor(x => x.Country).NotEmpty().MaximumLength(100); + } +} + +public class OrderItemDtoValidator : AbstractValidator +{ + public OrderItemDtoValidator() + { + RuleFor(x => x.ProductId).NotEmpty(); + RuleFor(x => x.Quantity).GreaterThan(0).LessThanOrEqualTo(1000); + RuleFor(x => x.UnitPrice).GreaterThanOrEqualTo(0); + } +} +``` + +--- + +## Query Patterns + +### Optimized Query with Dapper + +```csharp +/// +/// EN: Query for paginated user orders. +/// VI: Query cho orders phân trang của user. +/// +public record GetUserOrdersQuery( + string UserId, + int Skip = 0, + int Take = 20, + OrderStatus? StatusFilter = null) : IRequest>; + +/// +/// EN: Handler using Dapper for optimized reads. +/// VI: Handler dùng Dapper cho đọc tối ưu. +/// +public class GetUserOrdersQueryHandler + : IRequestHandler> +{ + private readonly IDbConnection _connection; + + public GetUserOrdersQueryHandler(IDbConnection connection) + { + _connection = connection; + } + + public async Task> Handle( + GetUserOrdersQuery request, + CancellationToken ct) + { + var whereClause = "WHERE o.UserId = @UserId"; + var parameters = new DynamicParameters(); + parameters.Add("UserId", request.UserId); + parameters.Add("Skip", request.Skip); + parameters.Add("Take", request.Take); + + if (request.StatusFilter.HasValue) + { + whereClause += " AND o.Status = @Status"; + parameters.Add("Status", request.StatusFilter.Value.ToString()); + } + + // EN: Count total + // VI: Đếm tổng + var countSql = $"SELECT COUNT(*) FROM Orders o {whereClause}"; + var total = await _connection.ExecuteScalarAsync(countSql, parameters); + + // EN: Get data with pagination + // VI: Lấy dữ liệu với phân trang + var dataSql = $@" + SELECT + o.Id, + o.Status, + o.TotalAmount, + o.CreatedAt, + (SELECT COUNT(*) FROM OrderItems WHERE OrderId = o.Id) as ItemCount + FROM Orders o + {whereClause} + ORDER BY o.CreatedAt DESC + OFFSET @Skip ROWS FETCH NEXT @Take ROWS ONLY"; + + var orders = await _connection.QueryAsync(dataSql, parameters); + + return new PagedResult( + orders.ToList(), + total, + request.Skip / request.Take + 1, + request.Take); + } +} +``` + +### Complex Query with Multi-Mapping + +```csharp +/// +/// EN: Query for order detail with items. +/// VI: Query cho chi tiết order với items. +/// +public record GetOrderDetailQuery( + Guid OrderId, + string UserId) : IRequest; + +public class GetOrderDetailQueryHandler + : IRequestHandler +{ + private readonly IDbConnection _connection; + + public async Task Handle( + GetOrderDetailQuery request, + CancellationToken ct) + { + const string sql = @" + SELECT + o.Id, o.UserId, o.Status, o.TotalAmount, o.CreatedAt, + o.ShippingAddress_Street as Street, + o.ShippingAddress_City as City, + o.ShippingAddress_PostalCode as PostalCode, + o.ShippingAddress_Country as Country, + oi.Id as ItemId, oi.ProductId, oi.Quantity, oi.UnitPrice, + p.Name as ProductName + FROM Orders o + LEFT JOIN OrderItems oi ON o.Id = oi.OrderId + LEFT JOIN Products p ON oi.ProductId = p.Id + WHERE o.Id = @OrderId AND o.UserId = @UserId"; + + var orderDict = new Dictionary(); + + await _connection.QueryAsync( + sql, + (order, item) => + { + if (!orderDict.TryGetValue(order.Id, out var existingOrder)) + { + existingOrder = order; + existingOrder.Items = new List(); + orderDict.Add(order.Id, existingOrder); + } + + if (item != null) + existingOrder.Items.Add(item); + + return existingOrder; + }, + new { request.OrderId, request.UserId }, + splitOn: "ItemId"); + + return orderDict.Values.FirstOrDefault(); + } +} +``` + +--- + +## Pipeline Behaviors + +### Complete Validation Behavior + +```csharp +/// +/// EN: Validation behavior with detailed error mapping. +/// VI: Validation behavior với ánh xạ lỗi chi tiết. +/// +public class ValidationBehavior + : IPipelineBehavior + where TRequest : IRequest +{ + private readonly IEnumerable> _validators; + private readonly ILogger> _logger; + + public ValidationBehavior( + IEnumerable> validators, + ILogger> logger) + { + _validators = validators; + _logger = logger; + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken ct) + { + var typeName = typeof(TRequest).Name; + + if (!_validators.Any()) + { + _logger.LogDebug("No validators for {RequestType}", typeName); + return await next(); + } + + var context = new ValidationContext(request); + + var validationResults = await Task.WhenAll( + _validators.Select(v => v.ValidateAsync(context, ct))); + + var failures = validationResults + .SelectMany(r => r.Errors) + .Where(f => f != null) + .ToList(); + + if (failures.Count != 0) + { + _logger.LogWarning( + "Validation failed for {RequestType}: {Errors}", + typeName, + string.Join(", ", failures.Select(f => f.ErrorMessage))); + + throw new ValidationException(failures); + } + + return await next(); + } +} +``` + +### Logging Behavior with Performance Tracking + +```csharp +/// +/// EN: Logging behavior with performance alerts. +/// VI: Logging behavior với cảnh báo hiệu năng. +/// +public class LoggingBehavior + : IPipelineBehavior + where TRequest : IRequest +{ + private readonly ILogger> _logger; + private readonly TimeSpan _slowThreshold = TimeSpan.FromSeconds(3); + + public LoggingBehavior(ILogger> logger) + { + _logger = logger; + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken ct) + { + var requestName = typeof(TRequest).Name; + var requestId = Guid.NewGuid().ToString("N")[..8]; + + _logger.LogInformation( + "[{RequestId}] Handling {RequestName}", + requestId, requestName); + + var stopwatch = Stopwatch.StartNew(); + + try + { + var response = await next(); + stopwatch.Stop(); + + if (stopwatch.Elapsed > _slowThreshold) + { + _logger.LogWarning( + "[{RequestId}] SLOW: {RequestName} took {ElapsedMs}ms", + requestId, requestName, stopwatch.ElapsedMilliseconds); + } + else + { + _logger.LogInformation( + "[{RequestId}] Completed {RequestName} in {ElapsedMs}ms", + requestId, requestName, stopwatch.ElapsedMilliseconds); + } + + return response; + } + catch (Exception ex) + { + stopwatch.Stop(); + _logger.LogError(ex, + "[{RequestId}] FAILED: {RequestName} after {ElapsedMs}ms", + requestId, requestName, stopwatch.ElapsedMilliseconds); + throw; + } + } +} +``` + +### Transaction Behavior + +```csharp +/// +/// EN: Transaction behavior for commands. +/// VI: Transaction behavior cho commands. +/// +public class TransactionBehavior + : IPipelineBehavior + where TRequest : IRequest +{ + private readonly ApplicationDbContext _dbContext; + private readonly ILogger> _logger; + + public TransactionBehavior( + ApplicationDbContext dbContext, + ILogger> logger) + { + _dbContext = dbContext; + _logger = logger; + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken ct) + { + // EN: Only wrap commands in transactions + // VI: Chỉ wrap commands trong transactions + if (!IsCommand()) + return await next(); + + var typeName = typeof(TRequest).Name; + + await using var transaction = await _dbContext.Database + .BeginTransactionAsync(ct); + + try + { + _logger.LogDebug("Begin transaction for {RequestType}", typeName); + + var response = await next(); + + await transaction.CommitAsync(ct); + + _logger.LogDebug("Committed transaction for {RequestType}", typeName); + + return response; + } + catch (Exception ex) + { + await transaction.RollbackAsync(ct); + _logger.LogError(ex, "Rolled back transaction for {RequestType}", typeName); + throw; + } + } + + private static bool IsCommand() + { + return typeof(TRequest).Name.EndsWith("Command"); + } +} +``` + +--- + +## Idempotency + +### Identified Command Pattern + +```csharp +/// +/// EN: Wrapper for idempotent commands. +/// VI: Wrapper cho commands idempotent. +/// +public record IdentifiedCommand( + Guid RequestId, + TCommand Command) : IRequest + where TCommand : IRequest; + +/// +/// EN: Handler for identified commands. +/// VI: Handler cho identified commands. +/// +public class IdentifiedCommandHandler + : IRequestHandler, TResult> + where TCommand : IRequest +{ + private readonly IMediator _mediator; + private readonly IRequestManager _requestManager; + private readonly ILogger> _logger; + + public IdentifiedCommandHandler( + IMediator mediator, + IRequestManager requestManager, + ILogger> logger) + { + _mediator = mediator; + _requestManager = requestManager; + _logger = logger; + } + + public async Task Handle( + IdentifiedCommand request, + CancellationToken ct) + { + var commandName = typeof(TCommand).Name; + + // EN: Check if already processed + // VI: Kiểm tra đã xử lý chưa + if (await _requestManager.ExistsAsync(request.RequestId, ct)) + { + _logger.LogWarning( + "Duplicate request {RequestId} for {CommandName}", + request.RequestId, commandName); + + return default!; // EN: Return cached result if needed + } + + // EN: Mark as processing + // VI: Đánh dấu đang xử lý + await _requestManager.CreateAsync(request.RequestId, commandName, ct); + + try + { + var result = await _mediator.Send(request.Command, ct); + + _logger.LogInformation( + "Processed {CommandName} with RequestId {RequestId}", + commandName, request.RequestId); + + return result; + } + catch + { + await _requestManager.DeleteAsync(request.RequestId, ct); + throw; + } + } +} +``` + +### Request Manager + +```csharp +/// +/// EN: Interface for request deduplication. +/// VI: Interface cho request deduplication. +/// +public interface IRequestManager +{ + Task ExistsAsync(Guid requestId, CancellationToken ct); + Task CreateAsync(Guid requestId, string commandName, CancellationToken ct); + Task DeleteAsync(Guid requestId, CancellationToken ct); +} + +/// +/// EN: Database-backed request manager. +/// VI: Request manager với database. +/// +public class RequestManager : IRequestManager +{ + private readonly ApplicationDbContext _context; + + public RequestManager(ApplicationDbContext context) + { + _context = context; + } + + public async Task ExistsAsync(Guid requestId, CancellationToken ct) + { + return await _context.ClientRequests + .AnyAsync(r => r.Id == requestId, ct); + } + + public async Task CreateAsync(Guid requestId, string commandName, CancellationToken ct) + { + var request = new ClientRequest + { + Id = requestId, + Name = commandName, + Time = DateTime.UtcNow + }; + + _context.ClientRequests.Add(request); + await _context.SaveChangesAsync(ct); + } + + public async Task DeleteAsync(Guid requestId, CancellationToken ct) + { + var request = await _context.ClientRequests.FindAsync(new object[] { requestId }, ct); + if (request != null) + { + _context.ClientRequests.Remove(request); + await _context.SaveChangesAsync(ct); + } + } +} + +/// +/// EN: Entity for tracking processed requests. +/// VI: Entity theo dõi requests đã xử lý. +/// +public class ClientRequest +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public DateTime Time { get; set; } +} +``` + +--- + +## Domain Events + +### Publishing Domain Events + +```csharp +/// +/// EN: Domain event interface. +/// VI: Interface domain event. +/// +public interface IDomainEvent : INotification +{ + DateTime OccurredOn { get; } +} + +/// +/// EN: Order created domain event. +/// VI: Domain event order được tạo. +/// +public record OrderCreatedEvent( + Guid OrderId, + string UserId, + decimal TotalAmount) : IDomainEvent +{ + public DateTime OccurredOn { get; } = DateTime.UtcNow; +} + +/// +/// EN: Handler for OrderCreatedEvent. +/// VI: Handler cho OrderCreatedEvent. +/// +public class OrderCreatedEventHandler : INotificationHandler +{ + private readonly IEmailService _emailService; + private readonly ILogger _logger; + + public OrderCreatedEventHandler( + IEmailService emailService, + ILogger logger) + { + _emailService = emailService; + _logger = logger; + } + + public async Task Handle(OrderCreatedEvent notification, CancellationToken ct) + { + _logger.LogInformation( + "Sending order confirmation email for Order {OrderId}", + notification.OrderId); + + await _emailService.SendOrderConfirmationAsync( + notification.UserId, + notification.OrderId, + notification.TotalAmount, + ct); + } +} +``` + +--- + +## Resources / Tài Nguyên + +- [MediatR Documentation](https://github.com/jbogard/MediatR) +- [FluentValidation](https://docs.fluentvalidation.net/) +- [CQRS Pattern - Microsoft](https://docs.microsoft.com/en-us/azure/architecture/patterns/cqrs) +- [eShopOnContainers - CQRS](https://github.com/dotnet-architecture/eShopOnContainers) diff --git a/.agent/skills/docker-traefik/SKILL.md b/.agent/skills/docker-traefik/SKILL.md new file mode 100644 index 00000000..cf20ad04 --- /dev/null +++ b/.agent/skills/docker-traefik/SKILL.md @@ -0,0 +1,401 @@ +--- +name: docker-traefik +description: Docker containerization và Traefik reverse proxy. Use for Dockerfile, docker-compose, routing rules, SSL termination, và load balancing. +compatibility: "Docker 24+, Docker Compose v2+, Traefik v3+" +metadata: + author: Velik Ho + version: "1.0" +--- + +# Docker & Traefik Patterns / Mẫu Docker & Traefik + +Docker containerization và Traefik reverse proxy cho GoodGo microservices. + +## When to Use This Skill / Khi Nào Sử Dụng + +Use this skill when: +- Creating Dockerfiles for .NET services / Tạo Dockerfiles cho services .NET +- Configuring docker-compose for local dev / Cấu hình docker-compose cho dev local +- Setting up Traefik routing / Cài đặt Traefik routing +- Configuring SSL/TLS termination / Cấu hình SSL/TLS termination +- Implementing load balancing / Triển khai load balancing +- Managing service discovery / Quản lý service discovery + +## Core Concepts / Khái Niệm Cốt Lõi + +### Architecture Overview / Tổng Quan Kiến Trúc + +``` +Internet + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ Traefik (Gateway) │ +│ - SSL Termination - Rate Limiting │ +│ - Load Balancing - Path Routing │ +└───────┬────────────────────────────────┬────────────┘ + │ │ + ▼ ▼ +┌───────────────┐ ┌───────────────┐ +│ iam-service │ │ storage-svc │ +│ :8080 │ │ :8080 │ +└───────────────┘ └───────────────┘ +``` + +### Traefik Concepts / Các Khái Niệm Traefik + +| Concept | Description | Example | +|---------|-------------|---------| +| **EntryPoints** | Network ports | `web:80`, `websecure:443` | +| **Routers** | Match requests to services | `PathPrefix(\`/api/v1/iam\`)` | +| **Services** | Backend targets | `loadbalancer.server.port=8080` | +| **Middlewares** | Request/Response modifiers | `stripprefix`, `ratelimit` | + +### Docker Best Practices / Best Practices Docker + +1. **Multi-stage builds** - Separate build and runtime +2. **Non-root user** - Security best practice +3. **Alpine images** - Smaller image size +4. **.dockerignore** - Exclude unnecessary files +5. **Layer caching** - Optimize build time + +## Key Patterns / Mẫu Chính + +### Dockerfile for .NET Service + +```dockerfile +# =================================== +# EN: Build stage / VI: Stage build +# =================================== +FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build +WORKDIR /src + +# EN: Copy solution and restore dependencies +# VI: Copy solution và restore dependencies +COPY *.slnx ./ +COPY src/MyService.API/*.csproj src/MyService.API/ +COPY src/MyService.Domain/*.csproj src/MyService.Domain/ +COPY src/MyService.Infrastructure/*.csproj src/MyService.Infrastructure/ +RUN dotnet restore + +# EN: Copy source code and build +# VI: Copy source code và build +COPY src/ src/ +RUN dotnet publish src/MyService.API -c Release -o /app --no-restore + +# =================================== +# EN: Runtime stage / VI: Stage runtime +# =================================== +FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS runtime +WORKDIR /app + +# EN: Install additional packages if needed +# VI: Cài đặt packages bổ sung nếu cần +RUN apk add --no-cache icu-libs + +# EN: Create non-root user for security +# VI: Tạo user không phải root để bảo mật +RUN addgroup -S appgroup && adduser -S appuser -G appgroup +USER appuser + +# EN: Copy published files +# VI: Copy files đã publish +COPY --from=build --chown=appuser:appgroup /app . + +# EN: Configure environment +# VI: Cấu hình môi trường +ENV ASPNETCORE_URLS=http://+:8080 +ENV ASPNETCORE_ENVIRONMENT=Production +ENV DOTNET_RUNNING_IN_CONTAINER=true + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health/live || exit 1 + +ENTRYPOINT ["dotnet", "MyService.API.dll"] +``` + +### Docker Compose với Traefik + +```yaml +# docker-compose.yml +version: "3.8" + +services: + # =================================== + # Traefik - API Gateway + # =================================== + traefik: + image: traefik:v3.0 + container_name: traefik + command: + # EN: Enable API and Dashboard + # VI: Bật API và Dashboard + - "--api.dashboard=true" + - "--api.insecure=true" + + # EN: Docker provider configuration + # VI: Cấu hình Docker provider + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--providers.docker.network=goodgo-network" + + # EN: Entrypoints + # VI: Các điểm vào + - "--entrypoints.web.address=:80" + - "--entrypoints.websecure.address=:443" + + # EN: Access logs + # VI: Logs truy cập + - "--accesslog=true" + - "--accesslog.format=json" + ports: + - "80:80" + - "443:443" + - "8080:8080" # Dashboard + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - goodgo-network + labels: + # EN: Enable Traefik dashboard + # VI: Bật Traefik dashboard + - "traefik.enable=true" + - "traefik.http.routers.dashboard.rule=Host(`traefik.localhost`)" + - "traefik.http.routers.dashboard.service=api@internal" + + # =================================== + # IAM Service + # =================================== + iam-service-net: + build: + context: ../.. + dockerfile: services/iam-service-net/Dockerfile + container_name: iam-service-net + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ConnectionStrings__DefaultConnection=${IAM_DATABASE_URL} + labels: + - "traefik.enable=true" + # EN: Router configuration / VI: Cấu hình router + - "traefik.http.routers.iam-service-net.rule=PathPrefix(`/api/v1/iam`)" + - "traefik.http.routers.iam-service-net.entrypoints=web" + # EN: Service configuration / VI: Cấu hình service + - "traefik.http.services.iam-service-net.loadbalancer.server.port=8080" + # EN: Health check / VI: Kiểm tra sức khỏe + - "traefik.http.services.iam-service-net.loadbalancer.healthcheck.path=/health/live" + - "traefik.http.services.iam-service-net.loadbalancer.healthcheck.interval=10s" + networks: + - goodgo-network + depends_on: + - postgres + + # =================================== + # Storage Service + # =================================== + storage-service-net: + build: + context: ../.. + dockerfile: services/storage-service-net/Dockerfile + container_name: storage-service-net + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ConnectionStrings__DefaultConnection=${STORAGE_DATABASE_URL} + - MinIO__Endpoint=${MINIO_ENDPOINT} + - MinIO__AccessKey=${MINIO_ACCESS_KEY} + - MinIO__SecretKey=${MINIO_SECRET_KEY} + labels: + - "traefik.enable=true" + - "traefik.http.routers.storage-service-net.rule=PathPrefix(`/api/v1/storage`)" + - "traefik.http.routers.storage-service-net.entrypoints=web" + - "traefik.http.services.storage-service-net.loadbalancer.server.port=8080" + networks: + - goodgo-network + depends_on: + - postgres + - minio + + # =================================== + # PostgreSQL + # =================================== + postgres: + image: postgres:15-alpine + container_name: postgres + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - goodgo-network + + # =================================== + # MinIO (S3-compatible storage) + # =================================== + minio: + image: minio/minio:latest + container_name: minio + command: server /data --console-address ":9001" + environment: + - MINIO_ROOT_USER=minioadmin + - MINIO_ROOT_PASSWORD=minioadmin + volumes: + - minio_data:/data + ports: + - "9000:9000" + - "9001:9001" + networks: + - goodgo-network + +networks: + goodgo-network: + driver: bridge + +volumes: + postgres_data: + minio_data: +``` + +### Traefik Middlewares + +```yaml +# EN: Strip path prefix middleware +# VI: Middleware bỏ prefix path +services: + iam-service-net: + labels: + - "traefik.enable=true" + - "traefik.http.routers.iam-service-net.rule=PathPrefix(`/api/v1/iam`)" + - "traefik.http.routers.iam-service-net.entrypoints=web" + # EN: Apply middleware to strip prefix + # VI: Áp dụng middleware để bỏ prefix + - "traefik.http.routers.iam-service-net.middlewares=iam-stripprefix" + - "traefik.http.middlewares.iam-stripprefix.stripprefix.prefixes=/api/v1/iam" + - "traefik.http.services.iam-service-net.loadbalancer.server.port=8080" +``` + +### Rate Limiting Middleware + +```yaml +services: + api-service: + labels: + - "traefik.enable=true" + - "traefik.http.routers.api.rule=PathPrefix(`/api`)" + # EN: Rate limiting middleware + # VI: Middleware giới hạn rate + - "traefik.http.routers.api.middlewares=api-ratelimit" + - "traefik.http.middlewares.api-ratelimit.ratelimit.average=100" + - "traefik.http.middlewares.api-ratelimit.ratelimit.burst=50" + - "traefik.http.middlewares.api-ratelimit.ratelimit.period=1m" +``` + +## Common Mistakes / Lỗi Thường Gặp + +### 1. Running as Root + +```dockerfile +# ❌ BAD: Running as root +FROM mcr.microsoft.com/dotnet/aspnet:8.0 +COPY --from=build /app . +ENTRYPOINT ["dotnet", "MyService.dll"] + +# ✅ GOOD: Non-root user +FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine +RUN adduser -D appuser +USER appuser +COPY --from=build --chown=appuser /app . +ENTRYPOINT ["dotnet", "MyService.dll"] +``` + +### 2. No Health Checks + +```yaml +# ❌ BAD: No health check +services: + my-service: + build: . + labels: + - "traefik.enable=true" + +# ✅ GOOD: With health check +services: + my-service: + build: . + labels: + - "traefik.enable=true" + - "traefik.http.services.my-service.loadbalancer.healthcheck.path=/health/live" + - "traefik.http.services.my-service.loadbalancer.healthcheck.interval=10s" +``` + +### 3. Exposing Internal Ports + +```yaml +# ❌ BAD: Exposing all ports +services: + my-service: + ports: + - "8080:8080" # Direct access bypasses Traefik + +# ✅ GOOD: Only expose through Traefik +services: + my-service: + # No ports exposed directly + labels: + - "traefik.enable=true" + - "traefik.http.services.my-service.loadbalancer.server.port=8080" +``` + +## Quick Reference / Tham Chiếu Nhanh + +### Docker Commands + +```bash +# EN: Build and start / VI: Build và start +docker-compose -f deployments/local/docker-compose.yml up -d --build + +# EN: View logs / VI: Xem logs +docker-compose logs -f iam-service-net + +# EN: Restart service / VI: Restart service +docker-compose restart iam-service-net + +# EN: Stop all / VI: Dừng tất cả +docker-compose down + +# EN: Clean rebuild / VI: Rebuild sạch +docker-compose down -v && docker-compose up -d --build +``` + +### Traefik Router Rules + +| Rule | Example | Description | +|------|---------|-------------| +| `Host` | ``Host(`api.example.com`)`` | Match by hostname | +| `PathPrefix` | ``PathPrefix(`/api`)`` | Match by path prefix | +| `Path` | ``Path(`/api/health`)`` | Match exact path | +| `Method` | ``Method(`GET`, `POST`)`` | Match by HTTP method | +| `Headers` | ``Headers(`X-Custom`, `value`)`` | Match by header | + +### Common Labels + +```yaml +# EN: Basic routing / VI: Routing cơ bản +- "traefik.enable=true" +- "traefik.http.routers.{name}.rule=PathPrefix(`/path`)" +- "traefik.http.routers.{name}.entrypoints=web" +- "traefik.http.services.{name}.loadbalancer.server.port=8080" + +# EN: With HTTPS / VI: Với HTTPS +- "traefik.http.routers.{name}-secure.rule=PathPrefix(`/path`)" +- "traefik.http.routers.{name}-secure.entrypoints=websecure" +- "traefik.http.routers.{name}-secure.tls=true" +``` + +## Resources / Tài Nguyên + +- [Detailed Examples](./references/REFERENCE.md) - Full configurations +- [Project Rules](../project-rules/SKILL.md) - Docker naming conventions +- [Observability](../observability/SKILL.md) - Container monitoring +- [Error Handling](../error-handling-patterns/SKILL.md) - Health checks diff --git a/.agent/skills/docker-traefik/references/REFERENCE.md b/.agent/skills/docker-traefik/references/REFERENCE.md new file mode 100644 index 00000000..32f72c1d --- /dev/null +++ b/.agent/skills/docker-traefik/references/REFERENCE.md @@ -0,0 +1,560 @@ +# Docker & Traefik - Detailed Reference + +Detailed configurations và examples cho Docker và Traefik trong GoodGo. + +## Table of Contents + +1. [Dockerfile Patterns](#dockerfile-patterns) +2. [Docker Compose Configurations](#docker-compose-configurations) +3. [Traefik Configuration](#traefik-configuration) +4. [SSL/TLS Setup](#ssltls-setup) +5. [Load Balancing](#load-balancing) +6. [Middleware Examples](#middleware-examples) +7. [Development Environment](#development-environment) + +--- + +## Dockerfile Patterns + +### Optimized .NET Dockerfile + +```dockerfile +# =================================== +# Stage 1: Build +# =================================== +FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build +WORKDIR /src + +# EN: Copy only project files first for better caching +# VI: Copy chỉ project files trước để cache tốt hơn +COPY services/iam-service-net/*.slnx ./ +COPY services/iam-service-net/src/IamService.API/*.csproj src/IamService.API/ +COPY services/iam-service-net/src/IamService.Domain/*.csproj src/IamService.Domain/ +COPY services/iam-service-net/src/IamService.Infrastructure/*.csproj src/IamService.Infrastructure/ + +# EN: Restore dependencies +# VI: Restore dependencies +RUN dotnet restore src/IamService.API + +# EN: Copy remaining source files +# VI: Copy các source files còn lại +COPY services/iam-service-net/src/ src/ + +# EN: Build and publish +# VI: Build và publish +RUN dotnet publish src/IamService.API \ + -c Release \ + -o /app \ + --no-restore \ + /p:PublishTrimmed=false \ + /p:PublishSingleFile=false + +# =================================== +# Stage 2: Runtime +# =================================== +FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS runtime + +# EN: Install required packages +# VI: Cài đặt packages cần thiết +RUN apk add --no-cache \ + icu-libs \ + tzdata \ + && rm -rf /var/cache/apk/* + +# EN: Set timezone +# VI: Đặt timezone +ENV TZ=Asia/Ho_Chi_Minh + +# EN: Create non-root user +# VI: Tạo user không phải root +RUN addgroup -g 1000 -S appgroup && \ + adduser -u 1000 -S appuser -G appgroup + +WORKDIR /app + +# EN: Copy published files with correct ownership +# VI: Copy files đã publish với ownership đúng +COPY --from=build --chown=appuser:appgroup /app . + +# EN: Switch to non-root user +# VI: Chuyển sang user không phải root +USER appuser + +# EN: Environment configuration +# VI: Cấu hình môi trường +ENV ASPNETCORE_URLS=http://+:8080 \ + ASPNETCORE_ENVIRONMENT=Production \ + DOTNET_RUNNING_IN_CONTAINER=true \ + DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false + +EXPOSE 8080 + +# EN: Health check +# VI: Kiểm tra sức khỏe +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health/live || exit 1 + +ENTRYPOINT ["dotnet", "IamService.API.dll"] +``` + +### .dockerignore + +``` +# EN: Git / VI: Git +.git +.gitignore + +# EN: Build outputs / VI: Build outputs +**/bin/ +**/obj/ +**/out/ + +# EN: IDE / VI: IDE +.vs/ +.vscode/ +.idea/ +*.swp +*.user + +# EN: Test results / VI: Kết quả test +**/TestResults/ +**/coverage/ + +# EN: Docker / VI: Docker +**/Dockerfile* +**/docker-compose* +**/.dockerignore + +# EN: Documentation / VI: Tài liệu +**/README.md +**/docs/ + +# EN: Secrets / VI: Secrets +**/*.env +**/appsettings.Development.json +**/appsettings.Local.json +``` + +--- + +## Docker Compose Configurations + +### Complete Development Stack + +```yaml +# deployments/local/docker-compose.yml +version: "3.8" + +services: + # =================================== + # TRAEFIK - API Gateway + # =================================== + traefik: + image: traefik:v3.0 + container_name: traefik + restart: unless-stopped + command: + # EN: API and Dashboard + - "--api.dashboard=true" + - "--api.insecure=true" + + # EN: Docker provider + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--providers.docker.network=goodgo-network" + + # EN: Entrypoints + - "--entrypoints.web.address=:80" + - "--entrypoints.websecure.address=:443" + + # EN: Logging + - "--log.level=INFO" + - "--accesslog=true" + - "--accesslog.format=json" + + # EN: Metrics + - "--metrics.prometheus=true" + - "--metrics.prometheus.buckets=0.1,0.3,1.2,5.0" + ports: + - "80:80" + - "443:443" + - "8080:8080" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./traefik/certs:/certs:ro + networks: + - goodgo-network + labels: + - "traefik.enable=true" + - "traefik.http.routers.traefik.rule=Host(`traefik.localhost`)" + - "traefik.http.routers.traefik.service=api@internal" + + # =================================== + # IAM SERVICE + # =================================== + iam-service-net: + build: + context: ../.. + dockerfile: services/iam-service-net/Dockerfile + container_name: iam-service-net + restart: unless-stopped + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ConnectionStrings__DefaultConnection=${IAM_DATABASE_URL} + - Jwt__Authority=http://iam-service-net:8080 + - Jwt__Audience=goodgo-api + labels: + - "traefik.enable=true" + # EN: HTTP Router + - "traefik.http.routers.iam-service-net.rule=PathPrefix(`/api/v1/iam`)" + - "traefik.http.routers.iam-service-net.entrypoints=web" + - "traefik.http.routers.iam-service-net.middlewares=iam-headers" + # EN: Service + - "traefik.http.services.iam-service-net.loadbalancer.server.port=8080" + - "traefik.http.services.iam-service-net.loadbalancer.healthcheck.path=/health/live" + - "traefik.http.services.iam-service-net.loadbalancer.healthcheck.interval=10s" + # EN: Response headers middleware + - "traefik.http.middlewares.iam-headers.headers.customresponseheaders.X-Service-Name=iam-service" + networks: + - goodgo-network + depends_on: + postgres: + condition: service_healthy + + # =================================== + # STORAGE SERVICE + # =================================== + storage-service-net: + build: + context: ../.. + dockerfile: services/storage-service-net/Dockerfile + container_name: storage-service-net + restart: unless-stopped + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ConnectionStrings__DefaultConnection=${STORAGE_DATABASE_URL} + - MinIO__Endpoint=minio:9000 + - MinIO__AccessKey=${MINIO_ACCESS_KEY} + - MinIO__SecretKey=${MINIO_SECRET_KEY} + - MinIO__UseSSL=false + - IamService__BaseUrl=http://iam-service-net:8080 + labels: + - "traefik.enable=true" + - "traefik.http.routers.storage-service-net.rule=PathPrefix(`/api/v1/storage`)" + - "traefik.http.routers.storage-service-net.entrypoints=web" + - "traefik.http.services.storage-service-net.loadbalancer.server.port=8080" + - "traefik.http.services.storage-service-net.loadbalancer.healthcheck.path=/health/live" + networks: + - goodgo-network + depends_on: + postgres: + condition: service_healthy + minio: + condition: service_started + + # =================================== + # POSTGRESQL + # =================================== + postgres: + image: postgres:15-alpine + container_name: postgres + restart: unless-stopped + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_MULTIPLE_DATABASES=iam_db,storage_db,wallet_db + volumes: + - postgres_data:/var/lib/postgresql/data + - ./scripts/create-multiple-dbs.sh:/docker-entrypoint-initdb.d/create-multiple-dbs.sh + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - goodgo-network + + # =================================== + # REDIS + # =================================== + redis: + image: redis:7-alpine + container_name: redis + restart: unless-stopped + command: redis-server --appendonly yes + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + networks: + - goodgo-network + + # =================================== + # MINIO (S3-compatible) + # =================================== + minio: + image: minio/minio:latest + container_name: minio + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + - MINIO_ROOT_USER=minioadmin + - MINIO_ROOT_PASSWORD=minioadmin + volumes: + - minio_data:/data + ports: + - "9000:9000" + - "9001:9001" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 30s + timeout: 10s + retries: 3 + networks: + - goodgo-network + labels: + - "traefik.enable=true" + - "traefik.http.routers.minio-console.rule=Host(`minio.localhost`)" + - "traefik.http.routers.minio-console.service=minio-console" + - "traefik.http.services.minio-console.loadbalancer.server.port=9001" + +networks: + goodgo-network: + driver: bridge + name: goodgo-network + +volumes: + postgres_data: + redis_data: + minio_data: +``` + +### Multi-Database Init Script + +```bash +#!/bin/bash +# scripts/create-multiple-dbs.sh + +set -e +set -u + +function create_user_and_database() { + local database=$1 + echo " Creating database '$database'" + psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL + CREATE DATABASE $database; + GRANT ALL PRIVILEGES ON DATABASE $database TO $POSTGRES_USER; +EOSQL +} + +if [ -n "$POSTGRES_MULTIPLE_DATABASES" ]; then + echo "Multiple database creation requested: $POSTGRES_MULTIPLE_DATABASES" + for db in $(echo $POSTGRES_MULTIPLE_DATABASES | tr ',' ' '); do + create_user_and_database $db + done + echo "Multiple databases created" +fi +``` + +--- + +## Traefik Configuration + +### Static Configuration (traefik.yml) + +```yaml +# infra/traefik/traefik.yml + +# EN: API and Dashboard +# VI: API và Dashboard +api: + dashboard: true + insecure: false + +# EN: Entrypoints +# VI: Các điểm vào +entryPoints: + web: + address: ":80" + http: + redirections: + entryPoint: + to: websecure + scheme: https + websecure: + address: ":443" + http: + tls: + certResolver: letsencrypt + +# EN: Certificate resolvers +# VI: Bộ giải quyết chứng chỉ +certificatesResolvers: + letsencrypt: + acme: + email: admin@goodgo.vn + storage: /certs/acme.json + httpChallenge: + entryPoint: web + +# EN: Providers +# VI: Providers +providers: + docker: + exposedByDefault: false + network: goodgo-network + file: + directory: /etc/traefik/dynamic + watch: true + +# EN: Logging +# VI: Logging +log: + level: INFO + format: json + +accessLog: + format: json + filters: + statusCodes: + - "400-599" + +# EN: Metrics +# VI: Metrics +metrics: + prometheus: + buckets: + - 0.1 + - 0.3 + - 1.2 + - 5.0 +``` + +### Dynamic Configuration + +```yaml +# infra/traefik/dynamic/middlewares.yml + +http: + middlewares: + # EN: Security headers + # VI: Headers bảo mật + secure-headers: + headers: + frameDeny: true + sslRedirect: true + browserXssFilter: true + contentTypeNosniff: true + forceSTSHeader: true + stsIncludeSubdomains: true + stsPreload: true + stsSeconds: 31536000 + customResponseHeaders: + X-Robots-Tag: "noindex,nofollow" + + # EN: Rate limiting + # VI: Giới hạn rate + rate-limit: + rateLimit: + average: 100 + burst: 50 + period: 1m + + # EN: Compression + # VI: Nén + compress: + compress: {} + + # EN: CORS + # VI: CORS + cors: + headers: + accessControlAllowMethods: + - GET + - POST + - PUT + - DELETE + - OPTIONS + accessControlAllowHeaders: + - "*" + accessControlAllowOriginList: + - "https://app.goodgo.vn" + - "http://localhost:3000" + accessControlMaxAge: 100 + addVaryHeader: true +``` + +--- + +## SSL/TLS Setup + +### Local Development with mkcert + +```bash +# EN: Install mkcert / VI: Cài đặt mkcert +brew install mkcert +mkcert -install + +# EN: Generate certificates / VI: Tạo certificates +cd infra/traefik/certs +mkcert "*.localhost" localhost 127.0.0.1 ::1 +``` + +### Traefik with Local Certs + +```yaml +# docker-compose.override.yml for local HTTPS +services: + traefik: + volumes: + - ./infra/traefik/certs:/certs:ro + labels: + - "traefik.http.routers.traefik.tls=true" + +# Dynamic config: infra/traefik/dynamic/tls.yml +tls: + certificates: + - certFile: /certs/_wildcard.localhost.pem + keyFile: /certs/_wildcard.localhost-key.pem + stores: + default: + defaultCertificate: + certFile: /certs/_wildcard.localhost.pem + keyFile: /certs/_wildcard.localhost-key.pem +``` + +--- + +## Load Balancing + +### Multiple Instances + +```yaml +services: + iam-service-net: + deploy: + replicas: 3 + labels: + - "traefik.enable=true" + - "traefik.http.routers.iam.rule=PathPrefix(`/api/v1/iam`)" + - "traefik.http.services.iam.loadbalancer.server.port=8080" + # EN: Sticky sessions (optional) + # VI: Sticky sessions (tùy chọn) + - "traefik.http.services.iam.loadbalancer.sticky.cookie.name=iam_sticky" + - "traefik.http.services.iam.loadbalancer.sticky.cookie.secure=true" + # EN: Health check + - "traefik.http.services.iam.loadbalancer.healthcheck.path=/health/live" + - "traefik.http.services.iam.loadbalancer.healthcheck.interval=5s" +``` + +--- + +## Resources / Tài Nguyên + +- [Traefik Documentation](https://doc.traefik.io/traefik/) +- [Docker Best Practices](https://docs.docker.com/develop/develop-images/dockerfile_best-practices/) +- [.NET Docker Images](https://hub.docker.com/_/microsoft-dotnet) +- [Docker Compose Specification](https://docs.docker.com/compose/compose-file/) diff --git a/.agent/skills/error-handling-patterns/SKILL.md b/.agent/skills/error-handling-patterns/SKILL.md index ef65cf10..83609e0c 100644 --- a/.agent/skills/error-handling-patterns/SKILL.md +++ b/.agent/skills/error-handling-patterns/SKILL.md @@ -1,7 +1,7 @@ --- name: error-handling-patterns description: Global error handling, domain exceptions, và Result pattern. Use for exception middleware, validation errors, Polly resiliency, và health checks. -compatibility: ".NET 8+, Polly, FluentValidation, Microsoft.Extensions.Diagnostics.HealthChecks" +compatibility: ".NET 10+, Polly, FluentValidation, Microsoft.Extensions.Diagnostics.HealthChecks" metadata: author: Velik Ho version: "1.0" diff --git a/.agent/skills/observability/SKILL.md b/.agent/skills/observability/SKILL.md new file mode 100644 index 00000000..108d6dfa --- /dev/null +++ b/.agent/skills/observability/SKILL.md @@ -0,0 +1,451 @@ +--- +name: observability +description: Monitoring, logging, và tracing patterns. Use for Prometheus metrics, Grafana dashboards, Loki logging, và distributed tracing. +compatibility: ".NET 10+, Serilog, OpenTelemetry, Prometheus, Grafana" +metadata: + author: Velik Ho + version: "1.0" +--- + +# Observability Patterns / Mẫu Quan Sát + +Monitoring, logging, và tracing cho GoodGo microservices. + +## When to Use This Skill / Khi Nào Sử Dụng + +Use this skill when: +- Implementing health checks / Triển khai health checks +- Configuring structured logging / Cấu hình structured logging +- Setting up Prometheus metrics / Cài đặt Prometheus metrics +- Implementing distributed tracing / Triển khai distributed tracing +- Creating Grafana dashboards / Tạo Grafana dashboards + +## Core Concepts / Khái Niệm Cốt Lõi + +### Three Pillars of Observability / Ba Trụ Cột của Observability + +``` +┌─────────────────────────────────────────────────────────────┐ +│ OBSERVABILITY │ +├───────────────────┬───────────────────┬───────────────────┤ +│ METRICS │ LOGS │ TRACES │ +│ (Prometheus) │ (Loki/ELK) │ (OpenTelemetry) │ +├───────────────────┼───────────────────┼───────────────────┤ +│ What's happening │ What happened │ How it happened │ +│ (Số liệu) │ (Nhật ký) │ (Theo dõi) │ +└───────────────────┴───────────────────┴───────────────────┘ +``` + +### Health Check Types / Các Loại Health Check + +| Type | Purpose | Endpoint | +|------|---------|----------| +| **Liveness** | Is the app running? | `/health/live` | +| **Readiness** | Can the app accept traffic? | `/health/ready` | +| **Startup** | Has the app initialized? | `/health/startup` | + +### Logging Levels / Các Mức Log + +| Level | Purpose | Example | +|-------|---------|---------| +| **Trace** | Detailed debugging | Variable values | +| **Debug** | Development info | Method entry/exit | +| **Information** | Normal operations | Request processed | +| **Warning** | Potential problems | Slow query | +| **Error** | Errors handled | Exception caught | +| **Critical** | System failures | Database down | + +## Key Patterns / Mẫu Chính + +### Health Checks Configuration + +```csharp +/// +/// EN: Configure comprehensive health checks. +/// VI: Cấu hình health checks toàn diện. +/// + +// Program.cs +builder.Services.AddHealthChecks() + // EN: Database health / VI: Health database + .AddNpgSql( + connectionString: builder.Configuration.GetConnectionString("DefaultConnection")!, + name: "postgresql", + failureStatus: HealthStatus.Unhealthy, + tags: new[] { "db", "ready" }) + + // EN: Redis cache / VI: Redis cache + .AddRedis( + redisConnectionString: builder.Configuration["Redis:ConnectionString"]!, + name: "redis", + failureStatus: HealthStatus.Degraded, + tags: new[] { "cache", "ready" }) + + // EN: Custom health check / VI: Health check tùy chỉnh + .AddCheck( + "external-api", + failureStatus: HealthStatus.Degraded, + tags: new[] { "external", "ready" }); + +// EN: Map health endpoints / VI: Map health endpoints +app.MapHealthChecks("/health/live", new HealthCheckOptions +{ + Predicate = _ => false, // EN: Just check app is running + ResponseWriter = WriteMinimalResponse +}); + +app.MapHealthChecks("/health/ready", new HealthCheckOptions +{ + Predicate = check => check.Tags.Contains("ready"), + ResponseWriter = WriteDetailedResponse +}); + +app.MapHealthChecks("/health", new HealthCheckOptions +{ + ResponseWriter = WriteDetailedResponse +}); +``` + +### Structured Logging with Serilog + +```csharp +/// +/// EN: Configure Serilog with structured logging. +/// VI: Cấu hình Serilog với structured logging. +/// + +// Program.cs +Log.Logger = new LoggerConfiguration() + .MinimumLevel.Information() + .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) + .MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information) + .Enrich.FromLogContext() + .Enrich.WithMachineName() + .Enrich.WithEnvironmentName() + .Enrich.WithProperty("Application", "IamService") + .WriteTo.Console(new JsonFormatter()) + .WriteTo.Seq(builder.Configuration["Seq:ServerUrl"]!) + .CreateLogger(); + +builder.Host.UseSerilog(); + +// EN: Usage in code / VI: Sử dụng trong code +public class OrderService +{ + private readonly ILogger _logger; + + public async Task CreateOrderAsync(CreateOrderCommand cmd) + { + // EN: Structured logging with properties + // VI: Structured logging với properties + _logger.LogInformation( + "Creating order for user {UserId} with {ItemCount} items", + cmd.UserId, + cmd.Items.Count); + + try + { + var order = await ProcessOrderAsync(cmd); + + _logger.LogInformation( + "Order {OrderId} created successfully. Total: {TotalAmount}", + order.Id, + order.TotalAmount); + + return order; + } + catch (Exception ex) + { + _logger.LogError(ex, + "Failed to create order for user {UserId}", + cmd.UserId); + throw; + } + } +} +``` + +### Prometheus Metrics + +```csharp +/// +/// EN: Configure Prometheus metrics. +/// VI: Cấu hình Prometheus metrics. +/// + +// Program.cs +builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation() + .AddPrometheusExporter(); + }); + +// EN: Map Prometheus endpoint / VI: Map Prometheus endpoint +app.MapPrometheusScrapingEndpoint(); + +/// +/// EN: Custom metrics for business logic. +/// VI: Metrics tùy chỉnh cho business logic. +/// +public class OrderMetrics +{ + private readonly Counter _ordersCreated; + private readonly Histogram _orderProcessingDuration; + private readonly UpDownCounter _activeOrders; + + public OrderMetrics(IMeterFactory meterFactory) + { + var meter = meterFactory.Create("GoodGo.Orders"); + + _ordersCreated = meter.CreateCounter( + "orders_created_total", + description: "Total number of orders created"); + + _orderProcessingDuration = meter.CreateHistogram( + "order_processing_duration_seconds", + description: "Duration of order processing in seconds"); + + _activeOrders = meter.CreateUpDownCounter( + "active_orders", + description: "Number of orders currently being processed"); + } + + public void OrderCreated(string status) + { + _ordersCreated.Add(1, new KeyValuePair("status", status)); + } + + public void RecordProcessingDuration(double seconds) + { + _orderProcessingDuration.Record(seconds); + } + + public void IncrementActiveOrders() => _activeOrders.Add(1); + public void DecrementActiveOrders() => _activeOrders.Add(-1); +} +``` + +### Distributed Tracing with OpenTelemetry + +```csharp +/// +/// EN: Configure OpenTelemetry distributed tracing. +/// VI: Cấu hình OpenTelemetry distributed tracing. +/// + +// Program.cs +builder.Services.AddOpenTelemetry() + .WithTracing(tracing => + { + tracing + .SetResourceBuilder(ResourceBuilder.CreateDefault() + .AddService("iam-service") + .AddAttributes(new[] + { + new KeyValuePair("deployment.environment", + builder.Environment.EnvironmentName) + })) + .AddAspNetCoreInstrumentation(options => + { + options.RecordException = true; + options.Filter = ctx => + !ctx.Request.Path.StartsWithSegments("/health"); + }) + .AddHttpClientInstrumentation() + .AddEntityFrameworkCoreInstrumentation() + .AddSource("GoodGo.Orders") + .AddOtlpExporter(options => + { + options.Endpoint = new Uri(builder.Configuration["Otlp:Endpoint"]!); + }); + }); + +/// +/// EN: Custom activity for business operations. +/// VI: Activity tùy chỉnh cho business operations. +/// +public class OrderService +{ + private static readonly ActivitySource ActivitySource = new("GoodGo.Orders"); + + public async Task ProcessOrderAsync(CreateOrderCommand cmd) + { + using var activity = ActivitySource.StartActivity("ProcessOrder"); + activity?.SetTag("user.id", cmd.UserId); + activity?.SetTag("order.items_count", cmd.Items.Count); + + try + { + // EN: Create child span for validation + // VI: Tạo child span cho validation + using var validationActivity = ActivitySource.StartActivity("ValidateOrder"); + await ValidateOrderAsync(cmd); + validationActivity?.SetStatus(ActivityStatusCode.Ok); + + // EN: Create child span for persistence + // VI: Tạo child span cho persistence + using var persistActivity = ActivitySource.StartActivity("PersistOrder"); + var order = await SaveOrderAsync(cmd); + persistActivity?.SetTag("order.id", order.Id.ToString()); + + activity?.SetTag("order.id", order.Id.ToString()); + activity?.SetStatus(ActivityStatusCode.Ok); + + return order; + } + catch (Exception ex) + { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + activity?.RecordException(ex); + throw; + } + } +} +``` + +### Request Logging Middleware + +```csharp +/// +/// EN: Middleware for request/response logging. +/// VI: Middleware cho logging request/response. +/// +public class RequestLoggingMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public RequestLoggingMiddleware( + RequestDelegate next, + ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + var stopwatch = Stopwatch.StartNew(); + var requestId = context.TraceIdentifier; + + // EN: Log request / VI: Log request + _logger.LogInformation( + "Request {RequestId} started: {Method} {Path}", + requestId, + context.Request.Method, + context.Request.Path); + + try + { + await _next(context); + } + finally + { + stopwatch.Stop(); + + // EN: Log response / VI: Log response + _logger.LogInformation( + "Request {RequestId} completed: {StatusCode} in {ElapsedMs}ms", + requestId, + context.Response.StatusCode, + stopwatch.ElapsedMilliseconds); + } + } +} +``` + +## Common Mistakes / Lỗi Thường Gặp + +### 1. Logging Sensitive Data + +```csharp +// ❌ BAD: Logging password +_logger.LogInformation("Login: {Email} {Password}", email, password); + +// ✅ GOOD: Redact sensitive data +_logger.LogInformation("Login attempt for {Email}", email); +``` + +### 2. Missing Correlation ID + +```csharp +// ❌ BAD: No correlation between logs +_logger.LogInformation("Processing order"); +_logger.LogInformation("Order completed"); + +// ✅ GOOD: Include correlation ID +using (_logger.BeginScope(new Dictionary +{ + ["OrderId"] = orderId, + ["UserId"] = userId +})) +{ + _logger.LogInformation("Processing order"); + _logger.LogInformation("Order completed"); +} +``` + +### 3. No Health Check for Dependencies + +```csharp +// ❌ BAD: Only check if app runs +builder.Services.AddHealthChecks(); + +// ✅ GOOD: Check all dependencies +builder.Services.AddHealthChecks() + .AddNpgSql(connectionString, name: "database") + .AddRedis(redisConnection, name: "cache") + .AddUrlGroup(new Uri(externalApi), name: "external-api"); +``` + +## Quick Reference / Tham Chiếu Nhanh + +### Serilog Configuration (appsettings.json) + +```json +{ + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "System": "Warning" + } + }, + "WriteTo": [ + { "Name": "Console", "Args": { "formatter": "Serilog.Formatting.Json.JsonFormatter" } }, + { "Name": "Seq", "Args": { "serverUrl": "http://seq:5341" } } + ], + "Enrich": ["FromLogContext", "WithMachineName", "WithEnvironmentName"] + } +} +``` + +### Health Check Endpoints + +| Endpoint | Purpose | K8s Probe | +|----------|---------|-----------| +| `/health/live` | App is running | livenessProbe | +| `/health/ready` | App can accept traffic | readinessProbe | +| `/health/startup` | App has initialized | startupProbe | + +### Common Metrics + +| Metric | Type | Description | +|--------|------|-------------| +| `http_requests_total` | Counter | Total HTTP requests | +| `http_request_duration_seconds` | Histogram | Request duration | +| `dotnet_gc_collections_total` | Counter | GC collections | +| `process_cpu_seconds_total` | Counter | CPU usage | + +## Resources / Tài Nguyên + +- [Detailed Examples](./references/REFERENCE.md) - Full configurations +- [Error Handling](../error-handling-patterns/SKILL.md) - Health checks +- [Docker Traefik](../docker-traefik/SKILL.md) - Container monitoring +- [Project Rules](../project-rules/SKILL.md) - Logging standards diff --git a/.agent/skills/observability/references/REFERENCE.md b/.agent/skills/observability/references/REFERENCE.md new file mode 100644 index 00000000..f3a8b822 --- /dev/null +++ b/.agent/skills/observability/references/REFERENCE.md @@ -0,0 +1,570 @@ +# Observability - Detailed Reference + +Detailed configurations và examples cho Observability stack trong GoodGo. + +## Table of Contents + +1. [Serilog Configuration](#serilog-configuration) +2. [OpenTelemetry Setup](#opentelemetry-setup) +3. [Prometheus & Grafana](#prometheus--grafana) +4. [Health Checks](#health-checks) +5. [Loki Logging](#loki-logging) +6. [Alerting](#alerting) + +--- + +## Serilog Configuration + +### Complete Program.cs Setup + +```csharp +/// +/// EN: Complete Serilog configuration for microservice. +/// VI: Serilog configuration đầy đủ cho microservice. +/// + +using Serilog; +using Serilog.Events; +using Serilog.Formatting.Json; +using Serilog.Sinks.Grafana.Loki; + +// EN: Configure Serilog bootstrap logger for startup errors +// VI: Cấu hình Serilog bootstrap logger cho lỗi startup +Log.Logger = new LoggerConfiguration() + .MinimumLevel.Override("Microsoft", LogEventLevel.Information) + .Enrich.FromLogContext() + .WriteTo.Console() + .CreateBootstrapLogger(); + +try +{ + var builder = WebApplication.CreateBuilder(args); + + // EN: Configure Serilog from configuration + // VI: Cấu hình Serilog từ configuration + builder.Host.UseSerilog((context, services, configuration) => configuration + .ReadFrom.Configuration(context.Configuration) + .ReadFrom.Services(services) + .Enrich.FromLogContext() + .Enrich.WithProperty("Application", "IamService") + .Enrich.WithProperty("Environment", context.HostingEnvironment.EnvironmentName) + .Enrich.WithMachineName() + .Enrich.WithThreadId() + .WriteTo.Console(new JsonFormatter()) + .WriteTo.GrafanaLoki( + context.Configuration["Loki:Endpoint"]!, + labels: new[] + { + new LokiLabel { Key = "app", Value = "iam-service" }, + new LokiLabel { Key = "env", Value = context.HostingEnvironment.EnvironmentName } + })); + + // ... rest of configuration + + var app = builder.Build(); + + // EN: Add Serilog request logging middleware + // VI: Thêm Serilog request logging middleware + app.UseSerilogRequestLogging(options => + { + options.EnrichDiagnosticContext = (diagnosticContext, httpContext) => + { + diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value); + diagnosticContext.Set("RequestScheme", httpContext.Request.Scheme); + diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent.ToString()); + + if (httpContext.User.Identity?.IsAuthenticated == true) + { + diagnosticContext.Set("UserId", httpContext.User.FindFirst("sub")?.Value); + } + }; + }); + + app.Run(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "Application terminated unexpectedly"); +} +finally +{ + Log.CloseAndFlush(); +} +``` + +### appsettings.json for Serilog + +```json +{ + "Serilog": { + "Using": ["Serilog.Sinks.Console", "Serilog.Sinks.Seq"], + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.EntityFrameworkCore": "Warning", + "System": "Warning", + "Grpc": "Warning" + } + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "formatter": "Serilog.Formatting.Json.JsonFormatter, Serilog" + } + }, + { + "Name": "Seq", + "Args": { + "serverUrl": "http://seq:5341", + "apiKey": "" + } + } + ], + "Enrich": [ + "FromLogContext", + "WithMachineName", + "WithThreadId", + "WithEnvironmentName" + ], + "Properties": { + "Application": "IamService" + } + } +} +``` + +--- + +## OpenTelemetry Setup + +### Complete OpenTelemetry Configuration + +```csharp +/// +/// EN: Configure OpenTelemetry for tracing and metrics. +/// VI: Cấu hình OpenTelemetry cho tracing và metrics. +/// + +builder.Services.AddOpenTelemetry() + .ConfigureResource(resource => resource + .AddService( + serviceName: "iam-service", + serviceVersion: typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0", + serviceInstanceId: Environment.MachineName) + .AddAttributes(new[] + { + new KeyValuePair("deployment.environment", + builder.Environment.EnvironmentName), + new KeyValuePair("host.name", Environment.MachineName) + })) + .WithTracing(tracing => + { + tracing + // EN: ASP.NET Core instrumentation + .AddAspNetCoreInstrumentation(options => + { + options.RecordException = true; + options.Filter = ctx => + !ctx.Request.Path.StartsWithSegments("/health") && + !ctx.Request.Path.StartsWithSegments("/metrics"); + }) + // EN: HTTP client instrumentation + .AddHttpClientInstrumentation(options => + { + options.RecordException = true; + options.FilterHttpRequestMessage = req => + !req.RequestUri?.Host.Contains("health") ?? true; + }) + // EN: Entity Framework instrumentation + .AddEntityFrameworkCoreInstrumentation(options => + { + options.SetDbStatementForText = true; + options.SetDbStatementForStoredProcedure = true; + }) + // EN: Custom activity sources + .AddSource("GoodGo.Iam") + .AddSource("GoodGo.Orders") + // EN: Export to OTLP (Jaeger/Tempo) + .AddOtlpExporter(options => + { + options.Endpoint = new Uri(builder.Configuration["Otlp:Endpoint"]!); + options.Protocol = OtlpExportProtocol.Grpc; + }); + }) + .WithMetrics(metrics => + { + metrics + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation() + // EN: Custom meters + .AddMeter("GoodGo.Iam") + .AddMeter("GoodGo.Orders") + // EN: Prometheus exporter + .AddPrometheusExporter(); + }); + +// EN: Map Prometheus scraping endpoint +app.MapPrometheusScrapingEndpoint(); +``` + +### Custom Activity Source + +```csharp +/// +/// EN: Service with custom tracing. +/// VI: Service với tracing tùy chỉnh. +/// +public class OrderService +{ + private static readonly ActivitySource ActivitySource = new("GoodGo.Orders"); + private readonly ILogger _logger; + + public async Task ProcessOrderAsync(CreateOrderCommand cmd, CancellationToken ct) + { + // EN: Create root span for order processing + // VI: Tạo root span cho xử lý order + using var activity = ActivitySource.StartActivity( + "ProcessOrder", + ActivityKind.Internal); + + activity?.SetTag("user.id", cmd.UserId); + activity?.SetTag("order.items_count", cmd.Items.Count); + + try + { + // EN: Child span: Validate + // VI: Child span: Xác thực + using (var validateActivity = ActivitySource.StartActivity("ValidateOrder")) + { + await ValidateOrderAsync(cmd, ct); + validateActivity?.SetTag("validation.result", "success"); + } + + // EN: Child span: Check inventory + // VI: Child span: Kiểm tra tồn kho + using (var inventoryActivity = ActivitySource.StartActivity("CheckInventory")) + { + await CheckInventoryAsync(cmd.Items, ct); + } + + // EN: Child span: Persist + // VI: Child span: Lưu trữ + Order order; + using (var persistActivity = ActivitySource.StartActivity("PersistOrder")) + { + order = await SaveOrderAsync(cmd, ct); + persistActivity?.SetTag("order.id", order.Id.ToString()); + } + + activity?.SetTag("order.id", order.Id.ToString()); + activity?.SetTag("order.total", order.TotalAmount); + activity?.SetStatus(ActivityStatusCode.Ok); + + return order; + } + catch (Exception ex) + { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + activity?.RecordException(ex); + + _logger.LogError(ex, "Failed to process order for user {UserId}", cmd.UserId); + throw; + } + } +} +``` + +--- + +## Prometheus & Grafana + +### Docker Compose for Observability Stack + +```yaml +# infra/observability/docker-compose.yml +version: "3.8" + +services: + # =================================== + # PROMETHEUS + # =================================== + prometheus: + image: prom/prometheus:v2.47.0 + container_name: prometheus + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + - "--web.enable-lifecycle" + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml + - ./prometheus/alerts:/etc/prometheus/alerts + - prometheus_data:/prometheus + ports: + - "9090:9090" + networks: + - goodgo-network + + # =================================== + # GRAFANA + # =================================== + grafana: + image: grafana/grafana:10.1.0 + container_name: grafana + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_USERS_ALLOW_SIGN_UP=false + volumes: + - ./grafana/provisioning:/etc/grafana/provisioning + - ./grafana/dashboards:/var/lib/grafana/dashboards + - grafana_data:/var/lib/grafana + ports: + - "3000:3000" + networks: + - goodgo-network + depends_on: + - prometheus + - loki + + # =================================== + # LOKI (Log aggregation) + # =================================== + loki: + image: grafana/loki:2.9.0 + container_name: loki + command: -config.file=/etc/loki/loki-config.yml + volumes: + - ./loki/loki-config.yml:/etc/loki/loki-config.yml + - loki_data:/loki + ports: + - "3100:3100" + networks: + - goodgo-network + + # =================================== + # TEMPO (Distributed tracing) + # =================================== + tempo: + image: grafana/tempo:2.2.0 + container_name: tempo + command: -config.file=/etc/tempo/tempo-config.yml + volumes: + - ./tempo/tempo-config.yml:/etc/tempo/tempo-config.yml + - tempo_data:/var/tempo + ports: + - "4317:4317" # OTLP gRPC + - "4318:4318" # OTLP HTTP + networks: + - goodgo-network + +volumes: + prometheus_data: + grafana_data: + loki_data: + tempo_data: + +networks: + goodgo-network: + external: true +``` + +### Prometheus Configuration + +```yaml +# infra/observability/prometheus/prometheus.yml +global: + scrape_interval: 15s + evaluation_interval: 15s + +alerting: + alertmanagers: + - static_configs: + - targets: [] + +rule_files: + - /etc/prometheus/alerts/*.yml + +scrape_configs: + # EN: Prometheus self-monitoring + - job_name: "prometheus" + static_configs: + - targets: ["localhost:9090"] + + # EN: GoodGo Services via Traefik + - job_name: "goodgo-services" + docker_sd_configs: + - host: unix:///var/run/docker.sock + filters: + - name: network + values: ["goodgo-network"] + relabel_configs: + - source_labels: [__meta_docker_container_name] + regex: /(.*) + target_label: container + - source_labels: [__meta_docker_container_label_com_docker_compose_service] + target_label: service + - source_labels: [__address__] + regex: (.+):.* + replacement: ${1}:8080 + target_label: __address__ + - source_labels: [__meta_docker_container_label_traefik_enable] + regex: "true" + action: keep + + # EN: Traefik metrics + - job_name: "traefik" + static_configs: + - targets: ["traefik:8080"] +``` + +### Grafana Dashboard (JSON) + +```json +{ + "dashboard": { + "title": "GoodGo Services Overview", + "panels": [ + { + "title": "Request Rate", + "type": "graph", + "targets": [ + { + "expr": "rate(http_server_request_duration_seconds_count[5m])", + "legendFormat": "{{service}}" + } + ] + }, + { + "title": "Error Rate", + "type": "graph", + "targets": [ + { + "expr": "rate(http_server_request_duration_seconds_count{http_response_status_code=~\"5..\"}[5m])", + "legendFormat": "{{service}} - 5xx" + } + ] + }, + { + "title": "Request Duration P99", + "type": "graph", + "targets": [ + { + "expr": "histogram_quantile(0.99, rate(http_server_request_duration_seconds_bucket[5m]))", + "legendFormat": "{{service}}" + } + ] + } + ] + } +} +``` + +--- + +## Health Checks + +### Comprehensive Health Check Configuration + +```csharp +/// +/// EN: Configure all health checks. +/// VI: Cấu hình tất cả health checks. +/// + +builder.Services.AddHealthChecks() + // EN: Database + .AddNpgSql( + connectionString: builder.Configuration.GetConnectionString("DefaultConnection")!, + name: "postgresql", + failureStatus: HealthStatus.Unhealthy, + tags: new[] { "db", "ready", "critical" }) + + // EN: Redis + .AddRedis( + redisConnectionString: builder.Configuration["Redis:ConnectionString"]!, + name: "redis", + failureStatus: HealthStatus.Degraded, + tags: new[] { "cache", "ready" }) + + // EN: External HTTP dependency + .AddUrlGroup( + new Uri(builder.Configuration["Services:Payment:HealthUrl"]!), + name: "payment-service", + failureStatus: HealthStatus.Degraded, + tags: new[] { "external", "ready" }) + + // EN: Disk space + .AddDiskStorageHealthCheck( + setup: options => options.AddDrive("/", 1024), + name: "disk-space", + failureStatus: HealthStatus.Degraded, + tags: new[] { "infrastructure" }) + + // EN: Memory + .AddProcessAllocatedMemoryHealthCheck( + maximumMegabytesAllocated: 500, + name: "memory", + tags: new[] { "infrastructure" }); + +// EN: Map endpoints +app.MapHealthChecks("/health/live", new HealthCheckOptions +{ + Predicate = _ => false, + ResponseWriter = WriteMinimalResponse +}); + +app.MapHealthChecks("/health/ready", new HealthCheckOptions +{ + Predicate = hc => hc.Tags.Contains("ready"), + ResponseWriter = WriteDetailedResponse +}); + +app.MapHealthChecks("/health", new HealthCheckOptions +{ + ResponseWriter = WriteDetailedResponse +}); + +// EN: Health check response writers +static Task WriteMinimalResponse(HttpContext context, HealthReport report) +{ + context.Response.ContentType = "application/json"; + return context.Response.WriteAsync( + JsonSerializer.Serialize(new { status = report.Status.ToString() })); +} + +static Task WriteDetailedResponse(HttpContext context, HealthReport report) +{ + context.Response.ContentType = "application/json"; + + var result = new + { + status = report.Status.ToString(), + totalDuration = report.TotalDuration.TotalMilliseconds, + entries = report.Entries.Select(e => new + { + name = e.Key, + status = e.Value.Status.ToString(), + duration = e.Value.Duration.TotalMilliseconds, + description = e.Value.Description, + tags = e.Value.Tags, + data = e.Value.Data + }) + }; + + return context.Response.WriteAsJsonAsync(result); +} +``` + +--- + +## Resources / Tài Nguyên + +- [OpenTelemetry .NET](https://opentelemetry.io/docs/instrumentation/net/) +- [Serilog Documentation](https://serilog.net/) +- [Prometheus Documentation](https://prometheus.io/docs/) +- [Grafana Dashboards](https://grafana.com/grafana/dashboards/) +- [Loki Documentation](https://grafana.com/docs/loki/) diff --git a/.agent/skills/project-rules/SKILL.md b/.agent/skills/project-rules/SKILL.md index 1baf937a..1c26b7bc 100644 --- a/.agent/skills/project-rules/SKILL.md +++ b/.agent/skills/project-rules/SKILL.md @@ -1,7 +1,7 @@ --- name: project-rules description: GoodGo Platform coding standards and architecture. Use when working with services, apps, packages, or infrastructure. -compatibility: ".NET 8+, ASP.NET Core, MediatR, Entity Framework Core" +compatibility: ".NET 10+, ASP.NET Core, MediatR, Entity Framework Core" metadata: author: Velik Ho version: "2.0" diff --git a/.agent/skills/repository-pattern/SKILL.md b/.agent/skills/repository-pattern/SKILL.md index 13cc0179..47c4b23a 100644 --- a/.agent/skills/repository-pattern/SKILL.md +++ b/.agent/skills/repository-pattern/SKILL.md @@ -1,7 +1,7 @@ --- name: repository-pattern description: Entity Framework Core repository và data access patterns. Use for DbContext, repositories, migrations, aggregate roots, Unit of Work, và CQRS queries. -compatibility: ".NET 8+, Entity Framework Core 8+, Dapper" +compatibility: ".NET 10+, Entity Framework Core 8+, Dapper" metadata: author: Velik Ho version: "1.0" diff --git a/.agent/skills/security/SKILL.md b/.agent/skills/security/SKILL.md index 482a82c7..e26956f6 100644 --- a/.agent/skills/security/SKILL.md +++ b/.agent/skills/security/SKILL.md @@ -1,7 +1,7 @@ --- name: security description: Security patterns for GoodGo platform. Use for authentication, authorization, data protection, input validation, rate limiting, or secrets management. -compatibility: ".NET 8+, ASP.NET Core Identity, Duende IdentityServer" +compatibility: ".NET 10+, ASP.NET Core Identity, Duende IdentityServer" metadata: author: Velik Ho version: "2.0" diff --git a/.agent/skills/testing-patterns/SKILL.md b/.agent/skills/testing-patterns/SKILL.md index 512f46d6..9cd40732 100644 --- a/.agent/skills/testing-patterns/SKILL.md +++ b/.agent/skills/testing-patterns/SKILL.md @@ -1,7 +1,7 @@ --- name: testing-patterns description: Unit/Integration testing patterns cho .NET microservices. Use for xUnit, NSubstitute, Testcontainers, và testing MediatR handlers. -compatibility: ".NET 8+, xUnit, NSubstitute, Testcontainers, Microsoft.AspNetCore.TestHost" +compatibility: ".NET 10+, xUnit, NSubstitute, Testcontainers, Microsoft.AspNetCore.TestHost" metadata: author: Velik Ho version: "1.0"