diff --git a/backend/ExternalApi/GamificationApi.cs b/backend/ExternalApi/GamificationApi.cs index 030f81f..3519bba 100644 --- a/backend/ExternalApi/GamificationApi.cs +++ b/backend/ExternalApi/GamificationApi.cs @@ -1,8 +1,5 @@ -using Microsoft.Extensions.Options; - -using TaskSync.ExternalApi.Interfaces; +using TaskSync.ExternalApi.Interfaces; using TaskSync.Infrastructure.Http.Interface; -using TaskSync.Infrastructure.Settings; using TaskSync.Models.Dto; using TaskStatus = TaskSync.Enums.TASK_STATUS; @@ -11,16 +8,14 @@ namespace TaskSync.ExternalApi { public class GamificationApi : IGamificationApi { - private readonly GamificationApiSettings _gamApiSettings; private readonly IHttpContextReader _httpContextReader; private readonly HttpClient _httpClient; private readonly ILogger _logger; - public GamificationApi(IOptions options, IHttpContextReader httpContextReader, IHttpClientFactory httpClientFactory, ILogger logger) + public GamificationApi(IHttpContextReader httpContextReader, IHttpClientFactory httpClientFactory, ILogger logger) { - _gamApiSettings = options.Value; _httpContextReader = httpContextReader; - _httpClient = httpClientFactory.CreateClient(); + _httpClient = httpClientFactory.CreateClient("GamificationApi"); _logger = logger; } @@ -28,7 +23,7 @@ public async Task UpdatePoint(int taskId, TaskStatus status) { try { - var httpMessage = new HttpRequestMessage(HttpMethod.Post, $"{_gamApiSettings.BaseUrl}/points") + var httpMessage = new HttpRequestMessage(HttpMethod.Post, "points") { Content = JsonContent.Create(new CreatePointDto() { @@ -37,7 +32,6 @@ public async Task UpdatePoint(int taskId, TaskStatus status) UserId = _httpContextReader.GetUserId(), }), }; - httpMessage.Headers.Add("x-gamapi-auth", "true"); await _httpClient.SendAsync(httpMessage); } diff --git a/backend/Infrastructure/Configurations/CorsConfiguration.cs b/backend/Infrastructure/Configurations/CorsConfiguration.cs index 886c48c..362f0dc 100644 --- a/backend/Infrastructure/Configurations/CorsConfiguration.cs +++ b/backend/Infrastructure/Configurations/CorsConfiguration.cs @@ -1,20 +1,24 @@ -namespace TaskSync.Infrastructure.Configurations +using Microsoft.Extensions.Options; + +using TaskSync.Infrastructure.Settings; + +namespace TaskSync.Infrastructure.Configurations { public static class CorsConfiguration { public static IServiceCollection ConfigureCors(this IServiceCollection services) { + var provider = services.BuildServiceProvider(); + services.AddCors(options => { options.AddDefaultPolicy(policy => { - policy.WithOrigins( - "http://localhost:3039", // Dev - "http://131.189.90.113:3039") // Production - .AllowAnyHeader() - .AllowAnyMethod() - .AllowCredentials() - .SetPreflightMaxAge(TimeSpan.FromMinutes(10)); // Cache preflight 10 min + policy.WithOrigins(provider.GetRequiredService>().Value.Urls) + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials() + .SetPreflightMaxAge(TimeSpan.FromMinutes(10)); // Cache preflight 10 min }); }); diff --git a/backend/Infrastructure/Configurations/DependencyInjectionConfiguration.cs b/backend/Infrastructure/Configurations/DependencyInjectionConfiguration.cs index 46d2f8a..f2e8122 100644 --- a/backend/Infrastructure/Configurations/DependencyInjectionConfiguration.cs +++ b/backend/Infrastructure/Configurations/DependencyInjectionConfiguration.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; using TaskSync.ExternalApi; using TaskSync.ExternalApi.Interfaces; @@ -6,6 +7,7 @@ using TaskSync.Infrastructure.Caching.Interfaces; using TaskSync.Infrastructure.Http; using TaskSync.Infrastructure.Http.Interface; +using TaskSync.Infrastructure.Settings; using TaskSync.Repositories; using TaskSync.Repositories.Entities; using TaskSync.Repositories.Interfaces; @@ -18,10 +20,12 @@ namespace TaskSync.Infrastructure.Configurations { public static class DependencyInjectionConfiguration { - public static IServiceCollection ConfigureDependencyInjection(this IServiceCollection services, IConfiguration configuration) + public static IServiceCollection ConfigureDependencyInjection(this IServiceCollection services) { + var provider = services.BuildServiceProvider(); + services.AddDbContext(options => - options.UseNpgsql(configuration.GetConnectionString("DefaultConnection")) + options.UseNpgsql(provider.GetRequiredService>().Value.DefaultConnection) ); services.AddSingleton(); diff --git a/backend/Infrastructure/Configurations/HttpClientConfiguration.cs b/backend/Infrastructure/Configurations/HttpClientConfiguration.cs new file mode 100644 index 0000000..18f98d0 --- /dev/null +++ b/backend/Infrastructure/Configurations/HttpClientConfiguration.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.Options; + +using TaskSync.Infrastructure.Settings; + +namespace TaskSync.Infrastructure.Configurations +{ + public static class HttpClientConfiguration + { + public static IServiceCollection ConfigureHttpClient(this IServiceCollection services) + { + var provider = services.BuildServiceProvider(); + + services.AddHttpClient("GamificationApi", client => + { + client.BaseAddress = new Uri(provider.GetRequiredService>().Value.BaseUrl); + client.DefaultRequestHeaders.Add("x-gamapi-auth", "true"); + }); + + return services; + } + } +} diff --git a/backend/Infrastructure/Configurations/JwtConfiguration.cs b/backend/Infrastructure/Configurations/JwtConfiguration.cs index be1c02e..8fde5b3 100644 --- a/backend/Infrastructure/Configurations/JwtConfiguration.cs +++ b/backend/Infrastructure/Configurations/JwtConfiguration.cs @@ -10,10 +10,9 @@ namespace TaskSync.Infrastructure.Configurations { public static class JwtConfiguration { - public static IServiceCollection ConfigureJwt(this IServiceCollection services, IConfiguration configuration) + public static IServiceCollection ConfigureJwt(this IServiceCollection services) { - var provider = services.BuildServiceProvider(); - var jwtSettings = provider.GetRequiredService>().Value; + var jwtSettings = services.BuildServiceProvider().GetRequiredService>().Value; services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => diff --git a/backend/Infrastructure/Configurations/OpenTelemetryConfiguration.cs b/backend/Infrastructure/Configurations/OpenTelemetryConfiguration.cs new file mode 100644 index 0000000..b5dd239 --- /dev/null +++ b/backend/Infrastructure/Configurations/OpenTelemetryConfiguration.cs @@ -0,0 +1,68 @@ +using Microsoft.Extensions.Options; + +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +using TaskSync.Infrastructure.Settings; + +namespace TaskSync.Infrastructure.Configurations +{ + public static class OpenTelemetryConfiguration + { + public static void ConfigureTelemetry(this WebApplicationBuilder builder) + { + var provider = builder.Services.BuildServiceProvider(); + var appInfo = provider.GetRequiredService>().Value; + var otelSettings = provider.GetRequiredService>().Value; + + // Shared resource attributes (customize for app) + var otelResource = ResourceBuilder.CreateDefault().AddService(appInfo.AppName, serviceVersion: "1.0.0"); + + // Add OpenTelemetry for Tracing and Metrics + builder.Services.AddOpenTelemetry().ConfigureResource(rb => rb.AddService(appInfo.AppName)) + .WithTracing(tracing => + { + tracing + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddSqlClientInstrumentation() + .AddOtlpExporter(opt => + { + opt.Endpoint = new Uri(otelSettings.BaseUrl); // http://otel-collector:4317 (grpc protocol) + opt.Protocol = OpenTelemetry.Exporter.OtlpExportProtocol.Grpc; + }); + }) + .WithMetrics(metrics => + { + metrics + .AddAspNetCoreInstrumentation() // automatic telemetry for incoming HTTP requests e.g. "/api/projects/1/tasks" + .AddHttpClientInstrumentation() // automatic telemetry for outgoing HTTP requests e.g. HttpClient usage + .AddRuntimeInstrumentation() // Garbage collection counts, Thread pool usage, Memory pressure + .AddProcessInstrumentation() // CPU usage, Memory, Thread count + .AddOtlpExporter(opt => + { + opt.Endpoint = new Uri(otelSettings.BaseUrl); + opt.Protocol = OpenTelemetry.Exporter.OtlpExportProtocol.Grpc; + }); + }); + + // Add OpenTelemetry Logging + builder.Logging.ClearProviders(); + builder.Logging.AddOpenTelemetry(loggerOptions => + { + loggerOptions.IncludeScopes = true; + loggerOptions.IncludeFormattedMessage = true; + loggerOptions.ParseStateValues = true; + + loggerOptions.SetResourceBuilder(otelResource); + + loggerOptions.AddOtlpExporter(otlpOptions => + { + otlpOptions.Endpoint = new Uri(otelSettings.BaseUrl); + }); + }); + } + } +} diff --git a/backend/Infrastructure/Configurations/SettingsConfiguration.cs b/backend/Infrastructure/Configurations/SettingsConfiguration.cs index 9985d37..dcb6d40 100644 --- a/backend/Infrastructure/Configurations/SettingsConfiguration.cs +++ b/backend/Infrastructure/Configurations/SettingsConfiguration.cs @@ -8,6 +8,9 @@ public static IServiceCollection ConfigureAppSettings(this IServiceCollection se { services.Configure(configuration.GetSection("AppInfo")); services.Configure(configuration.GetSection("JwtSettings")); + services.Configure(configuration.GetSection("Frontend")); + services.Configure(configuration.GetSection("PostgreSql")); + services.Configure(configuration.GetSection("OtelCollector")); services.Configure(configuration.GetSection("MiddlewareSettings")); services.Configure(configuration.GetSection("GamificationApi")); diff --git a/backend/Infrastructure/Settings/FrontendSettings.cs b/backend/Infrastructure/Settings/FrontendSettings.cs new file mode 100644 index 0000000..9ffb480 --- /dev/null +++ b/backend/Infrastructure/Settings/FrontendSettings.cs @@ -0,0 +1,7 @@ +namespace TaskSync.Infrastructure.Settings +{ + public class FrontendSettings + { + public required string[] Urls { get; set; } + } +} diff --git a/backend/Infrastructure/Settings/OtelCollectorSettings.cs b/backend/Infrastructure/Settings/OtelCollectorSettings.cs new file mode 100644 index 0000000..ac997a8 --- /dev/null +++ b/backend/Infrastructure/Settings/OtelCollectorSettings.cs @@ -0,0 +1,7 @@ +namespace TaskSync.Infrastructure.Settings +{ + public class OtelCollectorSettings + { + public required string BaseUrl { get; set; } + } +} diff --git a/backend/Infrastructure/Settings/PostgreSqlSettings.cs b/backend/Infrastructure/Settings/PostgreSqlSettings.cs new file mode 100644 index 0000000..ff49f85 --- /dev/null +++ b/backend/Infrastructure/Settings/PostgreSqlSettings.cs @@ -0,0 +1,7 @@ +namespace TaskSync.Infrastructure.Settings +{ + public class PostgreSqlSettings + { + public required string DefaultConnection { get; set; } + } +} diff --git a/backend/Program.cs b/backend/Program.cs index dbc4034..fa5745b 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -16,11 +16,16 @@ builder.Services.AddRateLimiter(); builder.Services.AddMemoryCache(options => options.SizeLimit = 100); builder.Services.ConfigureAppSettings(builder.Configuration); -builder.Services.ConfigureApiVersion(); +builder.Services.ConfigureDependencyInjection(); builder.Services.ConfigureResponseCompression(); +builder.Services.ConfigureHttpClient(); +builder.Services.ConfigureApiVersion(); builder.Services.ConfigureCors(); -builder.Services.ConfigureJwt(builder.Configuration); -builder.Services.ConfigureDependencyInjection(builder.Configuration); +builder.Services.ConfigureJwt(); +if (builder.Environment.IsDevelopment()) +{ + builder.ConfigureTelemetry(); +} // ------------------------------------------------------------------------------- // -------------- Configure/Order the request pipeline (Middleware) -------------- diff --git a/backend/Repositories/ProjectRepository.cs b/backend/Repositories/ProjectRepository.cs index 0c85b42..6d7d33a 100644 --- a/backend/Repositories/ProjectRepository.cs +++ b/backend/Repositories/ProjectRepository.cs @@ -12,7 +12,7 @@ public class ProjectRepository : IProjectRepository public async Task GetByIdAsync(int projectId) { - return await _dbContext.Projects.FirstOrDefaultAsync(x => x.Id == projectId); + return await _dbContext.Projects.AsNoTracking().FirstOrDefaultAsync(x => x.Id == projectId); } } } diff --git a/backend/Repositories/TaskRepository.cs b/backend/Repositories/TaskRepository.cs index 1d769e1..398ab49 100644 --- a/backend/Repositories/TaskRepository.cs +++ b/backend/Repositories/TaskRepository.cs @@ -18,7 +18,7 @@ public async Task BeginTransactionAsync() public async Task?> GetAsync(int projectId) { - return await _dbContext.Tasks.Where(x => x.ProjectId == projectId).AsNoTracking().ToListAsync(); + return await _dbContext.Tasks.AsNoTracking().Where(x => x.ProjectId == projectId).ToListAsync(); } public async Task AddAsync(string title, int? assigneeId, int projectId, int? creatorId) diff --git a/backend/Repositories/UserRepository.cs b/backend/Repositories/UserRepository.cs index 7d8d11f..a8133aa 100644 --- a/backend/Repositories/UserRepository.cs +++ b/backend/Repositories/UserRepository.cs @@ -13,12 +13,12 @@ public class UserRepository : IRepository public async Task GetAsync() { - return await _dbContext.Users.SingleOrDefaultAsync(x => x.Id == 1); + return await _dbContext.Users.AsNoTracking().SingleOrDefaultAsync(x => x.Id == 1); } public async Task GetAsync(string email) { - return await _dbContext.Users.SingleOrDefaultAsync(x => x.Email == email); + return await _dbContext.Users.AsNoTracking().SingleOrDefaultAsync(x => x.Email == email); } public Task GetAsync(int param1) @@ -26,4 +26,4 @@ public class UserRepository : IRepository throw new NotImplementedException(); } } -} \ No newline at end of file +} diff --git a/backend/TaskSync.csproj b/backend/TaskSync.csproj index cfaac4e..55bc736 100644 --- a/backend/TaskSync.csproj +++ b/backend/TaskSync.csproj @@ -18,7 +18,14 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/backend/appsettings.Development.json b/backend/appsettings.Development.json index e1565b8..48b6267 100644 --- a/backend/appsettings.Development.json +++ b/backend/appsettings.Development.json @@ -1,12 +1,13 @@ { "AppInfo": { - "AppName": "TaskSync Core API", + "AppName": "Core.API", "Environment": "Development" }, "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore.Database.Command": "Warning" } } } diff --git a/backend/appsettings.json b/backend/appsettings.json index b15dad9..11e697a 100644 --- a/backend/appsettings.json +++ b/backend/appsettings.json @@ -1,29 +1,35 @@ { - "AppInfo": { - "AppName": "TaskSync Core API", - "Environment": "Production" - }, - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning", - "Microsoft.EntityFrameworkCore.Database.Command": "Warning" - } - }, - "AllowedHosts": "*", - "ConnectionStrings": { - "DefaultConnection": "Host=localhost;Port=5433;Database=tasksync;Username=postgres;Password=123456789" - }, - "JwtSettings": { - "SecretKey": "taskSyncSuperSecretKeyThatIsLong123!", - "Issuer": "TaskSync", - "Audience": "TaskSyncUsers", - "ExpirationMinutes": 180 - }, - "MiddlewareSettings": { - "ExcludedPaths": ["/taskHub", "/signalr", "/health"] - }, - "GamificationApi": { - "BaseUrl": "http://localhost:3000" + "AppInfo": { + "AppName": "Core.API", + "Environment": "Production" + }, + "AllowedHosts": "*", + "Frontend": { + "Urls": ["http://localhost:3039", "http://131.189.90.113:3039"] // dev and production + }, + "PostgreSql": { + "DefaultConnection": "Host=localhost;Port=5433;Database=tasksync;Username=postgres;Password=123456789" + }, + "GamificationApi": { + "BaseUrl": "http://localhost:3000" + }, + "OtelCollector": { + "BaseUrl": "http://localhost:4317" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore.Database.Command": "Warning" } + }, + "JwtSettings": { + "SecretKey": "taskSyncSuperSecretKeyThatIsLong123!", + "Issuer": "TaskSync", + "Audience": "TaskSyncUsers", + "ExpirationMinutes": 180 + }, + "MiddlewareSettings": { + "ExcludedPaths": [ "/taskHub", "/signalr", "/health" ] + } } diff --git a/docker-compose.yml b/docker-compose.yml index 3bff3ec..f3222fe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,7 @@ services: build: context: . # build from project roo dockerfile: backend/Dockerfile # Use Dockerfile in the ./backend directory + profiles: [app] container_name: tasksync-backend restart: on-failure ports: @@ -31,6 +32,7 @@ services: gamification-api: build: context: ./gamification-api + profiles: [app] container_name: tasksync-gamapi restart: on-failure ports: @@ -43,6 +45,7 @@ services: frontend: build: context: ./frontend + profiles: [app] container_name: tasksync-frontend restart: on-failure ports: @@ -51,5 +54,63 @@ services: - backend # Ensure backend is up before serving frontend - gamification-api + otel-collector: + image: otel/opentelemetry-collector-contrib:0.97.0 + profiles: [observability] + container_name: otel-collector + command: ["--config=/etc/otel-collector-config.yaml"] + volumes: + - ./observability/otel-collector-config.yaml:/etc/otel-collector-config.yaml + ports: + - "4317:4317" # OTLP gRPC + - "4318:4318" # OTLP HTTP + - "8889:8889" # Collector internal metrics + + prometheus: + image: prom/prometheus + profiles: [observability] + container_name: prometheus + volumes: + - ./observability/prometheus.yml:/etc/prometheus/prometheus.yml + ports: + - "9090:9090" + depends_on: + - otel-collector + + tempo: + image: grafana/tempo:2.4.1 + profiles: [observability] + container_name: tempo + volumes: + - ./observability/tempo.yaml:/etc/tempo/tempo.yaml + command: ["-config.file=/etc/tempo/tempo.yaml"] + ports: + - "3200:3200" # For grafana, for otelcollector Tempo listens on 4317 for incoming traces by default + depends_on: + - otel-collector + + loki: + image: grafana/loki:2.9.0 + profiles: [observability] + container_name: loki + ports: + - "3100:3100" + depends_on: + - otel-collector + + grafana: + image: grafana/grafana + profiles: [observability] + container_name: grafana + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + ports: + - "3400:3000" + depends_on: + - prometheus + - tempo + - loki + +# persistent volume volumes: pgdata: # Named volume to store Postgres data (managed by Docker) diff --git a/frontend/src/sections/kanbanboard/hooks/useSignalRTaskHub.ts b/frontend/src/sections/kanbanboard/hooks/useSignalRTaskHub.ts index 48055f0..f69d8f7 100644 --- a/frontend/src/sections/kanbanboard/hooks/useSignalRTaskHub.ts +++ b/frontend/src/sections/kanbanboard/hooks/useSignalRTaskHub.ts @@ -3,7 +3,7 @@ import type { HubConnection } from '@microsoft/signalr'; import { useRef, useLayoutEffect } from 'react'; import { HubConnectionBuilder } from '@microsoft/signalr'; -import { getSeverUrl } from 'src/utils/env'; +import { getServerUrl } from 'src/utils/env'; import { NOTIFY_STATUS } from '../type/kanban-item'; @@ -19,7 +19,7 @@ export const useSignalRTaskHub = ( useLayoutEffect(() => { const connectToHub = async () => { const connection = new HubConnectionBuilder() - .withUrl(`${getSeverUrl()}/taskHub`, { + .withUrl(`${getServerUrl()}/taskHub`, { withCredentials: true, // Required }) .withAutomaticReconnect() diff --git a/frontend/src/sections/kanbanboard/view/kanban-board-view.tsx b/frontend/src/sections/kanbanboard/view/kanban-board-view.tsx index 9552f41..a7c2a44 100644 --- a/frontend/src/sections/kanbanboard/view/kanban-board-view.tsx +++ b/frontend/src/sections/kanbanboard/view/kanban-board-view.tsx @@ -66,6 +66,7 @@ export const KanbanBoardView = () => { return ( + i.id == selectedItem) ?? null} onSelect={handleSelectItem} diff --git a/frontend/src/utils/env.ts b/frontend/src/utils/env.ts index c90db06..36510bb 100644 --- a/frontend/src/utils/env.ts +++ b/frontend/src/utils/env.ts @@ -1,8 +1,8 @@ // TODO: Hack it for now, need to refactor to use .env files for cleaner approach later. // But need to think carefully and that might involve changed on dockerfile, docker-compose.yml, docker-compose.prod.yml, and ci.yml !! -export const getSeverUrl = (): string => +export const getServerUrl = (): string => window.location.hostname.includes('localhost') ? 'http://localhost:5070' : 'http://131.189.90.113:5070'; -export const getApiUrl = (): string => `${getSeverUrl()}/api/v1`; +export const getApiUrl = (): string => `${getServerUrl()}/api/v1`; diff --git a/observability/otel-collector-config.yaml b/observability/otel-collector-config.yaml new file mode 100644 index 0000000..846df11 --- /dev/null +++ b/observability/otel-collector-config.yaml @@ -0,0 +1,32 @@ +receivers: + otlp: + protocols: + grpc: + http: + +exporters: + prometheus: + endpoint: "0.0.0.0:8889" #pull model + + otlp/tempo: + endpoint: tempo:4319 #push model + tls: + insecure: true + + loki: + endpoint: "http://loki:3100/loki/api/v1/push" #push model + + logging: + loglevel: info + +service: + pipelines: + traces: + receivers: [otlp] + exporters: [otlp/tempo] + metrics: + receivers: [otlp] + exporters: [prometheus] + logs: + receivers: [otlp] + exporters: [loki] diff --git a/observability/prometheus.yml b/observability/prometheus.yml new file mode 100644 index 0000000..144bdea --- /dev/null +++ b/observability/prometheus.yml @@ -0,0 +1,7 @@ +global: + scrape_interval: 5s + +scrape_configs: + - job_name: "otel-collector" + static_configs: + - targets: ["otel-collector:8889"] diff --git a/observability/tempo.yaml b/observability/tempo.yaml new file mode 100644 index 0000000..1a0ecf1 --- /dev/null +++ b/observability/tempo.yaml @@ -0,0 +1,24 @@ +server: + http_listen_port: 3200 + grpc_listen_port: 4319 + +distributor: + receivers: + otlp: + protocols: + grpc: + http: + +ingester: + trace_idle_period: 10s + max_block_bytes: 1_000_000 + +compactor: + compaction: + block_retention: 48h + +storage: + trace: + backend: local + local: + path: /var/tempo/traces