From 20242d4d6a39f4a6eda06b01b70fb1bb8cefde9f Mon Sep 17 00:00:00 2001 From: Jay Malhotra <5047192+SapiensAnatis@users.noreply.github.com> Date: Thu, 24 Oct 2024 21:49:03 +0100 Subject: [PATCH] Add metrics and Aspire app host (#1128) - Implement metrics and OpenTelemetry tracing - Use .NET aspire for local orchestration instead of Docker Compose TODO: - [x] Update self-hosting guidance as required env variables have changed - [x] Update Nomad deployment for the same reason - [x] Block /metrics from reverse proxy --- .../Dawnshard.AppHost.csproj | 22 +++ Aspire/Dawnshard.AppHost/Program.cs | 39 +++++ .../Properties/launchSettings.json | 29 ++++ Aspire/Dawnshard.AppHost/appsettings.json | 12 ++ .../Dawnshard.ServiceDefaults.csproj | 22 +++ .../Dawnshard.ServiceDefaults/Extensions.cs | 160 ++++++++++++++++++ .../FilteringProcessor.cs | 33 ++++ .../HealthCheckWriter.cs | 4 +- Aspire/Directory.Build.props | 9 + Directory.Packages.props | 14 ++ DragaliaAPI.sln | 19 +++ .../DragaliaAPI.Database/ApiContextFactory.cs | 6 +- .../DatabaseConfiguration.cs | 8 +- .../DragaliaAPI.Database/PostgresOptions.cs | 34 ---- DragaliaAPI/DragaliaAPI/Dockerfile | 1 + DragaliaAPI/DragaliaAPI/DragaliaAPI.csproj | 10 ++ DragaliaAPI/DragaliaAPI/Program.cs | 56 +++--- .../Properties/launchSettings.json | 2 +- .../DragaliaAPI/ServiceConfiguration.cs | 39 ++++- DragaliaAPI/README.md | 47 +++-- DragaliaAPI/rider-aspire.png | Bin 0 -> 4308 bytes DragaliaAPI/vs-aspire.png | Bin 0 -> 3198 bytes .../CustomWebApplicationFactory.cs | 4 +- .../TestContainersHelper.cs | 52 +++--- .../Dockerfile | 1 + .../DragaliaAPI.Photon.StateManager.csproj | 2 + .../Program.cs | 74 +------- .../ServiceConfiguration.cs | 101 +++++++++++ Website/vite.config.ts | 2 +- 29 files changed, 594 insertions(+), 208 deletions(-) create mode 100644 Aspire/Dawnshard.AppHost/Dawnshard.AppHost.csproj create mode 100644 Aspire/Dawnshard.AppHost/Program.cs create mode 100644 Aspire/Dawnshard.AppHost/Properties/launchSettings.json create mode 100644 Aspire/Dawnshard.AppHost/appsettings.json create mode 100644 Aspire/Dawnshard.ServiceDefaults/Dawnshard.ServiceDefaults.csproj create mode 100644 Aspire/Dawnshard.ServiceDefaults/Extensions.cs create mode 100644 Aspire/Dawnshard.ServiceDefaults/FilteringProcessor.cs rename {DragaliaAPI/DragaliaAPI/Services/Health => Aspire/Dawnshard.ServiceDefaults}/HealthCheckWriter.cs (95%) create mode 100644 Aspire/Directory.Build.props create mode 100644 DragaliaAPI/rider-aspire.png create mode 100644 DragaliaAPI/vs-aspire.png create mode 100644 PhotonStateManager/DragaliaAPI.Photon.StateManager/ServiceConfiguration.cs diff --git a/Aspire/Dawnshard.AppHost/Dawnshard.AppHost.csproj b/Aspire/Dawnshard.AppHost/Dawnshard.AppHost.csproj new file mode 100644 index 000000000..6a4714349 --- /dev/null +++ b/Aspire/Dawnshard.AppHost/Dawnshard.AppHost.csproj @@ -0,0 +1,22 @@ + + + + + Exe + true + 6c42e872-dfcb-405f-a064-2169c50f7123 + + + + + + + + + + + + + + + diff --git a/Aspire/Dawnshard.AppHost/Program.cs b/Aspire/Dawnshard.AppHost/Program.cs new file mode 100644 index 000000000..040f94d6e --- /dev/null +++ b/Aspire/Dawnshard.AppHost/Program.cs @@ -0,0 +1,39 @@ +using Microsoft.Extensions.Configuration; + +IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(args); + +IResourceBuilder postgres = builder + .AddPostgres("postgres") + .WithImage("postgres", "16.4") + .WithDataVolume("dragalia-api-pgdata"); + +IResourceBuilder redis = builder + .AddRedis("redis") + .WithImage("redis/redis-stack", "7.4.0-v0"); + +IResourceBuilder dragaliaApi = builder + .AddProject("dragalia-api") + .WithReference(postgres) + .WithReference(redis) + .WithExternalHttpEndpoints(); + +if (builder.Configuration.GetValue("EnableStateManager")) +{ + IResourceBuilder stateManager = builder + .AddProject("photon-state-manager") + .WithReference(redis) + .WithEndpoint("http", http => http.TargetHost = "0.0.0.0") + .WithExternalHttpEndpoints(); + + dragaliaApi.WithEnvironment("PhotonOptions__StateManagerUrl", stateManager.GetEndpoint("http")); +} + +if (builder.Configuration.GetValue("EnableWebsite")) +{ + builder + .AddNpmApp("website", workingDirectory: "../Website", scriptName: "dev") + .WithEnvironment("PUBLIC_ENABLE_MSW", "false") + .WithEnvironment("DAWNSHARD_API_URL_SSR", dragaliaApi.GetEndpoint("http")); +} + +builder.Build().Run(); diff --git a/Aspire/Dawnshard.AppHost/Properties/launchSettings.json b/Aspire/Dawnshard.AppHost/Properties/launchSettings.json new file mode 100644 index 000000000..e92c8091a --- /dev/null +++ b/Aspire/Dawnshard.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17267;http://localhost:15150", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21171", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22135" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15150", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19186", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20131" + } + } + } +} diff --git a/Aspire/Dawnshard.AppHost/appsettings.json b/Aspire/Dawnshard.AppHost/appsettings.json new file mode 100644 index 000000000..286fc46d9 --- /dev/null +++ b/Aspire/Dawnshard.AppHost/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + }, + "EnableStateManager": false, + "EnableWebsite": false, + "EnableGrafana": false +} diff --git a/Aspire/Dawnshard.ServiceDefaults/Dawnshard.ServiceDefaults.csproj b/Aspire/Dawnshard.ServiceDefaults/Dawnshard.ServiceDefaults.csproj new file mode 100644 index 000000000..5a59597a5 --- /dev/null +++ b/Aspire/Dawnshard.ServiceDefaults/Dawnshard.ServiceDefaults.csproj @@ -0,0 +1,22 @@ + + + + true + + + + + + + + + + + + + + + + + + diff --git a/Aspire/Dawnshard.ServiceDefaults/Extensions.cs b/Aspire/Dawnshard.ServiceDefaults/Extensions.cs new file mode 100644 index 000000000..845dddae4 --- /dev/null +++ b/Aspire/Dawnshard.ServiceDefaults/Extensions.cs @@ -0,0 +1,160 @@ +using Dawnshard.ServiceDefaults; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; +using Serilog; + +// ReSharper disable once CheckNamespace +namespace Microsoft.Extensions.Hosting; + +public static class Extensions +{ + public static WebApplicationBuilder AddServiceDefaults(this WebApplicationBuilder builder) + { + builder.ConfigureOpenTelemetry(); + builder.AddDefaultHealthChecks(); + builder.ConfigureLogging(); + + // Cannot add this as a transitive dependency upgrade breaks logging + // https://github.com/dotnet/extensions/issues/5336 + // Re-evaluate when upgrading to .NET 9 + // + // builder.Services.ConfigureHttpClientDefaults(http => + // { + // http.AddStandardResilienceHandler(); + // }); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + app.MapHealthChecks( + "/health", + new HealthCheckOptions() { ResponseWriter = HealthCheckWriter.WriteResponse } + ); + app.MapHealthChecks( + "/ping", + new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") } + ); + app.MapPrometheusScrapingEndpoint(); + + return app; + } + + private static WebApplicationBuilder ConfigureLogging(this WebApplicationBuilder builder) + { + builder.Host.UseSerilog( + static (context, config) => + { + config.ReadFrom.Configuration(context.Configuration); + config.Enrich.FromLogContext(); + + config.Filter.ByExcluding( + "EndsWith(RequestPath, '/health') and @l in ['verbose', 'debug', 'information'] ci" + ); + config.Filter.ByExcluding( + "EndsWith(RequestPath, '/ping') and @l in ['verbose', 'debug', 'information'] ci" + ); + config.Filter.ByExcluding( + "EndsWith(RequestPath, '/metrics') and @l in ['verbose', 'debug', 'information'] ci" + ); + + if (context.HasOtlpLogsEndpoint()) + { + config.WriteTo.OpenTelemetry(); + } + } + ); + + return builder; + } + + private static IHostApplicationBuilder ConfigureOpenTelemetry( + this IHostApplicationBuilder builder + ) + { + builder + .Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation() + .AddPrometheusExporter(); + }); + + if (builder.HasOtlpTracesEndpoint()) + { + builder + .Services.AddOpenTelemetry() + .WithTracing(tracing => + { + tracing + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddProcessor(); + }); + } + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static IHostApplicationBuilder AddOpenTelemetryExporters( + this IHostApplicationBuilder builder + ) + { + if (builder.HasOtlpTracesEndpoint()) + { + builder.Services.ConfigureOpenTelemetryTracerProvider(tracing => + tracing.AddOtlpExporter() + ); + } + + if (builder.HasOtlpMetricsEndpoint()) + { + builder.Services.ConfigureOpenTelemetryMeterProvider(metrics => + metrics.AddOtlpExporter() + ); + } + + return builder; + } + + private static IHostApplicationBuilder AddDefaultHealthChecks( + this IHostApplicationBuilder builder + ) + { + builder + .Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static bool HasOtlpTracesEndpoint(this IHostApplicationBuilder builder) => + !string.IsNullOrWhiteSpace( + builder.Configuration.GetOtlpEndpoint("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") + ); + + private static bool HasOtlpMetricsEndpoint(this IHostApplicationBuilder builder) => + !string.IsNullOrWhiteSpace( + builder.Configuration.GetOtlpEndpoint("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT") + ); + + private static bool HasOtlpLogsEndpoint(this HostBuilderContext context) => + !string.IsNullOrWhiteSpace( + context.Configuration.GetOtlpEndpoint("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT") + ); + + private static string? GetOtlpEndpoint(this IConfiguration configuration, string envVarName) => + configuration[envVarName] ?? configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]; +} diff --git a/Aspire/Dawnshard.ServiceDefaults/FilteringProcessor.cs b/Aspire/Dawnshard.ServiceDefaults/FilteringProcessor.cs new file mode 100644 index 000000000..470494172 --- /dev/null +++ b/Aspire/Dawnshard.ServiceDefaults/FilteringProcessor.cs @@ -0,0 +1,33 @@ +using System.Diagnostics; +using OpenTelemetry; + +namespace Dawnshard.ServiceDefaults; + +/// +/// Custom processor to silence traces resulting from uninteresting activities such as metric scraping and healthchecks. +/// +internal sealed class FilteringProcessor : BaseProcessor +{ + public override void OnEnd(Activity data) + { + Activity root = data; + + while (root.Parent is not null) + { + root = root.Parent; + } + + if (root.OperationName != "Microsoft.AspNetCore.Hosting.HttpRequestIn") + { + return; + } + + foreach ((string key, string? value) in root.Tags) + { + if (key == "url.path" && value is "/metrics" or "/health" or "/ping") + { + root.ActivityTraceFlags &= ~ActivityTraceFlags.Recorded; + } + } + } +} diff --git a/DragaliaAPI/DragaliaAPI/Services/Health/HealthCheckWriter.cs b/Aspire/Dawnshard.ServiceDefaults/HealthCheckWriter.cs similarity index 95% rename from DragaliaAPI/DragaliaAPI/Services/Health/HealthCheckWriter.cs rename to Aspire/Dawnshard.ServiceDefaults/HealthCheckWriter.cs index 391604304..d7b4c272b 100644 --- a/DragaliaAPI/DragaliaAPI/Services/Health/HealthCheckWriter.cs +++ b/Aspire/Dawnshard.ServiceDefaults/HealthCheckWriter.cs @@ -1,7 +1,9 @@ using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Diagnostics.HealthChecks; -namespace DragaliaAPI.Services.Health; +namespace Dawnshard.ServiceDefaults; public class HealthCheckWriter { diff --git a/Aspire/Directory.Build.props b/Aspire/Directory.Build.props new file mode 100644 index 000000000..a4f1874d5 --- /dev/null +++ b/Aspire/Directory.Build.props @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index 50a9dc887..be0d6ec0a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,6 +3,10 @@ true + + + + @@ -44,12 +48,22 @@ + + + + + + + + + + diff --git a/DragaliaAPI.sln b/DragaliaAPI.sln index 67cf28d2f..eec3a8288 100644 --- a/DragaliaAPI.sln +++ b/DragaliaAPI.sln @@ -99,6 +99,15 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DragaliaAPI.Shared.SourceGe EndProject Project("{E53339B2-1760-4266-BCC7-CA923CBCF16C}") = "docker-compose", "docker-compose.dcproj", "{AB163A1E-1339-4CFC-82AD-E59ECFADA3C2}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Aspire", "Aspire", "{AB081B46-316E-4C34-8CBB-E5A0E42E1E66}" + ProjectSection(SolutionItems) = preProject + Aspire\Directory.Build.props = Aspire\Directory.Build.props + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dawnshard.AppHost", "Aspire\Dawnshard.AppHost\Dawnshard.AppHost.csproj", "{2BC007E8-E319-4FDE-AFFA-629111FB6D69}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dawnshard.ServiceDefaults", "Aspire\Dawnshard.ServiceDefaults\Dawnshard.ServiceDefaults.csproj", "{61FBE208-8670-452C-A2B2-9CAFB1791839}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -177,6 +186,14 @@ Global {AB163A1E-1339-4CFC-82AD-E59ECFADA3C2}.Debug|Any CPU.Build.0 = Debug|Any CPU {AB163A1E-1339-4CFC-82AD-E59ECFADA3C2}.Release|Any CPU.ActiveCfg = Release|Any CPU {AB163A1E-1339-4CFC-82AD-E59ECFADA3C2}.Release|Any CPU.Build.0 = Release|Any CPU + {2BC007E8-E319-4FDE-AFFA-629111FB6D69}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2BC007E8-E319-4FDE-AFFA-629111FB6D69}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2BC007E8-E319-4FDE-AFFA-629111FB6D69}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2BC007E8-E319-4FDE-AFFA-629111FB6D69}.Release|Any CPU.Build.0 = Release|Any CPU + {61FBE208-8670-452C-A2B2-9CAFB1791839}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {61FBE208-8670-452C-A2B2-9CAFB1791839}.Debug|Any CPU.Build.0 = Debug|Any CPU + {61FBE208-8670-452C-A2B2-9CAFB1791839}.Release|Any CPU.ActiveCfg = Release|Any CPU + {61FBE208-8670-452C-A2B2-9CAFB1791839}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -202,6 +219,8 @@ Global {18AB580B-2A52-4BD7-9642-60A7DFAA4FFE} = {F0C76530-C14A-4601-829B-EE0F4C7E24E6} {FD0F2BDF-715C-417D-9059-1F2EF8FA8901} = {F0C76530-C14A-4601-829B-EE0F4C7E24E6} {517FBE68-58A1-48DB-A798-13D6BDECF623} = {F0C76530-C14A-4601-829B-EE0F4C7E24E6} + {2BC007E8-E319-4FDE-AFFA-629111FB6D69} = {AB081B46-316E-4C34-8CBB-E5A0E42E1E66} + {61FBE208-8670-452C-A2B2-9CAFB1791839} = {AB081B46-316E-4C34-8CBB-E5A0E42E1E66} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D86831A9-C572-4B8D-BD12-EF0768BC9E4F} diff --git a/DragaliaAPI/DragaliaAPI.Database/ApiContextFactory.cs b/DragaliaAPI/DragaliaAPI.Database/ApiContextFactory.cs index 1888ab4fa..2b396707a 100644 --- a/DragaliaAPI/DragaliaAPI.Database/ApiContextFactory.cs +++ b/DragaliaAPI/DragaliaAPI.Database/ApiContextFactory.cs @@ -16,12 +16,8 @@ public ApiContext CreateDbContext(string[] args) .AddCommandLine(args) .Build(); - PostgresOptions? postgresOptions = configuration - .GetSection(nameof(PostgresOptions)) - ?.Get(); - DbContextOptions contextOptions = new DbContextOptionsBuilder() - .UseNpgsql(postgresOptions?.GetConnectionString("IDesignTimeDbContextFactory")) + .UseNpgsql(configuration.GetConnectionString("IDesignTimeDbContextFactory")) .Options; return new ApiContext(contextOptions, new StubPlayerIdentityService()); diff --git a/DragaliaAPI/DragaliaAPI.Database/DatabaseConfiguration.cs b/DragaliaAPI/DragaliaAPI.Database/DatabaseConfiguration.cs index 34ef1f82a..a862c5059 100644 --- a/DragaliaAPI/DragaliaAPI.Database/DatabaseConfiguration.cs +++ b/DragaliaAPI/DragaliaAPI.Database/DatabaseConfiguration.cs @@ -29,11 +29,11 @@ IConfiguration config .AddDbContext( (serviceProvider, options) => { - PostgresOptions postgresOptions = serviceProvider - .GetRequiredService>() - .Value; + IConfiguration configuration = + serviceProvider.GetRequiredService(); + options - .UseNpgsql(postgresOptions.GetConnectionString("ApiContext")) + .UseNpgsql(configuration.GetConnectionString("postgres")) .UseLoggerFactory(serviceProvider.GetRequiredService()) .EnableDetailedErrors(); } diff --git a/DragaliaAPI/DragaliaAPI.Database/PostgresOptions.cs b/DragaliaAPI/DragaliaAPI.Database/PostgresOptions.cs index 588ec86eb..966818d3e 100644 --- a/DragaliaAPI/DragaliaAPI.Database/PostgresOptions.cs +++ b/DragaliaAPI/DragaliaAPI.Database/PostgresOptions.cs @@ -1,40 +1,6 @@ -using Npgsql; - namespace DragaliaAPI.Database; public class PostgresOptions { - public string Hostname { get; set; } = "postgres"; - - public int Port { get; set; } = 5432; - - public string? Username { get; set; } - - public string? Password { get; set; } - - public string? Database { get; set; } - public bool DisableAutoMigration { get; set; } - - /// - /// Gets a connection string for the current instance. - /// - /// A suffix indicating the owner of the connection string, to be added to the application name parameter. - /// The connection string. - public string GetConnectionString(string subComponent) - { - NpgsqlConnectionStringBuilder connectionStringBuilder = - new() - { - Host = this.Hostname, - Port = this.Port, - Username = this.Username, - Password = this.Password, - Database = this.Database, - IncludeErrorDetail = true, - ApplicationName = $"DragaliaAPI_{subComponent}", - }; - - return connectionStringBuilder.ConnectionString; - } } diff --git a/DragaliaAPI/DragaliaAPI/Dockerfile b/DragaliaAPI/DragaliaAPI/Dockerfile index 4b591b0e6..e3810df45 100644 --- a/DragaliaAPI/DragaliaAPI/Dockerfile +++ b/DragaliaAPI/DragaliaAPI/Dockerfile @@ -16,6 +16,7 @@ COPY ["DragaliaAPI/DragaliaAPI/DragaliaAPI.csproj", "DragaliaAPI/DragaliaAPI/"] RUN dotnet restore "DragaliaAPI/DragaliaAPI/DragaliaAPI.csproj" COPY [".editorconfig", ".editorconfig"] COPY ["DragaliaAPI/", "DragaliaAPI/"] +COPY ["Aspire/", "Aspire/"] COPY ["Shared/", "Shared/"] WORKDIR "/src/DragaliaAPI/DragaliaAPI" RUN dotnet publish "DragaliaAPI.csproj" -c Release -o /app/publish/ /p:UseAppHost=false diff --git a/DragaliaAPI/DragaliaAPI/DragaliaAPI.csproj b/DragaliaAPI/DragaliaAPI/DragaliaAPI.csproj index c0f0e9ca4..d0b374bcb 100644 --- a/DragaliaAPI/DragaliaAPI/DragaliaAPI.csproj +++ b/DragaliaAPI/DragaliaAPI/DragaliaAPI.csproj @@ -40,11 +40,20 @@ + + + + + + + + + @@ -69,6 +78,7 @@ + diff --git a/DragaliaAPI/DragaliaAPI/Program.cs b/DragaliaAPI/DragaliaAPI/Program.cs index a86f31de9..6b42061ab 100644 --- a/DragaliaAPI/DragaliaAPI/Program.cs +++ b/DragaliaAPI/DragaliaAPI/Program.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Reflection; +using Dawnshard.ServiceDefaults; using DragaliaAPI; using DragaliaAPI.Authentication; using DragaliaAPI.Database; @@ -49,15 +50,8 @@ builder.Configuration.AddKeyPerFile(directoryPath: kpfPath, optional: true, reloadOnChange: true); -builder.Logging.ClearProviders(); -builder.Logging.AddSerilog(); -builder.Host.UseSerilog( - static (context, services, loggerConfig) => - loggerConfig - .ReadFrom.Configuration(context.Configuration) - .ReadFrom.Services(services) - .Enrich.FromLogContext() -); +builder.AddServiceDefaults(); +builder.ConfigureObservability(); builder .Services.AddControllers() @@ -70,26 +64,19 @@ option.InputFormatters.Add(new CustomMessagePackInputFormatter(CustomResolver.Options)); }); -RedisOptions redisOptions = - builder.Configuration.GetSection(nameof(RedisOptions)).Get() - ?? throw new InvalidOperationException("Failed to get Redis config"); -HangfireOptions hangfireOptions = - builder.Configuration.GetSection(nameof(HangfireOptions)).Get() - ?? new() { Enabled = false }; +HangfireOptions? hangfireOptions = builder + .Configuration.GetSection(nameof(HangfireOptions)) + .Get(); builder.Services.ConfigureDatabaseServices(builder.Configuration); builder.Services.AddStackExchangeRedisCache(options => { - options.ConfigurationOptions = new() - { - EndPoints = new() { { redisOptions.Hostname, redisOptions.Port } }, - Password = redisOptions.Password, - }; + options.Configuration = builder.Configuration.GetConnectionString("redis"); options.InstanceName = "RedisInstance"; }); -if (hangfireOptions.Enabled) +if (hangfireOptions is { Enabled: true }) { builder.Services.ConfigureHangfire(); } @@ -132,22 +119,20 @@ app.Logger.LogInformation("Loaded MasterAsset in {Time} ms.", watch.ElapsedMilliseconds); -PostgresOptions postgresOptions = app - .Services.GetRequiredService>() - .Value; - app.Logger.LogDebug( - "Using PostgreSQL connection {Host}:{Port}", - postgresOptions.Hostname, - postgresOptions.Port + "Using PostgreSQL connection {ConnectionString}", + builder.Configuration.GetConnectionString("postgres") ); app.Logger.LogDebug( - "Using Redis connection {Host}:{Port}", - redisOptions.Hostname, - redisOptions.Port + "Using PostgreSQL connection {ConnectionString}", + builder.Configuration.GetConnectionString("postgres") ); +PostgresOptions postgresOptions = app + .Services.GetRequiredService>() + .Value; + if (!postgresOptions.DisableAutoMigration) { app.MigrateDatabase(); @@ -207,7 +192,7 @@ } ); -if (hangfireOptions.Enabled) +if (hangfireOptions is { Enabled: true }) { app.AddHangfireJobs(); app.UseHangfireDashboard(); @@ -221,11 +206,8 @@ }); } -app.MapHealthChecks( - "/health", - new HealthCheckOptions() { ResponseWriter = HealthCheckWriter.WriteResponse } -); -app.MapGet("/ping", () => Results.Ok()); +app.MapDefaultEndpoints(); + app.MapGet( "/dragalipatch/config", ( diff --git a/DragaliaAPI/DragaliaAPI/Properties/launchSettings.json b/DragaliaAPI/DragaliaAPI/Properties/launchSettings.json index 804eb08b8..00c63ff86 100644 --- a/DragaliaAPI/DragaliaAPI/Properties/launchSettings.json +++ b/DragaliaAPI/DragaliaAPI/Properties/launchSettings.json @@ -8,7 +8,7 @@ "RedisOptions__Hostname": "localhost", "PostgresOptions__Hostname": "localhost" }, - "applicationUrl": "http://*:80;http://*:5000" + "applicationUrl": "http://+:80" } } } diff --git a/DragaliaAPI/DragaliaAPI/ServiceConfiguration.cs b/DragaliaAPI/DragaliaAPI/ServiceConfiguration.cs index 1907f8cc7..51a2f378c 100644 --- a/DragaliaAPI/DragaliaAPI/ServiceConfiguration.cs +++ b/DragaliaAPI/DragaliaAPI/ServiceConfiguration.cs @@ -26,6 +26,7 @@ using DragaliaAPI.Features.Version; using DragaliaAPI.Features.Web; using DragaliaAPI.Features.Zena; +using DragaliaAPI.Infrastructure; using DragaliaAPI.Infrastructure.Middleware; using DragaliaAPI.Models.Options; using DragaliaAPI.Services; @@ -38,6 +39,9 @@ using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Options; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; namespace DragaliaAPI; @@ -244,15 +248,13 @@ public static IServiceCollection ConfigureHangfire(this IServiceCollection servi serviceCollection.AddHangfire( (serviceProvider, cfg) => { - PostgresOptions postgresOptions = serviceProvider - .GetRequiredService>() - .Value; + IConfiguration configuration = serviceProvider.GetRequiredService(); cfg.SetDataCompatibilityLevel(CompatibilityLevel.Version_180) .UseSimpleAssemblyNameTypeSerializer() .UseRecommendedSerializerSettings() .UsePostgreSqlStorage(pgCfg => - pgCfg.UseNpgsqlConnection(postgresOptions.GetConnectionString("Hangfire")) + pgCfg.UseNpgsqlConnection(configuration.GetConnectionString("postgres")) ); } ); @@ -261,4 +263,33 @@ public static IServiceCollection ConfigureHangfire(this IServiceCollection servi return serviceCollection; } + + public static WebApplicationBuilder ConfigureObservability(this WebApplicationBuilder builder) + { + // Custom config on top of ServiceDefaults + + bool isDevelopment = builder.Environment.IsDevelopment(); + + builder + .Services.AddOpenTelemetry() + .ConfigureResource(cfg => + { + cfg.AddService(serviceName: "dragalia-api", autoGenerateServiceInstanceId: false); + }); + + if (builder.HasOtlpTracesEndpoint()) + { + builder + .Services.AddOpenTelemetry() + .WithTracing(tracing => + tracing.AddEntityFrameworkCoreInstrumentation(options => + options.SetDbStatementForText = isDevelopment + ) + // Not compatible with IDistributedCache as requires IConnectionMultiplexer + // .AddRedisInstrumentation() + ); + } + + return builder; + } } diff --git a/DragaliaAPI/README.md b/DragaliaAPI/README.md index 0d6060b5a..9e9ab82fe 100644 --- a/DragaliaAPI/README.md +++ b/DragaliaAPI/README.md @@ -6,45 +6,40 @@ DragaliaAPI is the main server component of Dawnshard, which handles the vast ma The server depends on [`DragaliaBaas`](https://github.com/DragaliaLostRevival/DragaliaBaasServer) as an identity provider. Clients are expected to go to an instance of the BaaS for login and authentication, and then come back -to `/tool/auth` with a signed JSON web token to authenticate against DragaliaAPI. +to `/tool/auth` with a signed JSON web token to authenticate against DragaliaAPI. The development instance is configured to point +at a publicly hosted instance of BaaS. ## Development environment ### Run the server -To get started, copy the `.env.default` file to `.env`. Choose some values for the database credentials, and then launch -the compose project from your IDE. Or, if using the command line, -use `docker-compose -f docker-compose.yml -f docker-compose.override.yml --profiles dragaliaapi`. +For local development, the recommended workflow is using the [.NET Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/get-started/aspire-overview) AppHost. +This will handle starting up the app and spinning up Docker containers for the dependent services. -The solution includes a `docker-compose.dcproj` project file which should be plug-and-play with Visual Studio and allow -launching the API plus the supporting Postgres and Redis services. It is compatible with container fast mode, so you can -iterate during development without rebuilding the containers each time. Other IDEs, including JetBrains Rider, should -also able to use the `docker-compose.yml` file if you add a run configuration pointed at it (as well -as `docker-compose.override.yml`). For users who are not using Visual Studio, ensure that your `docker-compose` -configuration or command includes an instruction to use the `dragaliaapi` profile so that the API is launched. +This has the following dependencies: -If you have issues with using the container fast mode, you can use the docker-compose file to only launch the supporting -services and then run the API directly on your machine. Either remove the profile arguments in your IDE or just -run `docker-compose -f docker-compose.yml up -d` from the command line without any `--profile` arguments to start Redis -and Postgres, and then launch the main project. You will need to configure the environment variables that it is run with -to match what is set in `docker-compose.yml`, and also to adjust the hostnames of Redis and Postgres now that it is not -running in the container network. +- A container runtime. On Windows, [Docker Desktop](https://docs.docker.com/desktop/install/windows-install/) is required to run Linux containers. +- The .NET Aspire workload. -An example configuration for running outside a container which is supported by Rider, Visual Studio, and the `dotnet` -cli, is included in [launchSettings.json](./DragaliaAPI/Properties/launchSettings.json). It does not include credentials -as it is in source control. The recommended way to set the credentials is using user secrets. See the [Microsoft Learn](https://learn.microsoft.com/en-us/aspnet/core/security/app-secrets#secret-manager) documentation on user secrets for -more information. +For more information, see the [Microsoft documentation on setting up .NET Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/setup-tooling). -You will need to set the following values corresponding to the values being used by Docker in `.env`: +The Aspire app host should be compatible with most IDEs like JetBrains Rider and Visual Studio. Simply select the `Dawnshard.AppHost: https` launch profile to start it up. -- `PostgresOptions:Username` -- `PostgresOptions:Password` -- `PostgresOptions:Database` +Rider: +![Aspire on Rider](rider-aspire.png) +Visual Studio: +![Aspire on Visual Studio](vs-aspire.png) +Alternatively, you can use the command line. From the repository root: + +``` +$ cd Dawnshard.AppHost +$ dotnet run +``` ### Set up a client -The `docker-compose.yml` / `launchSettings.json` file will start the server on port 80, so you can +The `launchSettings.json` file will start the server on port 80, so you can use [Dragalipatch](https://github.com/LukeFZ/DragaliPatch/releases/latest) with your [PC's local IP address](https://support.microsoft.com/en-us/windows/find-your-ip-address-in-windows-f21a9bbc-c582-55cd-35e0-73431160a1b9) to play on your local server with an emulator or mobile device. You must input the local IP address @@ -61,7 +56,7 @@ information. ### Dedicated server -On a dedicated server, the basic `docker-compose` setup will work, but additional considerations should be made +On a dedicated server, additional considerations should be made regarding reverse proxying, logging, etc. Speak to the maintainer if you are interested in hosting your own instance for further guidance. diff --git a/DragaliaAPI/rider-aspire.png b/DragaliaAPI/rider-aspire.png new file mode 100644 index 0000000000000000000000000000000000000000..f02402fd7f06d0105326a60e4770cd47d34b43a7 GIT binary patch literal 4308 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 new Dictionary { ["PhotonOptions:Token"] = "photontoken", - ["RedisOptions:Hostname"] = this.testContainersHelper.RedisHost, - ["RedisOptions:Port"] = this.testContainersHelper.RedisPort.ToString(), + ["ConnectionStrings:Redis"] = + this.testContainersHelper.GetRedisConnectionString(), } ) ); diff --git a/PhotonStateManager/DragaliaAPI.Photon.StateManager.Test/TestContainersHelper.cs b/PhotonStateManager/DragaliaAPI.Photon.StateManager.Test/TestContainersHelper.cs index ef9a050c6..1c95fab49 100644 --- a/PhotonStateManager/DragaliaAPI.Photon.StateManager.Test/TestContainersHelper.cs +++ b/PhotonStateManager/DragaliaAPI.Photon.StateManager.Test/TestContainersHelper.cs @@ -1,4 +1,5 @@ -using DotNet.Testcontainers.Builders; +using System.Diagnostics.CodeAnalysis; +using DotNet.Testcontainers.Builders; using DotNet.Testcontainers.Containers; namespace DragaliaAPI.Photon.StateManager.Test; @@ -9,30 +10,21 @@ public class TestContainersHelper private readonly IContainer? redisContainer; - public string RedisHost { get; private set; } - - public int RedisPort + public string GetRedisConnectionString() { - get + if (IsGithubActions) { - if (IsGithubActions) - return RedisContainerPort; - - ArgumentNullException.ThrowIfNull(this.redisContainer); - return this.redisContainer.GetMappedPublicPort(RedisContainerPort); + return $"localhost:{RedisContainerPort}"; } - } - private static bool IsGithubActions => - Environment.GetEnvironmentVariable("GITHUB_ACTIONS") is not null; + this.ThrowIfRedisContainerNull(); + + return $"{this.redisContainer.Hostname}:{this.redisContainer.GetMappedPublicPort(RedisContainerPort)}"; + } public TestContainersHelper() { - if (IsGithubActions) - { - RedisHost = "localhost"; - } - else + if (!IsGithubActions) { redisContainer = new ContainerBuilder() .WithImage("redis/redis-stack") @@ -40,26 +32,42 @@ public TestContainersHelper() .WithPortBinding(RedisContainerPort, true) .WithPortBinding(8001, true) .Build(); - - RedisHost = redisContainer.Hostname; } } public async Task StartAsync() { if (IsGithubActions) + { return; + } + + this.ThrowIfRedisContainerNull(); - ArgumentNullException.ThrowIfNull(this.redisContainer); await this.redisContainer.StartAsync(); } public async Task StopAsync() { if (IsGithubActions) + { return; + } + + this.ThrowIfRedisContainerNull(); - ArgumentNullException.ThrowIfNull(this.redisContainer); await this.redisContainer.StopAsync(); } + + [MemberNotNull(nameof(this.redisContainer))] + private void ThrowIfRedisContainerNull() + { + if (this.redisContainer is null) + { + throw new InvalidOperationException("Redis container not initialized!"); + } + } + + private static bool IsGithubActions => + Environment.GetEnvironmentVariable("GITHUB_ACTIONS") is not null; } diff --git a/PhotonStateManager/DragaliaAPI.Photon.StateManager/Dockerfile b/PhotonStateManager/DragaliaAPI.Photon.StateManager/Dockerfile index aef48f9cf..272b0d4d9 100644 --- a/PhotonStateManager/DragaliaAPI.Photon.StateManager/Dockerfile +++ b/PhotonStateManager/DragaliaAPI.Photon.StateManager/Dockerfile @@ -12,6 +12,7 @@ COPY ["nuget.config", "."] RUN dotnet restore "PhotonStateManager/DragaliaAPI.Photon.StateManager/DragaliaAPI.Photon.StateManager.csproj" COPY [".editorconfig", ".editorconfig"] COPY ["PhotonStateManager/", "PhotonStateManager/"] +COPY ["Aspire/", "Aspire/"] COPY ["Shared/", "Shared/"] WORKDIR "/src/PhotonStateManager/DragaliaAPI.Photon.StateManager" RUN dotnet publish "DragaliaAPI.Photon.StateManager.csproj" -c Release -o /app/publish/ /p:UseAppHost=false diff --git a/PhotonStateManager/DragaliaAPI.Photon.StateManager/DragaliaAPI.Photon.StateManager.csproj b/PhotonStateManager/DragaliaAPI.Photon.StateManager/DragaliaAPI.Photon.StateManager.csproj index c391f8ad3..003e6830a 100644 --- a/PhotonStateManager/DragaliaAPI.Photon.StateManager/DragaliaAPI.Photon.StateManager.csproj +++ b/PhotonStateManager/DragaliaAPI.Photon.StateManager/DragaliaAPI.Photon.StateManager.csproj @@ -16,6 +16,7 @@ + @@ -30,6 +31,7 @@ + diff --git a/PhotonStateManager/DragaliaAPI.Photon.StateManager/Program.cs b/PhotonStateManager/DragaliaAPI.Photon.StateManager/Program.cs index 4c778dd86..6969efcd6 100644 --- a/PhotonStateManager/DragaliaAPI.Photon.StateManager/Program.cs +++ b/PhotonStateManager/DragaliaAPI.Photon.StateManager/Program.cs @@ -12,76 +12,8 @@ builder.Services.AddControllers(); -builder.Host.UseSerilog( - (context, config) => - { - config.ReadFrom.Configuration(context.Configuration); - config.Enrich.FromLogContext(); - - config.Filter.ByExcluding( - "EndsWith(RequestPath, '/health') and @l in ['verbose', 'debug', 'information'] ci" - ); - config.Filter.ByExcluding( - "EndsWith(RequestPath, '/ping') and @l in ['verbose', 'debug', 'information'] ci" - ); - } -); - -builder - .Services.AddOptions() - .BindConfiguration(nameof(RedisCachingOptions)) - .Validate( - x => x.KeyExpiryTimeMins > 0, - "RedisCachingOptions.KeyExpiryTime must be greater than 0!" - ) - .ValidateOnStart(); - -builder - .Services.AddOptions() - .BindConfiguration(nameof(PhotonOptions)) - .Validate(x => !string.IsNullOrEmpty(x.Token), "Must specify a value for PhotonOptions.Token!") - .ValidateOnStart(); - -builder.Services.AddOptions().BindConfiguration(nameof(RedisOptions)); - -builder - .Services.AddAuthentication() - .AddScheme( - nameof(PhotonAuthenticationHandler), - null - ); - -builder.Services.AddHealthChecks().AddCheck("Redis"); - -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(config => -{ - config.SwaggerDoc( - "v1", - new() - { - Version = "v1", - Title = "Photon State Manager", - Description = "API for storing room state received from Photon webhooks.", - } - ); -}); - -builder.Services.AddSingleton(sp => -{ - RedisOptions redisOptions = sp.GetRequiredService>().Value; - - IConnectionMultiplexer multiplexer = ConnectionMultiplexer.Connect( - new ConfigurationOptions() - { - EndPoints = new() { { redisOptions.Hostname, redisOptions.Port } }, - Password = redisOptions.Password, - AbortOnConnectFail = false, - } - ); - - return new RedisConnectionProvider(multiplexer); -}); +builder.AddServiceDefaults(); +builder.ConfigureServices(); WebApplication app = builder.Build(); @@ -119,7 +51,7 @@ app.UseAuthentication(); app.MapControllers(); -app.MapHealthChecks("/health"); +app.MapDefaultEndpoints(); app.Run(); diff --git a/PhotonStateManager/DragaliaAPI.Photon.StateManager/ServiceConfiguration.cs b/PhotonStateManager/DragaliaAPI.Photon.StateManager/ServiceConfiguration.cs new file mode 100644 index 000000000..64a4bcc11 --- /dev/null +++ b/PhotonStateManager/DragaliaAPI.Photon.StateManager/ServiceConfiguration.cs @@ -0,0 +1,101 @@ +using DragaliaAPI.Photon.StateManager.Authentication; +using DragaliaAPI.Photon.StateManager.Models; +using Microsoft.AspNetCore.Authentication; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; +using Redis.OM; +using Redis.OM.Contracts; +using StackExchange.Redis; + +namespace DragaliaAPI.Photon.StateManager; + +internal static class ServiceConfiguration +{ + public static WebApplicationBuilder ConfigureServices(this WebApplicationBuilder builder) + { + builder.ConfigureOptions(); + builder.ConfigureObservability(); + + builder + .Services.AddAuthentication() + .AddScheme( + nameof(PhotonAuthenticationHandler), + null + ); + + builder.Services.AddHealthChecks().AddCheck("Redis"); + + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(config => + { + config.SwaggerDoc( + "v1", + new() + { + Version = "v1", + Title = "Photon State Manager", + Description = "API for storing room state received from Photon webhooks.", + } + ); + }); + + builder.Services.AddSingleton(serviceProvider => + { + string connectionString = + serviceProvider.GetRequiredService().GetConnectionString("redis") + ?? throw new InvalidOperationException("Missing Redis connection string!"); + + return ConnectionMultiplexer.Connect(connectionString); + }); + builder.Services.AddSingleton(); + + return builder; + } + + private static WebApplicationBuilder ConfigureOptions(this WebApplicationBuilder builder) + { + builder + .Services.AddOptions() + .BindConfiguration(nameof(RedisCachingOptions)) + .Validate( + x => x.KeyExpiryTimeMins > 0, + "RedisCachingOptions.KeyExpiryTime must be greater than 0!" + ) + .ValidateOnStart(); + + builder + .Services.AddOptions() + .BindConfiguration(nameof(PhotonOptions)) + .Validate( + x => !string.IsNullOrEmpty(x.Token), + "Must specify a value for PhotonOptions.Token!" + ) + .ValidateOnStart(); + + builder.Services.AddOptions().BindConfiguration(nameof(RedisOptions)); + + return builder; + } + + private static WebApplicationBuilder ConfigureObservability(this WebApplicationBuilder builder) + { + builder + .Services.AddOpenTelemetry() + .ConfigureResource(cfg => + { + cfg.AddService( + serviceName: "photon-state-manager", + autoGenerateServiceInstanceId: false + ); + }); + + if (builder.HasOtlpTracesEndpoint()) + { + builder + .Services.AddOpenTelemetry() + .WithTracing(tracing => tracing.AddRedisInstrumentation()); + } + + return builder; + } +} diff --git a/Website/vite.config.ts b/Website/vite.config.ts index 27b7bb93a..fe6f84ac9 100644 --- a/Website/vite.config.ts +++ b/Website/vite.config.ts @@ -17,7 +17,7 @@ export default defineConfig(({ mode }) => ({ host: true, proxy: { '/api': { - target: 'http://localhost:5000', + target: process.env.DAWNSHARD_API_URL_SSR ?? 'http://localhost:5000', changeOrigin: true } }