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
}
}