Debugging¶
This page covers how to debug the Cargonerds backend and frontends locally. There are three common
setups — the full Aspire graph, a single .NET host pointed at a deployed environment's
neighbors, and the frontends (Blazor WebAssembly and the Next.js realtime app) — followed by
how to read logs, traces and metrics in the Aspire dashboard, a set of common issues, and the
SQL query-performance notes (SET ARITHABORT ON, OPTION (RECOMPILE)) that are specific to this
codebase.
If you have not run the stack before, start with Running locally and the Aspire integration deep-dive; this page assumes the AppHost and containers already come up.
How debugging fits together¶
The local stack is orchestrated by .NET Aspire. The Cargonerds.AppHost project
(src/Cargonerds.AppHost/Program.cs) is a distributed-application host that starts every backend
service, every infrastructure container (SQL Server, Redis, RabbitMQ), the frontends and a docs
container, and exposes a single dashboard where their logs/traces/metrics are aggregated over
OpenTelemetry.
flowchart TD
IDE["IDE debugger\n(VS / Rider / VS Code)"]
AppHost["Cargonerds.AppHost\n(Aspire orchestrator)"]
Dash["Aspire dashboard\nhttps://localhost:17176"]
subgraph svc["Child processes (own OS processes)"]
Auth["Cargonerds.AuthServer\n:44345"]
Api["Cargonerds.HttpApi.Host\n:44354"]
Admin["Cargonerds.Blazor (admin)\n:44381"]
Mig["Cargonerds.DbMigrator"]
FE["frontend/realtime (Next.js)\n:4200"]
end
subgraph infra["Containers"]
Sql[("spark-db\nSQL Server")]
Redis[("spark-redis")]
Rabbit[("spark-rabbitmq")]
end
IDE -- F5 launches --> AppHost
AppHost -- starts --> svc
AppHost -- starts --> infra
AppHost -- OTLP --> Dash
svc -- logs / traces / metrics (OTLP) --> Dash
IDE -. attach to .-> svc
The AppHost starts child processes
Each service runs as its own OS process that Aspire launches. The debugger you attach to the AppHost process does not automatically step into those children — you attach to the child you care about, or debug a single host standalone (see below). Visual Studio and Rider can be configured to attach to the spawned children automatically; otherwise attach by hand.
Debug the full stack via Aspire¶
Run or debug the Cargonerds.AppHost project:
Aspire launches every service and infrastructure container and opens the Aspire dashboard. The
AppHost's own endpoints come from src/Cargonerds.AppHost/Properties/launchSettings.json:
| Endpoint | URL (https profile) |
|---|---|
| Dashboard / app URL | https://localhost:17176 (http: http://localhost:15198) |
Dashboard OTLP ingest (DOTNET_DASHBOARD_OTLP_ENDPOINT_URL) |
https://localhost:21236 |
Resource service (DOTNET_RESOURCE_SERVICE_ENDPOINT_URL) |
https://localhost:22044 |
Docker must be running first
The last line of Program.cs is
builder.Build().EnsureDockerRunningIfLocalDebug().Run();. EnsureDockerRunningIfLocalDebug() is
a Nextended.Aspire helper that fails fast with a
clear message if Docker Desktop is not running during local debug. If the AppHost exits
immediately complaining about Docker, start Docker Desktop and retry.
Once it is up, the dashboard lists each resource with its live URL, health status, structured logs, distributed traces and metrics — see The Aspire dashboard below.
Attaching to a single child service¶
When you only need to step through one service (e.g. the API) but still want the rest of the graph running:
- Start the AppHost (F5 or
dotnet run). - Attach your debugger to the child process — in Visual Studio/Rider use Attach to Process and
pick the executable (e.g.
Cargonerds.HttpApi.Host), or in the Aspire dashboard open the resource's details to find its PID. - Set breakpoints in that project as normal. The child already has the right configuration because
the AppHost set
USE_ASPIRE_CONFIG=trueon it (see the gotcha below).
This keeps real service discovery, Redis, RabbitMQ and the database wired exactly as in a normal run, while you only pay the debugger cost on one process.
Debug a single .NET host standalone¶
To step through one service while it talks to a deployed environment's neighbors (no local
Aspire graph, no containers), set AZURE_ENVIRONMENT and start just that host. The Azure config
layer then supplies both the backend connections and the Services block, so the service-discovery
tokens ({api}, {auth}, {realtime}, {web}, {admin}) resolve to the public URLs.
# e.g. debug the API against the dev environment's auth/realtime/etc.
AZURE_ENVIRONMENT=dev ASPNETCORE_ENVIRONMENT=Development \
dotnet run --project src/Cargonerds.HttpApi.Host
How the config resolves in this mode is implemented in
src/Cargonerds.ServiceDefaults/HostApplicationBuilderExtensions.cs (AddExtraConfigFiles →
AddAzureJsonFiles). See the Configuration Reference for the
full recipe and the important caveats.
Never debug against prod
AZURE_ENVIRONMENT=prod (or any prod.* value) resolves to production connection strings —
those credentials are real and committed in src/Cargonerds.ServiceDefaults/appsettings.azure.*.json.
Use dev/test. If you must run local code against a real Azure DB at all, use the AppHost's
dedicated "spark DB" mode (APPHOST_AZURE_ENVIRONMENT + APPHOST_AZURE_DATABASE_COPY_SUFFIX),
which forces a renamed copy for prod.* so you cannot mutate the live database — see
Aspire integration and
Running locally.
Fixed run-mode HTTPS ports (from each host's launchSettings.json and
src/Cargonerds.AppHost/RunModeServicePorts.cs):
| Host | URL | Constant |
|---|---|---|
Cargonerds.AuthServer |
https://localhost:44345 |
RunModeServicePorts.AuthServerHttpsPort |
Cargonerds.HttpApi.Host |
https://localhost:44354 |
RunModeServicePorts.ApiHttpsPort |
Cargonerds.Blazor (admin) |
https://localhost:44381 |
RunModeServicePorts.AdminHttpsPort |
Cargonerds.Web.Public |
https://localhost:44332 |
— |
frontend/realtime (Next.js, http) |
http://localhost:4200 |
RunModeServicePorts.RealtimeHttpPort |
| SQL container (un-proxied TCP) | localhost:14330 (DbPort = 53938 for the proxied endpoint) |
RunModeServicePorts.DbPort |
USE_ASPIRE_CONFIG is mandatory under the AppHost — and must be set for standalone Aspire-style runs
The AppHost sets USE_ASPIRE_CONFIG=true on each child via AddProjectWithDefaults
(src/Cargonerds.AppHost/Extensions/ResourceBuilderExtensions.cs). Per
EnvVars.UseAspireConfig: "If not set the default ABP configuration will be used which is not
compatible with the Aspire AppHost." When you launch a host directly (IIS Express / plain
dotnet run) it stays false and the host falls back to the static appsettings.json — which is
fine for the standalone-against-Azure recipe above, but means you are not using the Aspire
appsettings.aspire.json token wiring.
Per-developer overrides (appsettings.spark.local.user.json)¶
When debugging against SPARK_ENVIRONMENT=local, the per-developer override file
appsettings.spark.<env>.user.json (e.g. appsettings.spark.local.user.json) is layered on top with
reloadOnChange: true (loaded in AddExtraConfigFiles). It is git-ignored and is the right place to
point a host at your own local Hub DB / ports without touching committed config. See the
Configuration Reference and
appsettings reference.
Debugging Blazor WebAssembly¶
The Blazor admin (Cargonerds.Blazor) launch profiles set inspectUri for WASM debugging in
src/Cargonerds.Blazor/Properties/launchSettings.json (both the IIS Express and Cargonerds.Blazor
profiles):
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}"
Launch the Cargonerds.Blazor profile (or attach to it when running under the AppHost) and debug
.razor / client code in your IDE or the browser dev tools. The admin server listens on
https://localhost:44381. See Blazor admin UI.
Debugging the Next.js frontend (realtime)¶
Run it standalone with source maps:
Use the browser dev tools / React DevTools. TanStack Query devtools are wired in
frontend/realtime/src/app/providers.tsx. Point the app at a running API/auth via the env vars in
frontend/realtime/src/types/envVars.ts. Under the AppHost these env vars are injected for you by
WithNextAuth (src/Cargonerds.AppHost/Extensions/NodeAppExtensions.cs); standalone you supply them
yourself. See Realtime Frontend.
The Aspire dashboard¶
The dashboard (default https://localhost:17176) is the central place to observe the running graph.
Telemetry reaches it over OpenTelemetry (OTLP); the dashboard's OTLP ingest endpoint is published
to the children via DOTNET_DASHBOARD_OTLP_ENDPOINT_URL.
| Dashboard tab | What you get | Where it comes from |
|---|---|---|
| Resources | Every service/container with state, endpoints, env vars, and per-resource commands. | The resource graph in Program.cs. |
| Console logs | Raw stdout/stderr per resource. | The child process console. |
| Structured logs | Searchable structured log records with scopes/levels. | Serilog → WriteTo.OpenTelemetry() (see below) plus OTel logging. |
| Traces | Distributed traces across HTTP calls between services. | ASP.NET Core + HttpClient instrumentation. |
| Metrics | Runtime / ASP.NET Core / HttpClient meters. | AddRuntimeInstrumentation() + ASP.NET Core/HttpClient meters. |
How telemetry is produced¶
AddServiceDefaults() (called first in every host's Program.cs, before the ABP module bootstrap)
configures OpenTelemetry in src/Cargonerds.ServiceDefaults/HostApplicationBuilderExtensions.cs:
public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder)
where TBuilder : IHostApplicationBuilder
{
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.IncludeScopes = true;
});
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics.AddAspNetCoreInstrumentation().AddHttpClientInstrumentation().AddRuntimeInstrumentation();
})
.WithTracing(tracing =>
{
tracing
.AddSource(builder.Environment.ApplicationName)
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation();
});
builder.AddOpenTelemetryExporters();
return builder;
}
The exporter is chosen by environment variable (AddOpenTelemetryExporters):
// OTLP (the Aspire dashboard) — enabled when the endpoint is present
if (!string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]))
builder.Services.AddOpenTelemetry().UseOtlpExporter();
// Azure Monitor / Application Insights — enabled when the connection string is present
var connectionString = builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"];
if (!string.IsNullOrEmpty(connectionString))
builder.Services.AddOpenTelemetry().UseAzureMonitor(o => o.ConnectionString = connectionString);
Both exporters can be active
OTEL_EXPORTER_OTLP_ENDPOINT and APPLICATIONINSIGHTS_CONNECTION_STRING are independent — locally
Aspire sets the OTLP endpoint so logs/traces flow to the dashboard; in Azure the connection string
additionally ships telemetry to Application Insights.
Serilog → dashboard¶
ABP hosts log through Serilog. The bridge that makes Serilog records show up under the dashboard's
Structured logs is the OpenTelemetry sink. From src/Cargonerds.HttpApi.Host/Program.cs:
loggerConfiguration
.MinimumLevel.Override("Microsoft", LogEventLevel.Information)
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Warning)
.Enrich.FromLogContext()
.WriteTo.Async(c => c.Console())
.WriteTo.Async(c => c.OpenTelemetry()); // <-- exported to the Aspire dashboard / OTLP
EF Core SQL is filtered by default
Microsoft.EntityFrameworkCore is overridden to Warning, so generated SQL is not logged at
the default level. To see the SQL EF emits while debugging, lower that override (e.g. to
Information) or use the Hub CommandLoggingInterceptor path — see
Entity Framework. Note #if DEBUG already sets the global
minimum to Debug in Debug builds.
Speeding up startup while debugging¶
The AppHost honors two env vars (defined in src/Cargonerds.AppHost/EnvVars.cs). Both are checked for
the literal value "1" — any other value is treated as off:
| Variable | Effect | Accessor |
|---|---|---|
SKIP_WAITING_IN_BACKEND=1 |
Skip inter-service WaitFor / health / migrator-completion gating, so backends start without waiting on each other. |
EnvVars.SkipWaitingInBackend |
EXPLICIT_FRONTEND_START=1 |
Don't auto-start the frontends; start them manually from the dashboard. | EnvVars.ExplicitFrontendStart |
These feed the WaitForIf / WaitForCompletionIf / WithExplicitStartIf calls in Program.cs.
The migrator-related VALIDATE_MIGRATIONS_ONLY toggle (validate, don't apply) is documented under
Migrations and the
Aspire integration page.
Common issues¶
| Symptom | Likely cause / fix |
|---|---|
| AppHost exits immediately with a Docker message | Docker Desktop isn't running; EnsureDockerRunningIfLocalDebug() aborts early. Start Docker and retry. |
Service throws InvalidOperationException listing "Available: …" services at startup |
An appsettings.aspire.json value contains a {name} service-discovery token with no matching services:* endpoint / ConnectionStrings:* entry. Wire the resource in the AppHost, or remove the token. Resolution is fail-fast in ServiceDiscoveryConfigurationProvider. |
Standalone host uses the wrong (static) config / can't find {auth} etc. |
USE_ASPIRE_CONFIG not set. Under the AppHost it's set automatically; for standalone Aspire-style runs set USE_ASPIRE_CONFIG=true, or use the AZURE_ENVIRONMENT=dev recipe instead. |
| Port already in use (44345/44354/44381/4200) or SQL conflict on 14330 | The run-mode ports are fixed (RunModeServicePorts.cs) and the SQL container's TCP endpoint is un-proxied (14330). A stray local SQL Server / leftover host on that port collides — stop it. |
Startup crashes on a SPARK_ENVIRONMENT typo |
SparkEnvironment.FromName returns Dev for null/empty but throws on an unknown name. Fix the value (local/dev/test/prod/prod-read-only). |
WHITE_LABEL_SETTINGS_PATH / white-label file not found when running a host directly |
Aspire runs each process with its project directory as cwd (not the build output), so the AppHost passes the absolute path to appsettings.rohlig.json via WithWhiteLabelSettingsPath. Standalone, ensure the file is reachable from the working directory. |
| Permission/DDL errors only during migration | The DbMigrator uses admin DB credentials (appsettings.azure.migrator.<env>.json), distinct from the app's runtime credentials. See Migrations. |
OData groupby/aggregate ($apply) widget request returns NullReferenceException |
The live TagWith(recompile) + $apply interaction — see Query performance below and OData filtering. |
Query performance (ARITHABORT and OPTION (RECOMPILE))¶
A recurring class of "the app is slower than SSMS/Rider for the same query" issues comes down to SQL
Server plan-cache behaviour. Two EF Core interceptors in
modules/hub/src/Hub.EntityFrameworkCore/EntityFrameworkCore/ address it. Both are registered in
HubEntityFrameworkCoreModule for the pooled HubDbContext:
opts.AddInterceptors(
sp.GetRequiredService<SecondLevelCacheInterceptor>(),
sp.GetRequiredService<CommandLoggingInterceptor>(),
sp.GetRequiredService<FacetJoinInterceptor>(),
sp.GetRequiredService<ArithAbortConnectionInterceptor>(),
sp.GetRequiredService<RecompileCommandInterceptor>()
);
(The Default CargonerdsDbContext also gets ArithAbortConnectionInterceptor — added in
CargonerdsEntityFrameworkCoreModule, configured in the same options.Configure(...) as the SQL
Server provider; splitting that into a second Configure overwrites the provider and yields "No
database provider configured". See Entity Framework and
Layered architecture.)
ArithAbortConnectionInterceptor — same plans as your SQL tool¶
.NET SqlClient connects with ARITHABORT OFF while SSMS/Rider use ON. Differing SET options land
in separate plan-cache slots, so the app can get its own — often worse — execution plan than the
one you see when you run the identical query in your SQL tool. The interceptor re-applies the setting
on every connection open (not once), because connection pooling resets SET options via
sp_reset_connection:
// modules/hub/src/Hub.EntityFrameworkCore/EntityFrameworkCore/ArithAbortConnectionInterceptor.cs
private const string SetCommand = "SET ARITHABORT ON;";
public override void ConnectionOpened(DbConnection connection, ConnectionEndEventData eventData)
{
using var cmd = connection.CreateCommand();
cmd.CommandText = SetCommand;
cmd.ExecuteNonQuery();
}
// (ConnectionOpenedAsync does the same for async opens)
Reproducing an app plan in SSMS/Rider
Because the app now runs with ARITHABORT ON, your SQL tool's default ON matches it. If you are
still chasing a discrepancy, confirm the other SET options and parameter values match — EF's
parameterization (and .NET 10/EF 10 collection-parameter translation,
UseParameterizedCollectionMode) affects the cached plan too.
RecompileCommandInterceptor — opt-in OPTION (RECOMPILE)¶
For queries whose optimal plan depends heavily on the specific parameter values (parameter sniffing),
you can force a fresh plan per execution. The interceptor appends OPTION (RECOMPILE) only when
the command text contains the literal -- recompile marker, which EF adds via
TagWith(RecompileCommandInterceptor.Tag):
// modules/hub/src/Hub.EntityFrameworkCore/EntityFrameworkCore/RecompileCommandInterceptor.cs
public const string Tag = "recompile";
private const string TagMarker = "-- " + Tag; // EF renders TagWith("recompile") as "-- recompile"
private const string Hint = "\nOPTION (RECOMPILE)";
private static void ApplyHint(DbCommand command)
{
var text = command.CommandText;
if (text.Contains(TagMarker, StringComparison.Ordinal)
&& !text.Contains("OPTION (RECOMPILE)", StringComparison.Ordinal))
{
command.CommandText = text + Hint;
}
}
Use it from a query like:
Trade-off: recompile costs CPU
OPTION (RECOMPILE) recompiles the statement on every execution. It avoids a bad cached plan
but spends optimizer CPU each time and removes the query from the plan cache — apply it to
specific problem queries, not broadly.
Live TagWith(recompile) + OData $apply causes a NullReferenceException
ODataControllerBase.Get (modules/hub/src/Hub.HttpApi/Controllers/ODataControllerBase.cs) calls
res = res.TagWith(RecompileCommandInterceptor.Tag); unconditionally and returns the query to
[EnableQuery]. When the request is an OData $apply aggregation (groupby / aggregate),
OData rewrites the query into GroupBy(...).Select(new GroupByWrapper/AggregationWrapper{...}),
and the tag on that shape triggers a NullReferenceException — this hit POC-report
groupby/aggregate widgets. The known fix is to apply the tag only when $apply is absent:
if (string.IsNullOrEmpty(Request.Query["$apply"]))
{
res = res.TagWith(RecompileCommandInterceptor.Tag);
}
See OData, Filtering & Facets for the full controller pipeline, the
cn.facets annotation and this gotcha.
Other Hub interceptors worth knowing while debugging¶
SecondLevelCacheInterceptor(EFCoreSecondLevelCache) — caches all queries for a 1-minute absolute TTL (CacheAllQueries(CacheExpirationMode.Absolute, TimeSpan.FromMinutes(1))). A query that "doesn't reflect a write you just made" within a minute is usually this cache, not a bug.FacetJoinInterceptor— text-replacesLEFT JOIN→INNER JOINfor commands tagged with the facet prefix.CommandLoggingInterceptor— scoped command logging (CommandLoggingScope<HubDbContext>).
All four are described in detail on Entity Framework and Caching.
Related pages¶
- Running locally — bring the stack up, ports, containers, SPARK selection, infrastructure-only mode.
- Aspire integration — the AppHost resource graph, ServiceDefaults, OpenTelemetry, health gates and env-var toggles in depth.
- Configuration Reference and
appsettings reference — the config layering,
USE_ASPIRE_CONFIG,AZURE_ENVIRONMENT/SPARK_ENVIRONMENT, token replacement, and the standalone-debug recipe. - Entity Framework and Caching — the DbContext interceptor stack and second-level cache.
- OData, Filtering & Facets — the
TagWith(recompile)+$applygotcha. - Architecture overview — where the AppHost and the two DbContexts sit.