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 000000000..f02402fd7 Binary files /dev/null and b/DragaliaAPI/rider-aspire.png differ diff --git a/DragaliaAPI/vs-aspire.png b/DragaliaAPI/vs-aspire.png new file mode 100644 index 000000000..2488b3f90 Binary files /dev/null and b/DragaliaAPI/vs-aspire.png differ diff --git a/PhotonStateManager/DragaliaAPI.Photon.StateManager.Test/CustomWebApplicationFactory.cs b/PhotonStateManager/DragaliaAPI.Photon.StateManager.Test/CustomWebApplicationFactory.cs index 4229b03d6..ba717065b 100644 --- a/PhotonStateManager/DragaliaAPI.Photon.StateManager.Test/CustomWebApplicationFactory.cs +++ b/PhotonStateManager/DragaliaAPI.Photon.StateManager.Test/CustomWebApplicationFactory.cs @@ -19,8 +19,8 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) => 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 } }