From 60839c0637cdb2c5a5e31f5b002f8e6ce828ec60 Mon Sep 17 00:00:00 2001
From: "Eric J. Smith" <eric@codesmithtools.com>
Date: Wed, 18 Dec 2024 13:37:05 -0600
Subject: [PATCH] Switch to using Aspire (#1784)

* Working on migrating to aspire

* Progress

* Some aspire progress

* Fix bad merge

* Update to Aspire 9

* Fix duplicate project refs

* Update elasticsearch to 8.16.1

* Add storage to Aspire

* Update Elasticsearch

* Fix tests

* Revert some changes. Fix linting.

* Revert more changes

* Cleanup

* Use the right Elasticsearch docker image

* Use explicit minio version

* Fixed launch setting

* Removed start and stop services

* Use fixed web client ports

* Use S3 storage when running local

* Fixed an issue where code could throw due to CurrentUser

* Fix S3

* [BREAKING] Remove scope prefix from bucket names and instead use scoped file storage for app scopes

* Only poll queue metrics in the same process that is running the stack event count job

* Reverted some of the breaking changes around storage.

---------

Co-authored-by: Blake Niemyjski <bniemyjski@gmail.com>
---
 Dockerfile                                    |   1 -
 Exceptionless.sln                             |  13 +-
 docker/docker-compose.dcproj                  |  11 --
 .../Exceptionless.AppHost.csproj              |  27 ++++
 .../Extensions/ElasticsearchExtensions.cs     | 122 ++++++++++++++++
 .../Extensions/ElasticsearchResource.cs       |  72 +++++++++
 .../Extensions/KibanaConfigWriterHook.cs      |  37 +++++
 .../Extensions/KibanaResource.cs              |   9 ++
 .../Extensions/MinIoExtensions.cs             | 138 ++++++++++++++++++
 .../Extensions/RedisExtensions.cs             |  28 ++++
 .../Extensions/VolumeNameGenerator.cs         |  65 +++++++++
 src/Exceptionless.AppHost/Program.cs          |  57 ++++++++
 .../Properties/launchSettings.json            |  42 ++++++
 .../appsettings.Development.json              |   8 +
 src/Exceptionless.AppHost/appsettings.json    |   9 ++
 .../Configuration/CacheOptions.cs             |  23 ++-
 ...CustomEnvironmentVariablesConfiguration.cs |  56 +++++++
 .../Configuration/ElasticsearchOptions.cs     |   2 +-
 .../Configuration/MessageBusOptions.cs        |  21 ++-
 .../Configuration/QueueOptions.cs             |  25 +++-
 .../Configuration/StorageOptions.cs           |  25 +++-
 .../Exceptionless.Core.csproj                 |   4 +-
 src/Exceptionless.Insulation/Bootstrapper.cs  |  60 +++++---
 .../Exceptionless.Insulation.csproj           |   6 +-
 .../Exceptionless.Job.csproj                  |  10 +-
 src/Exceptionless.Job/Program.cs              |  41 +++---
 .../Properties/launchSettings.json            |  34 ++---
 .../appsettings.Development.yml               |   2 +-
 src/Exceptionless.Web/ApmExtensions.cs        |   5 +-
 .../ClientApp/vite.config.ts                  |   5 +-
 .../Base/ExceptionlessApiController.cs        |  11 +-
 .../Controllers/EventController.cs            |  16 +-
 .../Exceptionless.Web.csproj                  |  57 +-------
 src/Exceptionless.Web/Program.cs              |   7 +-
 .../Properties/launchSettings.json            |  14 +-
 src/Exceptionless.Web/Startup.cs              |   8 +-
 .../appsettings.Development.yml               |   2 +-
 start-services.ps1                            |   1 -
 stop-services.ps1                             |   1 -
 .../Exceptionless.Tests/AppWebHostFactory.cs  |  34 ++++-
 .../Exceptionless.Tests.csproj                |   4 +-
 tests/Exceptionless.Tests/TestWithServices.cs |  21 +--
 42 files changed, 924 insertions(+), 210 deletions(-)
 delete mode 100644 docker/docker-compose.dcproj
 create mode 100644 src/Exceptionless.AppHost/Exceptionless.AppHost.csproj
 create mode 100644 src/Exceptionless.AppHost/Extensions/ElasticsearchExtensions.cs
 create mode 100644 src/Exceptionless.AppHost/Extensions/ElasticsearchResource.cs
 create mode 100644 src/Exceptionless.AppHost/Extensions/KibanaConfigWriterHook.cs
 create mode 100644 src/Exceptionless.AppHost/Extensions/KibanaResource.cs
 create mode 100644 src/Exceptionless.AppHost/Extensions/MinIoExtensions.cs
 create mode 100644 src/Exceptionless.AppHost/Extensions/RedisExtensions.cs
 create mode 100644 src/Exceptionless.AppHost/Extensions/VolumeNameGenerator.cs
 create mode 100644 src/Exceptionless.AppHost/Program.cs
 create mode 100644 src/Exceptionless.AppHost/Properties/launchSettings.json
 create mode 100644 src/Exceptionless.AppHost/appsettings.Development.json
 create mode 100644 src/Exceptionless.AppHost/appsettings.json
 create mode 100644 src/Exceptionless.Core/Configuration/CustomEnvironmentVariablesConfiguration.cs
 delete mode 100644 start-services.ps1
 delete mode 100644 stop-services.ps1

diff --git a/Dockerfile b/Dockerfile
index cae57a7fe1..b9d1209987 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -5,7 +5,6 @@ COPY ./*.sln ./NuGet.Config ./
 COPY ./src/*.props ./src/
 COPY ./tests/*.props ./tests/
 COPY ./build/packages/* ./build/packages/
-COPY ./docker/docker-compose.dcproj ./docker/
 
 # Copy the main source project files
 COPY src/*/*.csproj ./
diff --git a/Exceptionless.sln b/Exceptionless.sln
index 34cba061b0..04b99ad6db 100644
--- a/Exceptionless.sln
+++ b/Exceptionless.sln
@@ -11,7 +11,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
 		.github\workflows\build.yaml = .github\workflows\build.yaml
 		CONTRIBUTING.md = CONTRIBUTING.md
 		src\Directory.Build.props = src\Directory.Build.props
-		docker\docker-compose.yml = docker\docker-compose.yml
 		Dockerfile = Dockerfile
 		exceptionless.http = exceptionless.http
 		global.json = global.json
@@ -28,8 +27,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Exceptionless.Tests", "test
 EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Exceptionless.Job", "src\Exceptionless.Job\Exceptionless.Job.csproj", "{788BA00C-FFBE-42A9-92A3-89E24FC137B5}"
 EndProject
-Project("{E53339B2-1760-4266-BCC7-CA923CBCF16C}") = "docker-compose", "docker\docker-compose.dcproj", "{9F933018-9E8B-4649-8C9A-D217B5E1C184}"
-EndProject
 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "http", "http", "{97ED03A0-8C49-4B15-8D93-C56AF4DDC30F}"
 	ProjectSection(SolutionItems) = preProject
 		tests\http\admin.http = tests\http\admin.http
@@ -44,6 +41,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "http", "http", "{97ED03A0-8
 		tests\http\webhooks.http = tests\http\webhooks.http
 	EndProjectSection
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Exceptionless.AppHost", "src\Exceptionless.AppHost\Exceptionless.AppHost.csproj", "{EB1AF004-A00D-4016-BA97-5E89177B0074}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -70,10 +69,10 @@ Global
 		{788BA00C-FFBE-42A9-92A3-89E24FC137B5}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{788BA00C-FFBE-42A9-92A3-89E24FC137B5}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{788BA00C-FFBE-42A9-92A3-89E24FC137B5}.Release|Any CPU.Build.0 = Release|Any CPU
-		{9F933018-9E8B-4649-8C9A-D217B5E1C184}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{9F933018-9E8B-4649-8C9A-D217B5E1C184}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{9F933018-9E8B-4649-8C9A-D217B5E1C184}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{9F933018-9E8B-4649-8C9A-D217B5E1C184}.Release|Any CPU.Build.0 = Release|Any CPU
+		{EB1AF004-A00D-4016-BA97-5E89177B0074}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{EB1AF004-A00D-4016-BA97-5E89177B0074}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{EB1AF004-A00D-4016-BA97-5E89177B0074}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{EB1AF004-A00D-4016-BA97-5E89177B0074}.Release|Any CPU.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE
diff --git a/docker/docker-compose.dcproj b/docker/docker-compose.dcproj
deleted file mode 100644
index 1a5989b744..0000000000
--- a/docker/docker-compose.dcproj
+++ /dev/null
@@ -1,11 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<Project ToolsVersion="15.0" Sdk="Microsoft.Docker.Sdk">
-  <PropertyGroup Label="Globals">
-    <ProjectVersion>2.1</ProjectVersion>
-    <DockerTargetOS>Linux</DockerTargetOS>
-    <ProjectGuid>9f933018-9e8b-4649-8c9a-d217b5e1c184</ProjectGuid>
-  </PropertyGroup>
-  <ItemGroup>
-    <None Include="docker-compose.yml" />
-  </ItemGroup>
-</Project>
\ No newline at end of file
diff --git a/src/Exceptionless.AppHost/Exceptionless.AppHost.csproj b/src/Exceptionless.AppHost/Exceptionless.AppHost.csproj
new file mode 100644
index 0000000000..74d6e01d75
--- /dev/null
+++ b/src/Exceptionless.AppHost/Exceptionless.AppHost.csproj
@@ -0,0 +1,27 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <Sdk Name="Aspire.AppHost.Sdk" Version="9.0.0" />
+
+  <PropertyGroup>
+    <OutputType>Exe</OutputType>
+    <TargetFramework>net9.0</TargetFramework>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <Nullable>enable</Nullable>
+    <IsAspireHost>true</IsAspireHost>
+    <UserSecretsId>a9c2ddcc-e51d-4cd1-9782-96e1d74eec87</UserSecretsId>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="Aspire.Hosting.AppHost" Version="9.0.0" />
+    <PackageReference Include="Aspire.Hosting.NodeJs" Version="9.0.0" />
+    <PackageReference Include="Aspire.Hosting.Redis" Version="9.0.0" />
+    <PackageReference Include="AspNetCore.HealthChecks.Elasticsearch" Version="8.0.1" />
+    <PackageReference Include="Foundatio.AWS" Version="11.0.6" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\Exceptionless.Job\Exceptionless.Job.csproj" />
+    <ProjectReference Include="..\Exceptionless.Web\Exceptionless.Web.csproj" />
+  </ItemGroup>
+
+</Project>
diff --git a/src/Exceptionless.AppHost/Extensions/ElasticsearchExtensions.cs b/src/Exceptionless.AppHost/Extensions/ElasticsearchExtensions.cs
new file mode 100644
index 0000000000..423bb813ae
--- /dev/null
+++ b/src/Exceptionless.AppHost/Extensions/ElasticsearchExtensions.cs
@@ -0,0 +1,122 @@
+using Aspire.Hosting.Lifecycle;
+using Aspire.Hosting.Utils;
+using HealthChecks.Elasticsearch;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+
+namespace Aspire.Hosting;
+
+/// <summary>
+/// Provides extension methods for adding Elasticsearch resources to the application model.
+/// </summary>
+public static class ElasticsearchBuilderExtensions
+{
+    private const int ElasticsearchPort = 9200;
+    private const int ElasticsearchInternalPort = 9300;
+    private const int KibanaPort = 5601;
+
+    /// <summary>
+    /// Adds a Elasticsearch container to the application model. The default image is "docker.elastic.co/elasticsearch/elasticsearch". This version the package defaults to the 8.17.0 tag of the Elasticsearch container image
+    /// </summary>
+    /// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param>
+    /// <param name="name">The name of the resource. This name will be used as the connection string name when referenced in a dependency.</param>
+    /// <param name="port">The host port to bind the underlying container to.</param>
+    /// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
+    public static IResourceBuilder<ElasticsearchResource> AddElasticsearch(this IDistributedApplicationBuilder builder, [ResourceName] string name, int? port = null)
+    {
+        ArgumentNullException.ThrowIfNull(builder);
+        ArgumentNullException.ThrowIfNull(name);
+
+        var elasticsearch = new ElasticsearchResource(name);
+
+        string? connectionString = null;
+        ElasticsearchOptions? options = null;
+
+        builder.Eventing.Subscribe<ConnectionStringAvailableEvent>(elasticsearch, async (@event, ct) =>
+        {
+            connectionString = await elasticsearch.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false);
+            if (connectionString is null)
+            {
+                throw new DistributedApplicationException($"ConnectionStringAvailableEvent was published for the '{elasticsearch.Name}' resource but the connection string was null.");
+            }
+
+            options = new ElasticsearchOptions();
+            options.UseServer(connectionString);
+        });
+
+        var healthCheckKey = $"{name}_check";
+        builder.Services.AddHealthChecks()
+          .Add(new HealthCheckRegistration(
+              healthCheckKey,
+              sp => new ElasticsearchHealthCheck(options!),
+              failureStatus: default,
+              tags: default,
+              timeout: default));
+
+        return builder.AddResource(elasticsearch)
+            .WithImage(ElasticsearchContainerImageTags.Image, ElasticsearchContainerImageTags.Tag)
+            .WithImageRegistry(ElasticsearchContainerImageTags.ElasticsearchRegistry)
+            .WithHttpEndpoint(targetPort: ElasticsearchPort, port: port, name: ElasticsearchResource.PrimaryEndpointName)
+            .WithEndpoint(targetPort: ElasticsearchInternalPort, name: ElasticsearchResource.InternalEndpointName)
+            .WithEnvironment("discovery.type", "single-node")
+            .WithEnvironment("xpack.security.enabled", "false")
+            .WithEnvironment("action.destructive_requires_name", "false")
+            .WithEnvironment("ES_JAVA_OPTS", "-Xms1g -Xmx1g")
+            .WithHealthCheck(healthCheckKey)
+            .PublishAsConnectionString();
+    }
+
+    public static IResourceBuilder<ElasticsearchResource> WithKibana(this IResourceBuilder<ElasticsearchResource> builder, Action<IResourceBuilder<KibanaResource>>? configureContainer = null, string? containerName = null)
+    {
+        ArgumentNullException.ThrowIfNull(builder);
+
+        if (builder.ApplicationBuilder.Resources.OfType<KibanaResource>().SingleOrDefault() is { } existingKibanaResource)
+        {
+            var builderForExistingResource = builder.ApplicationBuilder.CreateResourceBuilder(existingKibanaResource);
+            configureContainer?.Invoke(builderForExistingResource);
+            return builder;
+        }
+        else
+        {
+            containerName ??= $"{builder.Resource.Name}-kibana";
+
+            builder.ApplicationBuilder.Services.TryAddLifecycleHook<KibanaConfigWriterHook>();
+
+            var resource = new KibanaResource(containerName);
+            var resourceBuilder = builder.ApplicationBuilder.AddResource(resource)
+                                      .WithImage(ElasticsearchContainerImageTags.KibanaImage, ElasticsearchContainerImageTags.Tag)
+                                      .WithImageRegistry(ElasticsearchContainerImageTags.KibanaRegistry)
+                                      .WithHttpEndpoint(targetPort: KibanaPort, name: containerName)
+                                      .WithEnvironment("xpack.security.enabled", "false")
+                                      .ExcludeFromManifest();
+
+            configureContainer?.Invoke(resourceBuilder);
+
+            return builder;
+        }
+    }
+
+    public static IResourceBuilder<ElasticsearchResource> WithDataVolume(this IResourceBuilder<ElasticsearchResource> builder, string? name = null)
+    {
+        ArgumentNullException.ThrowIfNull(builder);
+
+        return builder.WithVolume(name ?? VolumeNameGenerator.CreateVolumeName(builder, "data"), "/usr/share/elasticsearch/data");
+    }
+
+    public static IResourceBuilder<ElasticsearchResource> WithDataBindMount(this IResourceBuilder<ElasticsearchResource> builder, string source)
+    {
+        ArgumentNullException.ThrowIfNull(builder);
+        ArgumentNullException.ThrowIfNull(source);
+
+        return builder.WithBindMount(source, "/usr/share/elasticsearch/data");
+    }
+}
+
+internal static class ElasticsearchContainerImageTags
+{
+    public const string ElasticsearchRegistry = "docker.io";
+    public const string Image = "exceptionless/elasticsearch";
+    public const string KibanaRegistry = "docker.elastic.co";
+    public const string KibanaImage = "kibana/kibana";
+    public const string Tag = "8.17.0";
+}
diff --git a/src/Exceptionless.AppHost/Extensions/ElasticsearchResource.cs b/src/Exceptionless.AppHost/Extensions/ElasticsearchResource.cs
new file mode 100644
index 0000000000..ebdd1ea09d
--- /dev/null
+++ b/src/Exceptionless.AppHost/Extensions/ElasticsearchResource.cs
@@ -0,0 +1,72 @@
+namespace Aspire.Hosting;
+
+/// <summary>
+/// A resource that represents a Elasticsearch resource independent of the hosting model.
+/// </summary>
+public class ElasticsearchResource : ContainerResource, IResourceWithConnectionString
+{
+    // this endpoint is used for all API calls over HTTP.
+    // This includes search and aggregations, monitoring and anything else that uses a HTTP request.
+    // All client libraries will use this port to talk to Elasticsearch
+    internal const string PrimaryEndpointName = "http";
+
+    //this endpoint is a custom binary protocol used for communications between nodes in a cluster.
+    //For things like cluster updates, master elections, nodes joining/leaving, shard allocation
+    internal const string InternalEndpointName = "internal";
+
+    /// <param name="name">The name of the resource.</param>
+    public ElasticsearchResource(string name) : base(name)
+    {
+    }
+
+    private EndpointReference? _primaryEndpoint;
+    private EndpointReference? _internalEndpoint;
+
+    /// <summary>
+    /// Gets the primary endpoint for the Elasticsearch. This endpoint is used for all API calls over HTTP.
+    /// </summary>
+    public EndpointReference PrimaryEndpoint => _primaryEndpoint ??= new(this, PrimaryEndpointName);
+
+    /// <summary>
+    /// Gets the internal endpoint for the Elasticsearch. This endpoint used for communications between nodes in a cluster
+    /// </summary>
+    public EndpointReference InternalEndpoint => _internalEndpoint ??= new(this, InternalEndpointName);
+
+    /// <summary>
+    /// Gets the connection string expression for the Elasticsearch
+    /// </summary>
+    public ReferenceExpression ConnectionString =>
+        ReferenceExpression.Create($"http://{PrimaryEndpoint.Property(EndpointProperty.Host)}:{PrimaryEndpoint.Property(EndpointProperty.Port)}");
+
+
+    /// <summary>
+    /// Gets the connection string expression for the Elasticsearch server for the manifest.
+    /// </summary>
+    public ReferenceExpression ConnectionStringExpression
+    {
+        get
+        {
+            if (this.TryGetLastAnnotation<ConnectionStringRedirectAnnotation>(out var connectionStringAnnotation))
+            {
+                return connectionStringAnnotation.Resource.ConnectionStringExpression;
+            }
+
+            return ConnectionString;
+        }
+    }
+
+    /// <summary>
+    /// Gets the connection string for the Elasticsearch server.
+    /// </summary>
+    /// <param name="cancellationToken"> A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
+    /// <returns>A connection string for the Elasticsearch server in the form "http://host:port".</returns>
+    public ValueTask<string?> GetConnectionStringAsync(CancellationToken cancellationToken = default)
+    {
+        if (this.TryGetLastAnnotation<ConnectionStringRedirectAnnotation>(out var connectionStringAnnotation))
+        {
+            return connectionStringAnnotation.Resource.GetConnectionStringAsync(cancellationToken);
+        }
+
+        return ConnectionString.GetValueAsync(cancellationToken);
+    }
+}
diff --git a/src/Exceptionless.AppHost/Extensions/KibanaConfigWriterHook.cs b/src/Exceptionless.AppHost/Extensions/KibanaConfigWriterHook.cs
new file mode 100644
index 0000000000..67bae87b43
--- /dev/null
+++ b/src/Exceptionless.AppHost/Extensions/KibanaConfigWriterHook.cs
@@ -0,0 +1,37 @@
+using System.Text;
+using Aspire.Hosting.Lifecycle;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Aspire.Hosting;
+
+internal class KibanaConfigWriterHook : IDistributedApplicationLifecycleHook
+{
+    public async Task AfterEndpointsAllocatedAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken)
+    {
+        if (appModel.Resources.OfType<KibanaResource>().SingleOrDefault() is not { } kibanaResource)
+            return;
+
+        var elasticsearchInstances = appModel.Resources.OfType<ElasticsearchResource>();
+
+        if (!elasticsearchInstances.Any())
+            return;
+
+        var hostsVariableBuilder = new StringBuilder();
+
+        foreach (var elasticsearchInstance in elasticsearchInstances)
+        {
+            if (elasticsearchInstance.PrimaryEndpoint.IsAllocated)
+            {
+                var connectionString = await elasticsearchInstance.GetConnectionStringAsync();
+                if (hostsVariableBuilder.Length > 0)
+                    hostsVariableBuilder.Append(",");
+                hostsVariableBuilder.Append(elasticsearchInstance.PrimaryEndpoint.Scheme).Append("://").Append(elasticsearchInstance.PrimaryEndpoint.ContainerHost).Append(":").Append(elasticsearchInstance.PrimaryEndpoint.Port);
+            }
+        }
+
+        kibanaResource.Annotations.Add(new EnvironmentCallbackAnnotation(context =>
+        {
+            context.EnvironmentVariables.Add("ELASTICSEARCH_HOSTS", hostsVariableBuilder.ToString());
+        }));
+    }
+}
diff --git a/src/Exceptionless.AppHost/Extensions/KibanaResource.cs b/src/Exceptionless.AppHost/Extensions/KibanaResource.cs
new file mode 100644
index 0000000000..7e5031a46b
--- /dev/null
+++ b/src/Exceptionless.AppHost/Extensions/KibanaResource.cs
@@ -0,0 +1,9 @@
+namespace Aspire.Hosting;
+
+/// <summary>
+/// A resource that represents a Kibana container.
+/// </summary>
+/// <param name="name">The name of the resource.</param>
+public class KibanaResource(string name) : ContainerResource(name)
+{
+}
diff --git a/src/Exceptionless.AppHost/Extensions/MinIoExtensions.cs b/src/Exceptionless.AppHost/Extensions/MinIoExtensions.cs
new file mode 100644
index 0000000000..464f3985f8
--- /dev/null
+++ b/src/Exceptionless.AppHost/Extensions/MinIoExtensions.cs
@@ -0,0 +1,138 @@
+using Foundatio.Storage;
+
+namespace Aspire.Hosting;
+
+public static class MinIoExtensions
+{
+    public static IResourceBuilder<MinIoResource> AddMinIo(
+        this IDistributedApplicationBuilder builder,
+        string name,
+        Action<MinIoBuilder>? configure = null)
+    {
+        var options = new MinIoBuilder();
+        configure?.Invoke(options);
+
+        var resource = new MinIoResource(name, options.AccessKey, options.SecretKey, options.Bucket ?? "storage");
+
+        string? connectionString = null;
+
+        builder.Eventing.Subscribe<ResourceReadyEvent>(resource, async (@event, ct) =>
+        {
+            connectionString = await resource.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false);
+
+            if (connectionString == null)
+                throw new DistributedApplicationException($"ResourceReadyEvent was published for the '{resource.Name}' resource but the connection string was null.");
+
+            var storage = new S3FileStorage(o => o.ConnectionString(connectionString));
+            try
+            {
+                storage.Client.PutBucketAsync(options.Bucket ?? "storage", ct).GetAwaiter().GetResult();
+            }
+            catch
+            {
+                // ignored
+            }
+        });
+
+        return builder.AddResource(resource)
+            .WithImage(MinIoContainerImageTags.Image)
+            .WithImageRegistry(MinIoContainerImageTags.Registry)
+            .WithImageTag(MinIoContainerImageTags.Tag)
+            .WithArgs("server", "/data", "--console-address", $":{MinIoResource.DefaultConsolePort}")
+            .WithEndpoint(port: options.ApiPort, targetPort: MinIoResource.DefaultApiPort, name: MinIoResource.ApiEndpointName)
+            .WithHttpEndpoint(port: options.ConsolePort, targetPort: MinIoResource.DefaultConsolePort, name: MinIoResource.ConsoleEndpointName)
+            .ConfigureCredentials(options)
+            .ConfigureVolume(options);
+    }
+
+    private static IResourceBuilder<MinIoResource> ConfigureCredentials(
+        this IResourceBuilder<MinIoResource> builder,
+        MinIoBuilder options)
+    {
+        return builder
+            .WithEnvironment("MINIO_ROOT_USER", options.AccessKey ?? "minioadmin")
+            .WithEnvironment("MINIO_ROOT_PASSWORD", options.SecretKey ?? "minioadmin");
+    }
+
+    private static IResourceBuilder<MinIoResource> ConfigureVolume(
+        this IResourceBuilder<MinIoResource> builder,
+        MinIoBuilder options)
+    {
+        if (!string.IsNullOrEmpty(options.DataVolumePath))
+            builder = builder.WithVolume(options.DataVolumePath, "/data");
+
+        return builder;
+    }
+}
+
+public class MinIoResource(string name, string? accessKey = null, string? secretKey = null, string? bucket = "storage")
+    : ContainerResource(name), IResourceWithConnectionString
+{
+    internal const string ApiEndpointName = "api";
+    internal const string ConsoleEndpointName = "console";
+    internal const int DefaultApiPort = 9000;
+    internal const int DefaultConsolePort = 9001;
+
+    private EndpointReference? _apiReference;
+    private EndpointReference? _consoleReference;
+
+    private EndpointReference ApiEndpoint =>
+        _apiReference ??= new EndpointReference(this, ApiEndpointName);
+
+    private EndpointReference ConsoleEndpoint =>
+        _consoleReference ??= new EndpointReference(this, ConsoleEndpointName);
+
+    public ReferenceExpression ConnectionStringExpression =>
+        ReferenceExpression.Create(
+            $"ServiceUrl=http://{ApiEndpoint.Property(EndpointProperty.Host)}:{ApiEndpoint.Property(EndpointProperty.Port)};" +
+            $"AccessKey={AccessKey ?? "minioadmin"};" +
+            $"SecretKey={SecretKey ?? "minioadmin"};" +
+            $"Bucket={Bucket}");
+
+    public string? AccessKey { get; } = accessKey;
+    public string? SecretKey { get; } = secretKey;
+    public string? Bucket { get; } = bucket;
+}
+
+public class MinIoBuilder
+{
+    public int? ApiPort { get; set; }
+    public int? ConsolePort { get; set; }
+    public string? AccessKey { get; set; }
+    public string? SecretKey { get; set; }
+    public string? Bucket { get; set; }
+    public string? DataVolumePath { get; set; }
+
+    public MinIoBuilder WithPorts(int? apiPort = null, int? consolePort = null)
+    {
+        ApiPort = apiPort;
+        ConsolePort = consolePort;
+        return this;
+    }
+
+    public MinIoBuilder WithCredentials(string accessKey, string secretKey)
+    {
+        AccessKey = accessKey;
+        SecretKey = secretKey;
+        return this;
+    }
+
+    public MinIoBuilder WithBucket(string bucket)
+    {
+        Bucket = bucket;
+        return this;
+    }
+
+    public MinIoBuilder WithDataVolume(string path)
+    {
+        DataVolumePath = path;
+        return this;
+    }
+}
+
+internal static class MinIoContainerImageTags
+{
+    internal const string Registry = "docker.io";
+    internal const string Image = "minio/minio";
+    internal const string Tag = "RELEASE.2024-12-13T22-19-12Z";
+}
diff --git a/src/Exceptionless.AppHost/Extensions/RedisExtensions.cs b/src/Exceptionless.AppHost/Extensions/RedisExtensions.cs
new file mode 100644
index 0000000000..71e75f94db
--- /dev/null
+++ b/src/Exceptionless.AppHost/Extensions/RedisExtensions.cs
@@ -0,0 +1,28 @@
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using StackExchange.Redis;
+
+namespace Aspire.Hosting;
+
+public static class RedisExtensions
+{
+    public static IResourceBuilder<RedisResource> WithClearCommand(
+        this IResourceBuilder<RedisResource> builder)
+    {
+        builder.WithCommand(
+            "clear-cache",
+            "Clear Cache",
+            async _ =>
+            {
+                var redisConnectionString = await builder.Resource.GetConnectionStringAsync() ??
+                                            throw new InvalidOperationException("Unable to get the Redis connection string.");
+
+                await using var connection = await ConnectionMultiplexer.ConnectAsync(redisConnectionString);
+
+                await connection.GetDatabase().ExecuteAsync("FLUSHALL");
+
+                return CommandResults.Success();
+            },
+            context => context.ResourceSnapshot.HealthStatus is HealthStatus.Healthy ? ResourceCommandState.Enabled : ResourceCommandState.Disabled);
+        return builder;
+    }
+}
diff --git a/src/Exceptionless.AppHost/Extensions/VolumeNameGenerator.cs b/src/Exceptionless.AppHost/Extensions/VolumeNameGenerator.cs
new file mode 100644
index 0000000000..6ecbc551f8
--- /dev/null
+++ b/src/Exceptionless.AppHost/Extensions/VolumeNameGenerator.cs
@@ -0,0 +1,65 @@
+namespace Aspire.Hosting.Utils;
+
+internal static class VolumeNameGenerator
+{
+    public static string CreateVolumeName<T>(IResourceBuilder<T> builder, string suffix) where T : IResource
+    {
+        if (!HasOnlyValidChars(suffix))
+        {
+            throw new ArgumentException($"The suffix '{suffix}' contains invalid characters. Only [a-zA-Z0-9_.-] are allowed.", nameof(suffix));
+        }
+
+        // Creates a volume name with the form < c > $"{applicationName}-{sha256 of apphost path}-{resourceName}-{suffix}</c>, e.g. <c>"myapplication-a345f2451-postgres-data"</c>.
+        // Create volume name like "{Sanitize(appname).Lower()}-{sha256.Lower()}-postgres-data"
+
+        // Compute a short hash of the content root path to differentiate between multiple AppHost projects with similar volume names
+        var safeApplicationName = Sanitize(builder.ApplicationBuilder.Environment.ApplicationName).ToLowerInvariant();
+        var applicationHash = builder.ApplicationBuilder.Configuration["AppHost:Sha256"]![..10].ToLowerInvariant();
+        var resourceName = builder.Resource.Name;
+        return $"{safeApplicationName}-{applicationHash}-{resourceName}-{suffix}";
+    }
+
+    public static string Sanitize(string name)
+    {
+        return string.Create(name.Length, name, static (s, name) =>
+        {
+            // According to the error message from docker CLI, volume names must be of form "[a-zA-Z0-9][a-zA-Z0-9_.-]"
+            var nameSpan = name.AsSpan();
+
+            for (var i = 0; i < nameSpan.Length; i++)
+            {
+                var c = nameSpan[i];
+
+                s[i] = IsValidChar(i, c) ? c : '_';
+            }
+        });
+    }
+
+    private static bool HasOnlyValidChars(string value)
+    {
+        for (var i = 0; i < value.Length; i++)
+        {
+            if (!IsValidChar(i, value[i]))
+            {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private static bool IsValidChar(int i, char c)
+    {
+        if (i == 0 && !(char.IsAsciiLetter(c) || char.IsNumber(c)))
+        {
+            // First char must be a letter or number
+            return false;
+        }
+        else if (!(char.IsAsciiLetter(c) || char.IsNumber(c) || c == '_' || c == '.' || c == '-'))
+        {
+            // Subsequent chars must be a letter, number, underscore, period, or hyphen
+            return false;
+        }
+
+        return true;
+    }
+}
diff --git a/src/Exceptionless.AppHost/Program.cs b/src/Exceptionless.AppHost/Program.cs
new file mode 100644
index 0000000000..0df5bf1581
--- /dev/null
+++ b/src/Exceptionless.AppHost/Program.cs
@@ -0,0 +1,57 @@
+var builder = DistributedApplication.CreateBuilder(args);
+
+var elastic = builder.AddElasticsearch("Elasticsearch", port: 9200)
+    .WithLifetime(ContainerLifetime.Persistent)
+    .WithContainerName("Exceptionless-Elasticsearch")
+    .WithDataVolume("exceptionless.data.v1")
+    .WithKibana(b => b.WithLifetime(ContainerLifetime.Persistent).WithContainerName("Exceptionless-Kibana"));
+
+var storage = builder.AddMinIo("S3", s => s.WithCredentials("guest", "password").WithPorts(9000).WithBucket("ex-events"))
+    .WithLifetime(ContainerLifetime.Persistent)
+    .WithContainerName("Exceptionless-Storage");
+
+var cache = builder.AddRedis("Redis", port: 6379)
+    .WithImageTag("7.4")
+    .WithLifetime(ContainerLifetime.Persistent)
+    .WithContainerName("Exceptionless-Redis")
+    .WithClearCommand()
+    .WithRedisInsight(b => b.WithLifetime(ContainerLifetime.Persistent).WithContainerName("Exceptionless-RedisInsight"));
+
+var mail = builder.AddContainer("Mail", "mailhog/mailhog")
+    .WithLifetime(ContainerLifetime.Persistent)
+    .WithContainerName("Exceptionless-Mail")
+    .WithEndpoint(8025, 8025, "http")
+    .WithEndpoint(1025, 1025);
+
+builder.AddProject<Projects.Exceptionless_Job>("Jobs", "AllJobs")
+    .WithReference(cache)
+    .WithReference(elastic)
+    .WithReference(storage)
+    .WithEnvironment("ConnectionStrings:Email", "smtp://localhost:1025")
+    .WaitFor(elastic)
+    .WaitFor(cache)
+    .WaitFor(mail)
+    .WithHttpHealthCheck("/health");
+
+var api = builder.AddProject<Projects.Exceptionless_Web>("Api", "Exceptionless")
+    .WithReference(cache)
+    .WithReference(elastic)
+    .WithReference(storage)
+    .WithEnvironment("ConnectionStrings:Email", "smtp://localhost:1025")
+    .WithEnvironment("RunJobsInProcess", "false")
+    .WaitFor(elastic)
+    .WaitFor(cache)
+    .WaitFor(mail)
+    .WithHttpHealthCheck("/health");
+
+builder.AddNpmApp("Web", "../../src/Exceptionless.Web/ClientApp", "dev")
+    .WithReference(api)
+    .WithEnvironment("ASPNETCORE_URLS", "http://localhost:5200")
+    .WithEndpoint(port: 5173, targetPort: 5173, scheme: "http", env: "PORT", isProxied: false);
+
+builder.AddNpmApp("AngularWeb", "../../src/Exceptionless.Web/ClientApp.angular", "serve")
+    .WithReference(api)
+    .WithEnvironment("ASPNETCORE_URLS", "http://localhost:5200")
+    .WithEndpoint(port: 5100, targetPort: 5100, scheme: "http", env: "PORT", isProxied: false);
+
+builder.Build().Run();
diff --git a/src/Exceptionless.AppHost/Properties/launchSettings.json b/src/Exceptionless.AppHost/Properties/launchSettings.json
new file mode 100644
index 0000000000..a657132e91
--- /dev/null
+++ b/src/Exceptionless.AppHost/Properties/launchSettings.json
@@ -0,0 +1,42 @@
+{
+  "$schema": "https://json.schemastore.org/launchsettings.json",
+    "profiles": {
+        "https": {
+            "commandName": "Project",
+            "dotnetRunMessages": true,
+            "launchBrowser": true,
+            "applicationUrl": "https://localhost:17056;http://localhost:15161",
+            "environmentVariables": {
+                "ASPNETCORE_ENVIRONMENT": "Development",
+                "DOTNET_ENVIRONMENT": "Development",
+                "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21210",
+                "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22299"
+            }
+        },
+        "https-all": {
+            "commandName": "Project",
+            "dotnetRunMessages": true,
+            "launchBrowser": true,
+            "applicationUrl": "https://localhost:17056;http://localhost:15161",
+            "environmentVariables": {
+                "EX_ALL": "true",
+                "ASPNETCORE_ENVIRONMENT": "Development",
+                "DOTNET_ENVIRONMENT": "Development",
+                "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21210",
+                "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22299"
+            }
+        },
+        "http": {
+            "commandName": "Project",
+            "dotnetRunMessages": true,
+            "launchBrowser": true,
+            "applicationUrl": "http://localhost:15161",
+            "environmentVariables": {
+                "ASPNETCORE_ENVIRONMENT": "Development",
+                "DOTNET_ENVIRONMENT": "Development",
+                "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19113",
+                "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20111"
+            }
+        }
+    }
+}
diff --git a/src/Exceptionless.AppHost/appsettings.Development.json b/src/Exceptionless.AppHost/appsettings.Development.json
new file mode 100644
index 0000000000..0c208ae918
--- /dev/null
+++ b/src/Exceptionless.AppHost/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+  "Logging": {
+    "LogLevel": {
+      "Default": "Information",
+      "Microsoft.AspNetCore": "Warning"
+    }
+  }
+}
diff --git a/src/Exceptionless.AppHost/appsettings.json b/src/Exceptionless.AppHost/appsettings.json
new file mode 100644
index 0000000000..31c092aa45
--- /dev/null
+++ b/src/Exceptionless.AppHost/appsettings.json
@@ -0,0 +1,9 @@
+{
+  "Logging": {
+    "LogLevel": {
+      "Default": "Information",
+      "Microsoft.AspNetCore": "Warning",
+      "Aspire.Hosting.Dcp": "Warning"
+    }
+  }
+}
diff --git a/src/Exceptionless.Core/Configuration/CacheOptions.cs b/src/Exceptionless.Core/Configuration/CacheOptions.cs
index a0d788c482..3427e1a1bb 100644
--- a/src/Exceptionless.Core/Configuration/CacheOptions.cs
+++ b/src/Exceptionless.Core/Configuration/CacheOptions.cs
@@ -20,14 +20,29 @@ public static CacheOptions ReadFromConfiguration(IConfiguration config, AppOptio
         options.ScopePrefix = !String.IsNullOrEmpty(options.Scope) ? $"{options.Scope}-" : String.Empty;
 
         string? cs = config.GetConnectionString("Cache");
-        options.Data = cs.ParseConnectionString();
-        options.Provider = options.Data.GetString(nameof(options.Provider));
+        if (cs != null)
+        {
+            options.Data = cs.ParseConnectionString();
+            options.Provider = options.Data.GetString(nameof(options.Provider));
+        }
+        else
+        {
+            var redisConnectionString = config.GetConnectionString("Redis");
+            if (!String.IsNullOrEmpty(redisConnectionString))
+            {
+                options.Provider = "redis";
+            }
+        }
 
         string? providerConnectionString = !String.IsNullOrEmpty(options.Provider) ? config.GetConnectionString(options.Provider) : null;
         if (!String.IsNullOrEmpty(providerConnectionString))
-            options.Data.AddRange(providerConnectionString.ParseConnectionString());
+        {
+            var providerOptions = providerConnectionString.ParseConnectionString(defaultKey: "server");
+            options.Data ??= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+            options.Data.AddRange(providerOptions);
+        }
 
-        options.ConnectionString = options.Data.BuildConnectionString(new HashSet<string> { nameof(options.Provider) });
+        options.ConnectionString = options.Data.BuildConnectionString([nameof(options.Provider)]);
 
         return options;
     }
diff --git a/src/Exceptionless.Core/Configuration/CustomEnvironmentVariablesConfiguration.cs b/src/Exceptionless.Core/Configuration/CustomEnvironmentVariablesConfiguration.cs
new file mode 100644
index 0000000000..f626ff6788
--- /dev/null
+++ b/src/Exceptionless.Core/Configuration/CustomEnvironmentVariablesConfiguration.cs
@@ -0,0 +1,56 @@
+using System.Collections;
+using Microsoft.Extensions.Configuration;
+
+namespace Exceptionless.Core.Configuration;
+
+public static class CustomEnvironmentVariablesExtensions
+{
+    public static IConfigurationBuilder AddCustomEnvironmentVariables(this IConfigurationBuilder configurationBuilder)
+    {
+        configurationBuilder.Add(new CustomEnvironmentVariablesConfigurationSource());
+        return configurationBuilder;
+    }
+}
+
+public class CustomEnvironmentVariablesConfigurationSource : IConfigurationSource
+{
+    public IConfigurationProvider Build(IConfigurationBuilder builder)
+    {
+        return new CustomEnvironmentVariablesConfigurationProvider();
+    }
+}
+
+public class CustomEnvironmentVariablesConfigurationProvider : ConfigurationProvider
+{
+    public override void Load() => Load(Environment.GetEnvironmentVariables());
+
+    internal void Load(IDictionary envVariables)
+    {
+        var data = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
+
+        IDictionaryEnumerator e = envVariables.GetEnumerator();
+        try
+        {
+            while (e.MoveNext())
+            {
+                string key = (string)e.Entry.Key;
+                string? value = (string?)e.Entry.Value;
+
+                var normalizedKey = Normalize(key);
+                // remove EX_ prefix
+                if (normalizedKey.StartsWith("EX_"))
+                    data[normalizedKey.Substring(3)] = value;
+                else
+                    data[normalizedKey] = value;
+            }
+        }
+        finally
+        {
+            (e as IDisposable)?.Dispose();
+        }
+
+        Data = data;
+    }
+
+    private static string Normalize(string key) => key.Replace("__", ConfigurationPath.KeyDelimiter);
+}
diff --git a/src/Exceptionless.Core/Configuration/ElasticsearchOptions.cs b/src/Exceptionless.Core/Configuration/ElasticsearchOptions.cs
index 38eabafe72..bce6d41cd7 100644
--- a/src/Exceptionless.Core/Configuration/ElasticsearchOptions.cs
+++ b/src/Exceptionless.Core/Configuration/ElasticsearchOptions.cs
@@ -51,7 +51,7 @@ public static ElasticsearchOptions ReadFromConfiguration(IConfiguration config,
 
     private static void ParseConnectionString(string? connectionString, ElasticsearchOptions options, AppMode appMode)
     {
-        var pairs = connectionString.ParseConnectionString();
+        var pairs = connectionString.ParseConnectionString(defaultKey: "server");
         options.ServerUrl = pairs.GetString("server", "http://localhost:9200");
 
         int shards = pairs.GetValueOrDefault<int>("shards", 1);
diff --git a/src/Exceptionless.Core/Configuration/MessageBusOptions.cs b/src/Exceptionless.Core/Configuration/MessageBusOptions.cs
index b69407028f..23327d2387 100644
--- a/src/Exceptionless.Core/Configuration/MessageBusOptions.cs
+++ b/src/Exceptionless.Core/Configuration/MessageBusOptions.cs
@@ -22,12 +22,27 @@ public static MessageBusOptions ReadFromConfiguration(IConfiguration config, App
         options.Topic = config.GetValue<string>(nameof(options.Topic), $"{options.ScopePrefix}messages")!;
 
         string? cs = config.GetConnectionString("MessageBus");
-        options.Data = cs.ParseConnectionString();
-        options.Provider = options.Data.GetString(nameof(options.Provider));
+        if (cs != null)
+        {
+            options.Data = cs.ParseConnectionString();
+            options.Provider = options.Data.GetString(nameof(options.Provider));
+        }
+        else
+        {
+            var redisConnectionString = config.GetConnectionString("Redis");
+            if (!String.IsNullOrEmpty(redisConnectionString))
+            {
+                options.Provider = "redis";
+            }
+        }
 
         string? providerConnectionString = !String.IsNullOrEmpty(options.Provider) ? config.GetConnectionString(options.Provider) : null;
         if (!String.IsNullOrEmpty(providerConnectionString))
-            options.Data.AddRange(providerConnectionString.ParseConnectionString());
+        {
+            var providerOptions = providerConnectionString.ParseConnectionString(defaultKey: "server");
+            options.Data ??= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+            options.Data.AddRange(providerOptions);
+        }
 
         options.ConnectionString = options.Data.BuildConnectionString(new HashSet<string> { nameof(options.Provider) });
 
diff --git a/src/Exceptionless.Core/Configuration/QueueOptions.cs b/src/Exceptionless.Core/Configuration/QueueOptions.cs
index 9ab058ca66..5955eb25fd 100644
--- a/src/Exceptionless.Core/Configuration/QueueOptions.cs
+++ b/src/Exceptionless.Core/Configuration/QueueOptions.cs
@@ -13,6 +13,8 @@ public class QueueOptions
 
     public string Scope { get; internal set; } = null!;
     public string ScopePrefix { get; internal set; } = null!;
+    public bool MetricsPollingEnabled { get; set; } = true;
+    public TimeSpan MetricsPollingInterval { get; set; } = TimeSpan.FromSeconds(5);
 
     public static QueueOptions ReadFromConfiguration(IConfiguration config, AppOptions appOptions)
     {
@@ -20,15 +22,32 @@ public static QueueOptions ReadFromConfiguration(IConfiguration config, AppOptio
         options.ScopePrefix = !String.IsNullOrEmpty(options.Scope) ? $"{options.Scope}-" : String.Empty;
 
         string? cs = config.GetConnectionString("Queue");
-        options.Data = cs.ParseConnectionString();
-        options.Provider = options.Data.GetString(nameof(options.Provider));
+        if (cs != null)
+        {
+            options.Data = cs.ParseConnectionString();
+            options.Provider = options.Data.GetString(nameof(options.Provider));
+        }
+        else
+        {
+            var redisConnectionString = config.GetConnectionString("Redis");
+            if (!String.IsNullOrEmpty(redisConnectionString))
+            {
+                options.Provider = "redis";
+            }
+        }
 
         string? providerConnectionString = !String.IsNullOrEmpty(options.Provider) ? config.GetConnectionString(options.Provider) : null;
         if (!String.IsNullOrEmpty(providerConnectionString))
-            options.Data.AddRange(providerConnectionString.ParseConnectionString());
+        {
+            var providerOptions = providerConnectionString.ParseConnectionString(defaultKey: "server");
+            options.Data ??= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+            options.Data.AddRange(providerOptions);
+        }
 
         options.ConnectionString = options.Data.BuildConnectionString(new HashSet<string> { nameof(options.Provider) });
 
+        options.MetricsPollingInterval = appOptions.AppMode == AppMode.Development ? TimeSpan.FromSeconds(15) : TimeSpan.FromSeconds(5);
+
         return options;
     }
 }
diff --git a/src/Exceptionless.Core/Configuration/StorageOptions.cs b/src/Exceptionless.Core/Configuration/StorageOptions.cs
index 2c114e973d..79fbbba864 100644
--- a/src/Exceptionless.Core/Configuration/StorageOptions.cs
+++ b/src/Exceptionless.Core/Configuration/StorageOptions.cs
@@ -16,18 +16,31 @@ public class StorageOptions
 
     public static StorageOptions ReadFromConfiguration(IConfiguration config, AppOptions appOptions)
     {
-        var options = new StorageOptions();
-
-        options.Scope = appOptions.AppScope;
+        var options = new StorageOptions { Scope = appOptions.AppScope };
         options.ScopePrefix = !String.IsNullOrEmpty(options.Scope) ? $"{options.Scope}-" : String.Empty;
 
         string? cs = config.GetConnectionString("Storage");
-        options.Data = cs.ParseConnectionString();
-        options.Provider = options.Data.GetString(nameof(options.Provider));
+        if (cs != null)
+        {
+            options.Data = cs.ParseConnectionString();
+            options.Provider = options.Data.GetString(nameof(options.Provider));
+        }
+        else
+        {
+            var minioConnectionString = config.GetConnectionString("S3");
+            if (!String.IsNullOrEmpty(minioConnectionString))
+            {
+                options.Provider = "s3";
+            }
+        }
 
         string? providerConnectionString = !String.IsNullOrEmpty(options.Provider) ? config.GetConnectionString(options.Provider) : null;
         if (!String.IsNullOrEmpty(providerConnectionString))
-            options.Data.AddRange(providerConnectionString.ParseConnectionString());
+        {
+            var providerOptions = providerConnectionString.ParseConnectionString(defaultKey: "server");
+            options.Data ??= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+            options.Data.AddRange(providerOptions);
+        }
 
         options.ConnectionString = options.Data.BuildConnectionString(new HashSet<string> { nameof(options.Provider) });
 
diff --git a/src/Exceptionless.Core/Exceptionless.Core.csproj b/src/Exceptionless.Core/Exceptionless.Core.csproj
index bf3a748b3c..4b2da10210 100644
--- a/src/Exceptionless.Core/Exceptionless.Core.csproj
+++ b/src/Exceptionless.Core/Exceptionless.Core.csproj
@@ -22,8 +22,8 @@
   <ItemGroup>
     <PackageReference Include="AutoMapper" Version="13.0.1" />
     <PackageReference Include="FluentValidation" Version="11.11.0" />
-    <PackageReference Include="Foundatio.Extensions.Hosting" Version="11.0.6" />
-    <PackageReference Include="Foundatio.JsonNet" Version="11.0.6" />
+    <PackageReference Include="Foundatio.Extensions.Hosting" Version="11.0.7-alpha.0.3" />
+    <PackageReference Include="Foundatio.JsonNet" Version="11.0.7-alpha.0.3" />
     <PackageReference Include="MiniValidation" Version="0.9.1" />
     <PackageReference Include="NEST.JsonNetSerializer" Version="7.17.5" />
     <PackageReference Include="Handlebars.Net" Version="2.1.6" />
diff --git a/src/Exceptionless.Insulation/Bootstrapper.cs b/src/Exceptionless.Insulation/Bootstrapper.cs
index dd690ec6e4..d47c6916b6 100644
--- a/src/Exceptionless.Insulation/Bootstrapper.cs
+++ b/src/Exceptionless.Insulation/Bootstrapper.cs
@@ -183,22 +183,22 @@ private static void RegisterQueue(IServiceCollection container, QueueOptions opt
 
     private static void RegisterStorage(IServiceCollection container, StorageOptions options)
     {
-        if (String.Equals(options.Provider, "aliyun"))
+        if (String.Equals(options.Provider, "azurestorage"))
         {
-            container.ReplaceSingleton<IFileStorage>(s => new AliyunFileStorage(new AliyunFileStorageOptions
+            container.ReplaceSingleton<IFileStorage>(s => new AzureFileStorage(new AzureFileStorageOptions
             {
                 ConnectionString = options.ConnectionString,
+                ContainerName = $"{options.ScopePrefix}ex-events",
                 Serializer = s.GetRequiredService<ITextSerializer>(),
                 TimeProvider = s.GetRequiredService<TimeProvider>(),
                 LoggerFactory = s.GetRequiredService<ILoggerFactory>()
             }));
         }
-        else if (String.Equals(options.Provider, "azurestorage"))
+        else if (String.Equals(options.Provider, "aliyun"))
         {
-            container.ReplaceSingleton<IFileStorage>(s => new AzureFileStorage(new AzureFileStorageOptions
+            container.ReplaceSingleton<IFileStorage>(s => new AliyunFileStorage(new AliyunFileStorageOptions
             {
                 ConnectionString = options.ConnectionString,
-                ContainerName = $"{options.ScopePrefix}ex-events",
                 Serializer = s.GetRequiredService<ITextSerializer>(),
                 TimeProvider = s.GetRequiredService<TimeProvider>(),
                 LoggerFactory = s.GetRequiredService<ILoggerFactory>()
@@ -207,13 +207,21 @@ private static void RegisterStorage(IServiceCollection container, StorageOptions
         else if (String.Equals(options.Provider, "folder"))
         {
             string path = options.Data.GetString("path", "|DataDirectory|\\storage");
-            container.AddSingleton<IFileStorage>(s => new FolderFileStorage(new FolderFileStorageOptions
+            container.AddSingleton<IFileStorage>(s =>
             {
-                Folder = PathHelper.ExpandPath(path),
-                Serializer = s.GetRequiredService<ITextSerializer>(),
-                TimeProvider = s.GetRequiredService<TimeProvider>(),
-                LoggerFactory = s.GetRequiredService<ILoggerFactory>()
-            }));
+                IFileStorage storage = new FolderFileStorage(new FolderFileStorageOptions
+                {
+                    Folder = PathHelper.ExpandPath(path),
+                    Serializer = s.GetRequiredService<ITextSerializer>(),
+                    TimeProvider = s.GetRequiredService<TimeProvider>(),
+                    LoggerFactory = s.GetRequiredService<ILoggerFactory>()
+                });
+
+                if (!String.IsNullOrWhiteSpace(options.Scope))
+                    storage = new ScopedFileStorage(storage, options.Scope);
+
+                return storage;
+            });
         }
         else if (String.Equals(options.Provider, "minio"))
         {
@@ -227,16 +235,14 @@ private static void RegisterStorage(IServiceCollection container, StorageOptions
         }
         else if (String.Equals(options.Provider, "s3"))
         {
-            container.ReplaceSingleton<IFileStorage>(s => new S3FileStorage(new S3FileStorageOptions
-            {
-                ConnectionString = options.ConnectionString,
-                Credentials = GetAWSCredentials(options.Data),
-                Region = GetAWSRegionEndpoint(options.Data),
-                Bucket = $"{options.ScopePrefix}{options.Data.GetString("bucket", "ex-events")}",
-                Serializer = s.GetRequiredService<ITextSerializer>(),
-                TimeProvider = s.GetRequiredService<TimeProvider>(),
-                LoggerFactory = s.GetRequiredService<ILoggerFactory>()
-            }));
+            container.ReplaceSingleton<IFileStorage>(s => new S3FileStorage(o => o
+                    .ConnectionString(options.ConnectionString)
+                    .Credentials(GetAWSCredentials(options.Data))
+                    .Region(GetAWSRegionEndpoint(options.Data))
+                    .Bucket(options.Data.GetString("bucket", $"{options.ScopePrefix}ex-events"))
+                    .Serializer(s.GetRequiredService<ITextSerializer>())
+                    .TimeProvider(s.GetRequiredService<TimeProvider>())
+                    .LoggerFactory(s.GetRequiredService<ILoggerFactory>())));
         }
     }
 
@@ -251,7 +257,9 @@ private static IQueue<T> CreateAzureStorageQueue<T>(IServiceProvider container,
             WorkItemTimeout = workItemTimeout.GetValueOrDefault(TimeSpan.FromMinutes(5.0)),
             Serializer = container.GetRequiredService<ISerializer>(),
             TimeProvider = container.GetRequiredService<TimeProvider>(),
-            LoggerFactory = container.GetRequiredService<ILoggerFactory>()
+            LoggerFactory = container.GetRequiredService<ILoggerFactory>(),
+            MetricsPollingEnabled = options.MetricsPollingEnabled,
+            MetricsPollingInterval = options.MetricsPollingInterval
         });
     }
 
@@ -267,7 +275,9 @@ private static IQueue<T> CreateRedisQueue<T>(IServiceProvider container, QueueOp
             RunMaintenanceTasks = runMaintenanceTasks,
             Serializer = container.GetRequiredService<ISerializer>(),
             TimeProvider = container.GetRequiredService<TimeProvider>(),
-            LoggerFactory = container.GetRequiredService<ILoggerFactory>()
+            LoggerFactory = container.GetRequiredService<ILoggerFactory>(),
+            MetricsPollingEnabled = options.MetricsPollingEnabled,
+            MetricsPollingInterval = options.MetricsPollingInterval
         });
     }
 
@@ -295,7 +305,9 @@ private static IQueue<T> CreateSQSQueue<T>(IServiceProvider container, QueueOpti
             WorkItemTimeout = workItemTimeout.GetValueOrDefault(TimeSpan.FromMinutes(5.0)),
             Serializer = container.GetRequiredService<ISerializer>(),
             TimeProvider = container.GetRequiredService<TimeProvider>(),
-            LoggerFactory = container.GetRequiredService<ILoggerFactory>()
+            LoggerFactory = container.GetRequiredService<ILoggerFactory>(),
+            MetricsPollingEnabled = options.MetricsPollingEnabled,
+            MetricsPollingInterval = options.MetricsPollingInterval
         });
     }
 
diff --git a/src/Exceptionless.Insulation/Exceptionless.Insulation.csproj b/src/Exceptionless.Insulation/Exceptionless.Insulation.csproj
index 94aca8e88f..953d5aedea 100644
--- a/src/Exceptionless.Insulation/Exceptionless.Insulation.csproj
+++ b/src/Exceptionless.Insulation/Exceptionless.Insulation.csproj
@@ -14,10 +14,10 @@
     <PackageReference Include="Microsoft.Extensions.DependencyModel" Version="9.0.0" />
     <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
     <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.0" />
-    <PackageReference Include="MailKit" Version="4.8.0" />
-    <PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
+    <PackageReference Include="MailKit" Version="4.9.0" />
+    <PackageReference Include="Serilog.Extensions.Logging" Version="9.0.0" />
     <PackageReference Include="Serilog.Formatting.Compact" Version="3.0.0" />
-    <PackageReference Include="Serilog.Settings.Configuration" Version="8.0.4" />
+    <PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0" />
     <PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
     <PackageReference Include="Serilog.Sinks.ExceptionLess" Version="5.0.0" />
     <PackageReference Include="YamlDotNet" Version="16.2.1" />
diff --git a/src/Exceptionless.Job/Exceptionless.Job.csproj b/src/Exceptionless.Job/Exceptionless.Job.csproj
index 5ae38e689a..a7161b3e28 100644
--- a/src/Exceptionless.Job/Exceptionless.Job.csproj
+++ b/src/Exceptionless.Job/Exceptionless.Job.csproj
@@ -7,7 +7,7 @@
     <PackageReference Include="Exceptionless.AspNetCore" Version="6.0.4" />
     <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.0" />
     <PackageReference Include="App.Metrics.AspNetCore" Version="4.3.0" />
-    <PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
+    <PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
     <PackageReference Include="Serilog.Enrichers.Span" Version="3.1.0" />
     <PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
 
@@ -15,12 +15,12 @@
     <PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.10.0" />
     <PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.10.0-beta.1" />
     <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.10.0" />
-    <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.9.0" />
+    <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.10.1" />
     <PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.10.0" />
-    <PackageReference Include="OpenTelemetry.Instrumentation.StackExchangeRedis" Version="1.9.0-beta.1" />
+    <PackageReference Include="OpenTelemetry.Instrumentation.StackExchangeRedis" Version="1.10.0-beta.1" />
     <PackageReference Include="OpenTelemetry.Instrumentation.ElasticsearchClient" Version="1.0.0-beta.5" />
-    <PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.9.0" />
-    <PackageReference Include="OpenTelemetry.Instrumentation.Process" Version="0.5.0-beta.7" />
+    <PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.10.0" />
+    <PackageReference Include="OpenTelemetry.Instrumentation.Process" Version="1.10.0-beta.1" />
   </ItemGroup>
 
   <ItemGroup Label="Transitive dependency updates to resolve vulnerability warnings">
diff --git a/src/Exceptionless.Job/Program.cs b/src/Exceptionless.Job/Program.cs
index 80da975ef5..2433bb65be 100644
--- a/src/Exceptionless.Job/Program.cs
+++ b/src/Exceptionless.Job/Program.cs
@@ -1,5 +1,6 @@
-using System.Diagnostics;
+using System.Diagnostics;
 using Exceptionless.Core;
+using Exceptionless.Core.Configuration;
 using Exceptionless.Core.Extensions;
 using Exceptionless.Core.Jobs;
 using Exceptionless.Core.Jobs.Elastic;
@@ -49,8 +50,7 @@ public static IHostBuilder CreateHostBuilder(string[] args)
             .SetBasePath(Directory.GetCurrentDirectory())
             .AddYamlFile("appsettings.yml", optional: true, reloadOnChange: true)
             .AddYamlFile($"appsettings.{environment}.yml", optional: true, reloadOnChange: true)
-            .AddEnvironmentVariables("EX_")
-            .AddEnvironmentVariables("ASPNETCORE_")
+            .AddCustomEnvironmentVariables()
             .AddCommandLine(args)
             .Build();
 
@@ -60,6 +60,9 @@ public static IHostBuilder CreateHostBuilder(string[] args)
             .ForContext<Program>();
 
         var options = AppOptions.ReadFromConfiguration(config);
+        // only poll the queue metrics if this process is going to run the stack event count job
+        options.QueueOptions.MetricsPollingEnabled = jobOptions.StackEventCount;
+
         var apmConfig = new ApmConfig(config, $"job-{jobOptions.JobName.ToLowerUnderscoredWords('-')}", options.InformationalVersion, options.CacheOptions.Provider == "redis");
 
         Log.Information("Bootstrapping Exceptionless {JobName} job(s) in {AppMode} mode ({InformationalVersion}) on {MachineName} with options {@Options}", jobOptions.JobName ?? "All", environment, options.InformationalVersion, Environment.MachineName, options);
@@ -85,13 +88,13 @@ public static IHostBuilder CreateHostBuilder(string[] args)
                         app.UseSerilogRequestLogging(o =>
                         {
                             o.MessageTemplate = "TraceId={TraceId} HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms";
-                            o.GetLevel = (context, duration, ex) =>
+                            o.GetLevel = new Func<HttpContext, double, Exception?, LogEventLevel>((context, duration, ex) =>
                             {
                                 if (ex is not null || context.Response.StatusCode > 499)
                                     return LogEventLevel.Error;
 
                                 return duration < 1000 && context.Response.StatusCode < 400 ? LogEventLevel.Debug : LogEventLevel.Information;
-                            };
+                            });
                         });
 
                         Bootstrapper.LogConfiguration(app.ApplicationServices, options, app.ApplicationServices.GetRequiredService<ILogger<Program>>());
@@ -146,27 +149,27 @@ private static void AddJobs(IServiceCollection services, JobRunnerOptions option
             services.AddJob<CleanupOrphanedDataJob>();
 
         if (options.CloseInactiveSessions)
-            services.AddJob<CloseInactiveSessionsJob>(o => o.WaitForStartupActions(true));
+            services.AddJob<CloseInactiveSessionsJob>(o => o.WaitForStartupActions());
         if (options.DailySummary)
-            services.AddJob<DailySummaryJob>(o => o.WaitForStartupActions(true));
+            services.AddJob<DailySummaryJob>(o => o.WaitForStartupActions());
         if (options.DataMigration)
-            services.AddJob<DataMigrationJob>(o => o.WaitForStartupActions(true));
+            services.AddJob<DataMigrationJob>(o => o.WaitForStartupActions());
 
         if (options is { DownloadGeoIPDatabase: true, AllJobs: true })
             services.AddCronJob<DownloadGeoIPDatabaseJob>("0 1 * * *");
         if (options is { DownloadGeoIPDatabase: true, AllJobs: false })
-            services.AddJob<DownloadGeoIPDatabaseJob>(o => o.WaitForStartupActions(true));
+            services.AddJob<DownloadGeoIPDatabaseJob>(o => o.WaitForStartupActions());
 
         if (options.EventNotifications)
-            services.AddJob<EventNotificationsJob>(o => o.WaitForStartupActions(true));
+            services.AddJob<EventNotificationsJob>(o => o.WaitForStartupActions());
         if (options.EventPosts)
-            services.AddJob<EventPostsJob>(o => o.WaitForStartupActions(true));
+            services.AddJob<EventPostsJob>(o => o.WaitForStartupActions());
         if (options.EventUsage)
-            services.AddJob<EventUsageJob>(o => o.WaitForStartupActions(true));
+            services.AddJob<EventUsageJob>(o => o.WaitForStartupActions());
         if (options.EventUserDescriptions)
-            services.AddJob<EventUserDescriptionsJob>(o => o.WaitForStartupActions(true));
+            services.AddJob<EventUserDescriptionsJob>(o => o.WaitForStartupActions());
         if (options.MailMessage)
-            services.AddJob<MailMessageJob>(o => o.WaitForStartupActions(true));
+            services.AddJob<MailMessageJob>(o => o.WaitForStartupActions());
 
         if (options is { MaintainIndexes: true, AllJobs: true })
             services.AddCronJob<MaintainIndexesJob>("10 */2 * * *");
@@ -174,14 +177,14 @@ private static void AddJobs(IServiceCollection services, JobRunnerOptions option
             services.AddJob<MaintainIndexesJob>();
 
         if (options.Migration)
-            services.AddJob<MigrationJob>(o => o.WaitForStartupActions(true));
+            services.AddJob<MigrationJob>(o => o.WaitForStartupActions());
         if (options.StackStatus)
-            services.AddJob<StackStatusJob>(o => o.WaitForStartupActions(true));
+            services.AddJob<StackStatusJob>(o => o.WaitForStartupActions());
         if (options.StackEventCount)
-            services.AddJob<StackEventCountJob>(o => o.WaitForStartupActions(true));
+            services.AddJob<StackEventCountJob>(o => o.WaitForStartupActions());
         if (options.WebHooks)
-            services.AddJob<WebHooksJob>(o => o.WaitForStartupActions(true));
+            services.AddJob<WebHooksJob>(o => o.WaitForStartupActions());
         if (options.WorkItem)
-            services.AddJob<WorkItemJob>(o => o.WaitForStartupActions(true));
+            services.AddJob<WorkItemJob>(o => o.WaitForStartupActions());
     }
 }
diff --git a/src/Exceptionless.Job/Properties/launchSettings.json b/src/Exceptionless.Job/Properties/launchSettings.json
index 9043c97165..f6f61e578f 100644
--- a/src/Exceptionless.Job/Properties/launchSettings.json
+++ b/src/Exceptionless.Job/Properties/launchSettings.json
@@ -5,7 +5,7 @@
       "environmentVariables": {
         "EX_AppMode": "Development"
       },
-      "launchBrowser": true,
+      "launchBrowser": false,
       "applicationUrl": "https://localhost:5002;http://localhost:5003"
     },
     "CleanupDataJob": {
@@ -14,7 +14,7 @@
       "environmentVariables": {
         "EX_AppMode": "Development"
       },
-      "launchBrowser": true,
+      "launchBrowser": false,
       "applicationUrl": "https://localhost:5002;http://localhost:5003"
     },
     "DataMigrationJob": {
@@ -23,7 +23,7 @@
       "environmentVariables": {
         "EX_AppMode": "Development"
       },
-      "launchBrowser": true,
+      "launchBrowser": false,
       "applicationUrl": "https://localhost:5002;http://localhost:5003"
     },
     "MigrationJob": {
@@ -32,7 +32,7 @@
       "environmentVariables": {
         "EX_AppMode": "Development"
       },
-      "launchBrowser": true,
+      "launchBrowser": false,
       "applicationUrl": "https://localhost:5002;http://localhost:5003"
     },
     "EventPostsJob": {
@@ -41,7 +41,7 @@
       "environmentVariables": {
         "EX_AppMode": "Development"
       },
-      "launchBrowser": true,
+      "launchBrowser": false,
       "applicationUrl": "https://localhost:5002;http://localhost:5003"
     },
     "EventUserDescriptionsJob": {
@@ -50,7 +50,7 @@
       "environmentVariables": {
         "EX_AppMode": "Development"
       },
-      "launchBrowser": true,
+      "launchBrowser": false,
       "applicationUrl": "https://localhost:5002;http://localhost:5003"
     },
     "EventNotificationsJob": {
@@ -59,7 +59,7 @@
       "environmentVariables": {
         "EX_AppMode": "Development"
       },
-      "launchBrowser": true,
+      "launchBrowser": false,
       "applicationUrl": "https://localhost:5002;http://localhost:5003"
     },
     "MailMessageJob": {
@@ -68,7 +68,7 @@
       "environmentVariables": {
         "EX_AppMode": "Development"
       },
-      "launchBrowser": true,
+      "launchBrowser": false,
       "applicationUrl": "https://localhost:5002;http://localhost:5003"
     },
     "WebHooksJob": {
@@ -77,7 +77,7 @@
       "environmentVariables": {
         "EX_AppMode": "Development"
       },
-      "launchBrowser": true,
+      "launchBrowser": false,
       "applicationUrl": "https://localhost:5002;http://localhost:5003"
     },
     "CloseInactiveSessionsJob": {
@@ -86,7 +86,7 @@
       "environmentVariables": {
         "EX_AppMode": "Development"
       },
-      "launchBrowser": true,
+      "launchBrowser": false,
       "applicationUrl": "https://localhost:5002;http://localhost:5003"
     },
     "DailySummaryJob": {
@@ -95,7 +95,7 @@
       "environmentVariables": {
         "EX_AppMode": "Development"
       },
-      "launchBrowser": true,
+      "launchBrowser": false,
       "applicationUrl": "https://localhost:5002;http://localhost:5003"
     },
     "DownloadGeoIPDatabaseJob": {
@@ -104,7 +104,7 @@
       "environmentVariables": {
         "EX_AppMode": "Development"
       },
-      "launchBrowser": true,
+      "launchBrowser": false,
       "applicationUrl": "https://localhost:5002;http://localhost:5003"
     },
     "WorkItemJob": {
@@ -113,7 +113,7 @@
       "environmentVariables": {
         "EX_AppMode": "Development"
       },
-      "launchBrowser": true,
+      "launchBrowser": false,
       "applicationUrl": "https://localhost:5002;http://localhost:5003"
     },
     "MaintainIndexesJob": {
@@ -122,7 +122,7 @@
       "environmentVariables": {
         "EX_AppMode": "Development"
       },
-      "launchBrowser": true,
+      "launchBrowser": false,
       "applicationUrl": "https://localhost:5002;http://localhost:5003"
     },
     "StackEventCountJob": {
@@ -131,7 +131,7 @@
       "environmentVariables": {
         "EX_AppMode": "Development"
       },
-      "launchBrowser": true,
+      "launchBrowser": false,
       "applicationUrl": "https://localhost:5002;http://localhost:5003"
     },
     "EventSnapshotJob": {
@@ -140,8 +140,8 @@
       "environmentVariables": {
         "EX_AppMode": "Development"
       },
-      "launchBrowser": true,
+      "launchBrowser": false,
       "applicationUrl": "https://localhost:5002;http://localhost:5003"
     }
   }
-}
\ No newline at end of file
+}
diff --git a/src/Exceptionless.Job/appsettings.Development.yml b/src/Exceptionless.Job/appsettings.Development.yml
index 438c3a12c0..bdd125522a 100644
--- a/src/Exceptionless.Job/appsettings.Development.yml
+++ b/src/Exceptionless.Job/appsettings.Development.yml
@@ -5,7 +5,7 @@ ConnectionStrings:
 #  Cache: provider=redis;
 #  MessageBus: provider=redis;
 #  Queue: provider=redis;
-  Storage: provider=folder;path=..\Exceptionless.Web\storage
+#  Storage: provider=folder;path=..\Exceptionless.Web\storage
   Email: smtp://localhost:1025
 
 # Base url for the ui used to build links in emails and other places.
diff --git a/src/Exceptionless.Web/ApmExtensions.cs b/src/Exceptionless.Web/ApmExtensions.cs
index 3c96da994d..ef11a669f2 100644
--- a/src/Exceptionless.Web/ApmExtensions.cs
+++ b/src/Exceptionless.Web/ApmExtensions.cs
@@ -36,7 +36,7 @@ public static IHostBuilder AddApm(this IHostBuilder builder, ApmConfig config)
 
                 b.AddAspNetCoreInstrumentation(o =>
                 {
-                    o.Filter = context =>
+                    o.Filter = new Func<HttpContext, bool>(context =>
                     {
                         if (context.Request.Path.StartsWithSegments("/api/v2/push", StringComparison.OrdinalIgnoreCase))
                             return false;
@@ -48,7 +48,7 @@ public static IHostBuilder AddApm(this IHostBuilder builder, ApmConfig config)
                             return false;
 
                         return true;
-                    };
+                    });
                 });
 
                 b.AddElasticsearchClientInstrumentation(c =>
@@ -129,6 +129,7 @@ public static IHostBuilder AddApm(this IHostBuilder builder, ApmConfig config)
                 b.AddHttpClientInstrumentation();
                 b.AddAspNetCoreInstrumentation();
                 b.AddMeter("Exceptionless", "Foundatio");
+                b.AddMeter("System.Runtime");
                 b.AddRuntimeInstrumentation();
                 b.AddProcessInstrumentation();
 
diff --git a/src/Exceptionless.Web/ClientApp/vite.config.ts b/src/Exceptionless.Web/ClientApp/vite.config.ts
index f366b5d315..ade7bab6fb 100644
--- a/src/Exceptionless.Web/ClientApp/vite.config.ts
+++ b/src/Exceptionless.Web/ClientApp/vite.config.ts
@@ -17,7 +17,8 @@ export default defineConfig({
     ],
     server: {
         hmr: aspNetConfig.hmr,
-        port: 5173,
+        host: true,
+        port: parseInt(process.env.PORT ?? '5173'),
         proxy: {
             '/_framework': {
                 changeOrigin: true,
@@ -77,7 +78,7 @@ function getAspNetConfig() {
 
     // get current aspnetcore port / url
     const aspnetHttpsPort = process.env.ASPNETCORE_HTTPS_PORT;
-    const aspnetUrls = process.env.ASPNETCORE_URLS;
+    const aspnetUrls = process.env.ASPNETCORE_URLS ?? process.env.services__Api__0;
     const serverPort = 5173;
 
     const hmrRemoteHost = codespaceName ? `${codespaceName}-${serverPort}.${codespaceDomain}` : 'localhost';
diff --git a/src/Exceptionless.Web/Controllers/Base/ExceptionlessApiController.cs b/src/Exceptionless.Web/Controllers/Base/ExceptionlessApiController.cs
index 6292a71153..fb968c017a 100644
--- a/src/Exceptionless.Web/Controllers/Base/ExceptionlessApiController.cs
+++ b/src/Exceptionless.Web/Controllers/Base/ExceptionlessApiController.cs
@@ -94,6 +94,16 @@ protected int GetSkip(int currentPage, int limit)
         return skip;
     }
 
+    /// <summary>
+    /// This call will throw an exception if the user is a token auth type.
+    /// This is less than ideal, and we should refactor this to be a nullable user.
+    /// NOTE: The only endpoints that allow token auth types is
+    ///     - post event
+    ///     - post user event description
+    ///     - post session heartbeat
+    ///     - post session end
+    ///     - project config
+    /// </summary>
     protected virtual User CurrentUser => Request.GetUser();
 
     protected bool CanAccessOrganization(string organizationId)
@@ -101,7 +111,6 @@ protected bool CanAccessOrganization(string organizationId)
         return Request.CanAccessOrganization(organizationId);
     }
 
-
     protected bool IsInOrganization([NotNullWhen(true)] string? organizationId)
     {
         if (String.IsNullOrEmpty(organizationId))
diff --git a/src/Exceptionless.Web/Controllers/EventController.cs b/src/Exceptionless.Web/Controllers/EventController.cs
index 2f7b90c7c0..ca35362fb8 100644
--- a/src/Exceptionless.Web/Controllers/EventController.cs
+++ b/src/Exceptionless.Web/Controllers/EventController.cs
@@ -248,8 +248,8 @@ private async Task<ActionResult<CountResult>> CountInternalAsync(AppFilter sf, T
         }
         catch (Exception ex)
         {
-            using (_logger.BeginScope(new ExceptionlessState().Property("Search Filter", new { SystemFilter = sf, UserFilter = filter, Time = ti, Aggregations = aggregations }).Tag("Search").Identity(CurrentUser.EmailAddress).Property("User", CurrentUser).SetHttpContext(HttpContext)))
-                _logger.LogError(ex, "An error has occurred. Please check your filter or aggregations");
+            using var _ = _logger.BeginScope(new ExceptionlessState().Property("Search Filter", new { SystemFilter = sf, UserFilter = filter, Time = ti, Aggregations = aggregations }).Tag("Search").Identity(CurrentUser.EmailAddress).Property("User", CurrentUser).SetHttpContext(HttpContext));
+            _logger.LogError(ex, "An error has occurred. Please check your filter or aggregations: {Message}", ex.Message);
 
             throw;
         }
@@ -867,8 +867,8 @@ await Task.WhenAll(
         {
             if (projectId != _appOptions.InternalProjectId)
             {
-                using (_logger.BeginScope(new ExceptionlessState().Project(projectId).Identity(CurrentUser.EmailAddress).Property("User", CurrentUser).Property("Id", id).Property("Close", close).SetHttpContext(HttpContext)))
-                    _logger.LogError(ex, "Error enqueuing session heartbeat");
+                using var _ = _logger.BeginScope(new ExceptionlessState().Project(projectId).Property("Id", id).Property("Close", close).SetHttpContext(HttpContext));
+                _logger.LogError(ex, "Error enqueuing session heartbeat: {Message}", ex.Message);
             }
 
             throw;
@@ -1127,8 +1127,8 @@ await _eventPostService.EnqueueAsync(new EventPost(_appOptions.EnableArchive)
         {
             if (projectId != _appOptions.InternalProjectId)
             {
-                using (_logger.BeginScope(new ExceptionlessState().Project(projectId).Identity(CurrentUser.EmailAddress).Property("User", CurrentUser).SetHttpContext(HttpContext)))
-                    _logger.LogError(ex, "Error enqueuing event post");
+                using var _ = _logger.BeginScope(new ExceptionlessState().Project(projectId).SetHttpContext(HttpContext));
+                _logger.LogError(ex, "Error enqueuing event post: {Message}", ex.Message);
             }
 
             throw;
@@ -1328,8 +1328,8 @@ await _eventPostService.EnqueueAsync(new EventPost(_appOptions.EnableArchive)
         {
             if (projectId != _appOptions.InternalProjectId)
             {
-                using (_logger.BeginScope(new ExceptionlessState().Project(projectId).Identity(CurrentUser.EmailAddress).Property("User", CurrentUser).SetHttpContext(HttpContext)))
-                    _logger.LogError(ex, "Error enqueuing event post");
+                using var _ = _logger.BeginScope(new ExceptionlessState().Project(projectId).SetHttpContext(HttpContext));
+                _logger.LogError(ex, "Error enqueuing event post: {Message}", ex.Message);
             }
 
             throw;
diff --git a/src/Exceptionless.Web/Exceptionless.Web.csproj b/src/Exceptionless.Web/Exceptionless.Web.csproj
index dbd09aebad..7ca6504b29 100644
--- a/src/Exceptionless.Web/Exceptionless.Web.csproj
+++ b/src/Exceptionless.Web/Exceptionless.Web.csproj
@@ -1,12 +1,4 @@
 <Project Sdk="Microsoft.NET.Sdk.Web">
-  <PropertyGroup>
-    <SpaRoot>ClientApp\</SpaRoot>
-    <AngularSpaRoot>ClientApp.angular\</AngularSpaRoot>
-    <DefaultItemExcludes>$(DefaultItemExcludes);$(SpaRoot)node_modules\**;$(AngularSpaRoot)node_modules\**;</DefaultItemExcludes>
-    <SpaProxyServerUrl>http://localhost:5173/next</SpaProxyServerUrl>
-    <SpaProxyLaunchCommand>npm run dev</SpaProxyLaunchCommand>
-    <SkipSpaPublish>false</SkipSpaPublish>
-  </PropertyGroup>
 
   <ItemGroup>
     <Content Remove="Mail\**" />
@@ -24,8 +16,8 @@
     <PackageReference Include="MiniValidation" Version="0.9.1" />
     <PackageReference Include="NEST.JsonNetSerializer" Version="7.17.5" />
     <PackageReference Include="OAuth2" Version="0.10.3" />
-    <PackageReference Include="Swashbuckle.AspNetCore" Version="7.1.0" />
-    <PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
+    <PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
+    <PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
     <PackageReference Include="Serilog.Enrichers.Span" Version="3.1.0" />
     <PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
 
@@ -33,13 +25,13 @@
     <PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.10.0" />
     <PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.10.0-beta.1" />
     <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.10.0" />
-    <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.9.0" />
+    <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.10.1" />
     <PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.10.0" />
-    <PackageReference Include="OpenTelemetry.Instrumentation.StackExchangeRedis" Version="1.9.0-beta.1" />
+    <PackageReference Include="OpenTelemetry.Instrumentation.StackExchangeRedis" Version="1.10.0-beta.1" />
     <PackageReference Include="OpenTelemetry.Instrumentation.ElasticsearchClient" Version="1.0.0-beta.5" />
-    <PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.9.0" />
-    <PackageReference Include="OpenTelemetry.Instrumentation.Process" Version="0.5.0-beta.7" />
-    <PackageReference Include="Swashbuckle.AspNetCore.Newtonsoft" Version="7.1.0" />
+    <PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.10.0" />
+    <PackageReference Include="OpenTelemetry.Instrumentation.Process" Version="1.10.0-beta.1" />
+    <PackageReference Include="Swashbuckle.AspNetCore.Newtonsoft" Version="7.2.0" />
     <PackageReference Include="Unchase.Swashbuckle.AspNetCore.Extensions" Version="2.7.1" />
   </ItemGroup>
   <ItemGroup Label="Transitive dependency updates to resolve vulnerability warnings">
@@ -63,39 +55,4 @@
     <None Include="$(AngularSpaRoot)**" Exclude="$(AngularSpaRoot)node_modules\**;" />
   </ItemGroup>
 
-  <Target Name="DebugEnsureNodeEnv" BeforeTargets="Build" Condition=" '$(Configuration)' == 'Debug' And !Exists('$(SpaRoot)node_modules') ">
-    <!-- Ensure Node.js is installed -->
-    <Exec Command="node --version" ContinueOnError="true">
-      <Output TaskParameter="ExitCode" PropertyName="ErrorCode" />
-    </Exec>
-    <Error Condition="'$(ErrorCode)' != '0'" Text="Node.js is required to build and run this project. To continue, please install Node.js from https://nodejs.org/, and then restart your command prompt or IDE." />
-    <Message Importance="high" Text="Restoring dependencies using 'npm'. This may take several minutes..." />
-    <Exec WorkingDirectory="$(SpaRoot)" Command="npm ci" />
-  </Target>
-
-  <Target Name="PublishRunWebBuild" AfterTargets="ComputeFilesToPublish" Condition="'$(SkipSpaPublish)' != 'true'">
-    <!-- As part of publishing, ensure the JS resources are freshly built in production mode -->
-    <Exec WorkingDirectory="$(SpaRoot)" Command="npm ci" />
-    <Exec WorkingDirectory="$(SpaRoot)" Command="npm run build" />
-    <Exec WorkingDirectory="$(AngularSpaRoot)" Command="npm ci" />
-    <Exec WorkingDirectory="$(AngularSpaRoot)" Command="npm run build" />
-
-    <!-- Include the newly-built files in the publish output -->
-    <ItemGroup>
-      <DistFiles Include="$(SpaRoot)build\**" />
-      <ResolvedFileToPublish Include="@(DistFiles->'%(FullPath)')" Exclude="@(ResolvedFileToPublish)">
-        <RelativePath>wwwroot\next\%(RecursiveDir)%(FileName)%(Extension)</RelativePath>
-        <CopyToPublishDirectory>Always</CopyToPublishDirectory>
-        <ExcludeFromSingleFile>true</ExcludeFromSingleFile>
-      </ResolvedFileToPublish>
-    </ItemGroup>
-    <ItemGroup>
-      <DistFiles Include="$(AngularSpaRoot)dist\**" />
-      <ResolvedFileToPublish Include="@(DistFiles->'%(FullPath)')" Exclude="@(ResolvedFileToPublish)">
-        <RelativePath>wwwroot\%(RecursiveDir)%(FileName)%(Extension)</RelativePath>
-        <CopyToPublishDirectory>Always</CopyToPublishDirectory>
-        <ExcludeFromSingleFile>true</ExcludeFromSingleFile>
-      </ResolvedFileToPublish>
-    </ItemGroup>
-  </Target>
 </Project>
diff --git a/src/Exceptionless.Web/Program.cs b/src/Exceptionless.Web/Program.cs
index ef2fa06082..88bb1f0dad 100644
--- a/src/Exceptionless.Web/Program.cs
+++ b/src/Exceptionless.Web/Program.cs
@@ -1,5 +1,6 @@
 using System.Diagnostics;
 using Exceptionless.Core;
+using Exceptionless.Core.Configuration;
 using Exceptionless.Core.Extensions;
 using Exceptionless.Insulation.Configuration;
 using OpenTelemetry;
@@ -43,8 +44,7 @@ public static IHostBuilder CreateHostBuilder(string[] args)
             .SetBasePath(Directory.GetCurrentDirectory())
             .AddYamlFile("appsettings.yml", optional: true, reloadOnChange: true)
             .AddYamlFile($"appsettings.{environment}.yml", optional: true, reloadOnChange: true)
-            .AddEnvironmentVariables("EX_")
-            .AddEnvironmentVariables("ASPNETCORE_")
+            .AddCustomEnvironmentVariables()
             .AddCommandLine(args)
             .Build();
 
@@ -61,6 +61,9 @@ public static IHostBuilder CreateHostBuilder(IConfigurationRoot config, string e
             .ForContext<Program>();
 
         var options = AppOptions.ReadFromConfiguration(config);
+        // only poll the queue metrics if this process is going to host the jobs
+        options.QueueOptions.MetricsPollingEnabled = options.RunJobsInProcess;
+
         var apmConfig = new ApmConfig(config, "web", options.InformationalVersion, options.CacheOptions.Provider == "redis");
 
         Log.Information("Bootstrapping Exceptionless Web in {AppMode} mode ({InformationalVersion}) on {MachineName} with options {@Options}", environment, options.InformationalVersion, Environment.MachineName, options);
diff --git a/src/Exceptionless.Web/Properties/launchSettings.json b/src/Exceptionless.Web/Properties/launchSettings.json
index c660ee9846..a62987a549 100644
--- a/src/Exceptionless.Web/Properties/launchSettings.json
+++ b/src/Exceptionless.Web/Properties/launchSettings.json
@@ -2,19 +2,7 @@
     "profiles": {
         "Exceptionless": {
             "commandName": "Project",
-            "launchBrowser": true,
-            "launchUrl": "http://localhost:5200/next",
-            "environmentVariables": {
-                "EX_AppMode": "Development",
-                "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "Microsoft.AspNetCore.SpaProxy",
-                "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:8200"
-            },
-            "dotnetRunMessages": true,
-            "applicationUrl": "http://localhost:5200"
-        },
-        "Exceptionless API": {
-            "commandName": "Project",
-            "launchBrowser": true,
+            "launchBrowser": false,
             "environmentVariables": {
                 "EX_AppMode": "Development"
             },
diff --git a/src/Exceptionless.Web/Startup.cs b/src/Exceptionless.Web/Startup.cs
index a2bd162f63..c20655cd01 100644
--- a/src/Exceptionless.Web/Startup.cs
+++ b/src/Exceptionless.Web/Startup.cs
@@ -14,6 +14,7 @@
 using Foundatio.Extensions.Hosting.Startup;
 using Foundatio.Repositories.Exceptions;
 using Joonasw.AspNetCore.SecurityHeaders;
+using Joonasw.AspNetCore.SecurityHeaders.Csp;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Diagnostics.HealthChecks;
 using Microsoft.AspNetCore.Hosting.Server.Features;
@@ -299,18 +300,19 @@ ApplicationException applicationException when applicationException.Message.Cont
                 .To("https://api-iam.intercom.io/")
                 .To("wss://nexus-websocket-a.intercom.io");
 
-            csp.OnSendingHeader = context =>
+            csp.OnSendingHeader = new Func<CspSendingHeaderContext, Task>(context =>
             {
                 context.ShouldNotSend = context.HttpContext.Request.Path.StartsWithSegments("/api");
                 return Task.CompletedTask;
-            };
+            });
         });
 
         app.UseSerilogRequestLogging(o =>
         {
             o.EnrichDiagnosticContext = (context, httpContext) =>
             {
-                context.Set("ActivityId", Activity.Current?.Id);
+                if (Activity.Current?.Id is not null)
+                    context.Set("ActivityId", Activity.Current.Id);
             };
             o.MessageTemplate = "{ActivityId} HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms";
             o.GetLevel = (context, duration, ex) =>
diff --git a/src/Exceptionless.Web/appsettings.Development.yml b/src/Exceptionless.Web/appsettings.Development.yml
index a399826abe..2abf77f96e 100644
--- a/src/Exceptionless.Web/appsettings.Development.yml
+++ b/src/Exceptionless.Web/appsettings.Development.yml
@@ -5,7 +5,7 @@ ConnectionStrings:
 #  Cache: provider=redis;
 #  MessageBus: provider=redis;
 #  Queue: provider=redis;
-  Storage: provider=folder;path=.\storage
+#  Storage: provider=folder;path=.\storage
 #  LDAP: ''
 #  Email: smtp://localhost:1025
 
diff --git a/start-services.ps1 b/start-services.ps1
deleted file mode 100644
index 559514bd2b..0000000000
--- a/start-services.ps1
+++ /dev/null
@@ -1 +0,0 @@
-docker compose -f docker/docker-compose.yml up --detach elasticsearch kibana
\ No newline at end of file
diff --git a/stop-services.ps1 b/stop-services.ps1
deleted file mode 100644
index e407f79689..0000000000
--- a/stop-services.ps1
+++ /dev/null
@@ -1 +0,0 @@
-docker compose -f docker/docker-compose.yml down --remove-orphans
\ No newline at end of file
diff --git a/tests/Exceptionless.Tests/AppWebHostFactory.cs b/tests/Exceptionless.Tests/AppWebHostFactory.cs
index 78ce6a99e8..7b9abeb862 100644
--- a/tests/Exceptionless.Tests/AppWebHostFactory.cs
+++ b/tests/Exceptionless.Tests/AppWebHostFactory.cs
@@ -1,12 +1,36 @@
+using Aspire.Hosting;
+using Aspire.Hosting.ApplicationModel;
 using Exceptionless.Insulation.Configuration;
 using Exceptionless.Web;
 using Microsoft.AspNetCore.Mvc.Testing;
 using Microsoft.AspNetCore.TestHost;
+using Xunit;
 
 namespace Exceptionless.Tests;
 
-public class AppWebHostFactory : WebApplicationFactory<Startup>
+public class AppWebHostFactory : WebApplicationFactory<Startup>, IAsyncLifetime
 {
+    private DistributedApplication? _app;
+
+    public DistributedApplication App => _app ?? throw new InvalidOperationException("The application is not initialized");
+
+    public Task InitializeAsync()
+    {
+        var options = new DistributedApplicationOptions { AssemblyName = typeof(ElasticsearchResource).Assembly.FullName, DisableDashboard = true };
+        var builder = DistributedApplication.CreateBuilder(options);
+
+        // don't use random ports for tests
+        builder.Configuration["DcpPublisher:RandomizePorts"] = "false";
+
+        builder.AddElasticsearch("Elasticsearch", port: 9200)
+            .WithContainerName("Exceptionless-Elasticsearch-Test")
+            .WithLifetime(ContainerLifetime.Persistent);
+
+        _app = builder.Build();
+
+        return _app.StartAsync();
+    }
+
     protected override void ConfigureWebHost(IWebHostBuilder builder)
     {
         builder.UseSolutionRelativeContentRoot("src/Exceptionless.Web");
@@ -21,4 +45,12 @@ protected override IHostBuilder CreateHostBuilder()
 
         return Program.CreateHostBuilder(config, Environments.Development);
     }
+
+    async Task IAsyncLifetime.DisposeAsync()
+    {
+        if (_app is not null)
+        {
+            await _app.DisposeAsync();
+        }
+    }
 }
diff --git a/tests/Exceptionless.Tests/Exceptionless.Tests.csproj b/tests/Exceptionless.Tests/Exceptionless.Tests.csproj
index d6ebf316ba..e18e4f4f01 100644
--- a/tests/Exceptionless.Tests/Exceptionless.Tests.csproj
+++ b/tests/Exceptionless.Tests/Exceptionless.Tests.csproj
@@ -9,18 +9,20 @@
 
     <PackageReference Include="FluentRest.NewtonsoftJson" Version="10.0.0" />
 
+    <PackageReference Include="Aspire.Hosting.Testing" Version="9.0.0" />
     <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.0" />
     <PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="9.0.0" />
     <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
     <PackageReference Include="xunit" Version="2.9.2" />
     <PackageReference Include="GitHubActionsTestLogger" Version="2.4.1" PrivateAssets="All" />
-    <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" PrivateAssets="All" />
+    <PackageReference Include="xunit.runner.visualstudio" Version="3.0.0" PrivateAssets="All" />
     <PackageReference Include="coverlet.collector" Version="6.0.2">
         <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
         <PrivateAssets>all</PrivateAssets>
     </PackageReference>
   </ItemGroup>
   <ItemGroup>
+    <ProjectReference Include="..\..\src\Exceptionless.AppHost\Exceptionless.AppHost.csproj" />
     <ProjectReference Include="..\..\src\Exceptionless.Web\Exceptionless.Web.csproj" />
   </ItemGroup>
 
diff --git a/tests/Exceptionless.Tests/TestWithServices.cs b/tests/Exceptionless.Tests/TestWithServices.cs
index cc6e124af6..40011b3954 100644
--- a/tests/Exceptionless.Tests/TestWithServices.cs
+++ b/tests/Exceptionless.Tests/TestWithServices.cs
@@ -1,5 +1,6 @@
 using Exceptionless.Core;
 using Exceptionless.Core.Authentication;
+using Exceptionless.Core.Configuration;
 using Exceptionless.Core.Extensions;
 using Exceptionless.Core.Mail;
 using Exceptionless.Helpers;
@@ -17,11 +18,10 @@
 
 namespace Exceptionless.Tests;
 
-public class TestWithServices : TestWithLoggingBase, IAsyncLifetime
+public class TestWithServices : TestWithLoggingBase, IDisposable
 {
     private readonly IServiceProvider _container;
     private readonly ProxyTimeProvider _timeProvider;
-    private static bool _startupActionsRun;
 
     public TestWithServices(ITestOutputHelper output) : base(output)
     {
@@ -37,18 +37,6 @@ public TestWithServices(ITestOutputHelper output) : base(output)
         else
             throw new InvalidOperationException("TimeProvider must be of type ProxyTimeProvider");
     }
-
-    public virtual async Task InitializeAsync()
-    {
-        if (_startupActionsRun)
-            return;
-
-        var result = await _container.RunStartupActionsAsync();
-        if (!result.Success)
-            throw new ApplicationException($"Startup action \"{result.FailedActionName}\" failed");
-
-        _startupActionsRun = true;
-    }
     protected ProxyTimeProvider TimeProvider => _timeProvider;
 
     protected TService GetService<TService>() where TService : class
@@ -83,7 +71,7 @@ private IServiceProvider CreateContainer()
         var config = new ConfigurationBuilder()
             .SetBasePath(AppContext.BaseDirectory)
             .AddYamlFile("appsettings.yml", optional: false, reloadOnChange: false)
-            .AddEnvironmentVariables()
+            .AddCustomEnvironmentVariables()
             .Build();
 
         services.AddSingleton<IConfiguration>(config);
@@ -94,9 +82,8 @@ private IServiceProvider CreateContainer()
         return services.BuildServiceProvider();
     }
 
-    public Task DisposeAsync()
+    public void Dispose()
     {
         _timeProvider.Restore();
-        return Task.CompletedTask;
     }
 }