Observability — Logging and Tracing
Benevia Core uses standard .NET telemetry: Microsoft.Extensions.Logging for logs and System.Diagnostics.ActivitySource (OpenTelemetry-compatible) for traces. There is no custom abstraction.
What Core emits
An
ActivitySourcenamedBenevia.Corewith spans for entity events (Compute,Validate,Changed,PreSave,Added,Deleted), OData operations, and workflow steps.Standard tags on the active ASP.NET Core request span:
tenant.idanduser.idafter authenticationworkflow.idandworkflow.indexwhen those headers are present
An
ActivitySourcenamedBenevia.Core.Blazorwith aui.loadspan per user-visible load. See UI load spans.
Startup logs
Core writes a short, structured summary for each tenant at startup, at Information. A host with two tenants and nothing to do logs:
Initializing databases for 2 tenant(s): Demo, DemoFull.
Tenant Demo: database schema is up to date (localhost:5432/benevia_demo).
Tenant DemoFull: database schema is up to date (localhost:5432/benevia_demo_full).
Tenant Demo: no new data generators to run.
Tenant DemoFull: no new data generators to run.
| Category | Message template | When |
|---|---|---|
Benevia.Core.Postgres.CorePostgresExtensions |
Initializing databases for {TenantCount} tenant(s): {TenantIds}. |
Once, when UseCorePostgres starts. A Warning replaces it when no tenants are configured. |
Benevia.Core.Postgres.DatabaseManager |
Tenant {TenantId}: database schema is up to date ({Database}). |
Once per tenant. Other outcomes: created database from the current model, created schema in empty database, applied {OperationCount} database upgrade operation(s). |
Benevia.Core.Postgres.DatabaseUpgrade.DataUpgradeBLManager |
Applying upgrade operation {Index}/{Count} to {Database}: followed by the SQL |
Once per applied operation, only when the schema changed. One operation can run several SQL statements. |
Benevia.Core.DataGenerator.DataGeneratorExecutor |
Tenant {TenantId}: ran {GeneratorCount} new data generator(s) ({BlankDataCount} blank data, {DemoDataCount} demo data) in {ElapsedSeconds:F1}s. |
Once per tenant. no new data generators to run. when nothing ran. The breakdown is omitted for tenants without demo data. A Warning with {FailedCount} replaces it when a generator failed outside Development. |
Tenants initialize in parallel, so lines from different tenants can interleave. Every line carries TenantId or Database as a structured property, so you can filter by tenant in your log backend.
The SQL of each applied upgrade operation is logged at Information in the Sql property. If you do not want schema changes in your log sink, set the category Benevia.Core.Postgres.DatabaseUpgrade.DataUpgradeBLManager to Warning in your Logging configuration.
Per-feature timings of the data generator are logged at Debug.
Wiring up OpenTelemetry
Core does not configure an exporter — that is the host application's choice. A typical setup adds the Benevia.Core source, plus Npgsql for database spans:
builder.Services
.AddOpenTelemetry()
.WithTracing(t => t
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddSource("Benevia.Core")
.AddSource("Npgsql")
.AddOtlpExporter())
.WithLogging();
Send to whichever backend you prefer (Jaeger, Sentry via the OpenTelemetry bridge, Application Insights, etc.).
Add "Benevia.Core.Blazor" as a source in the client host to collect the UI load spans below.
UI load spans
A grid or a data graph page emits one root ui.load span per load: the work between that load starting and the content it produces reaching the screen. The requests it makes are children, so a slow first paint can be read down to the call that caused it.
| Tag | Meaning |
|---|---|
benevia.ui.type |
The surface being loaded, such as list or datagraph. |
benevia.ui.target |
What is being loaded: a view key, an entity name, or a route. |
benevia.ui.container |
Where it is loaded, page or drawer. |
benevia.entity.name |
The entity behind the surface, when there is one. |
benevia.ui.load.trigger |
What asked for the load: initial, refresh, or search. |
benevia.ui.load.outcome |
How it ended: ok, superseded, canceled, error, or abandoned. |
A load ends as soon as its outcome is known, so a load whose result is thrown away closes its own span instead of staying open and timing whatever the user does next.
Filter latency dashboards to benevia.ui.load.outcome = ok. Only that outcome measures content reaching the screen. superseded and canceled are loads the user replaced, so they are shorter than a real load; abandoned means the surface never reported an outcome and the span was closed at its cap, so it is longer than a real load. A rising rate of abandoned is a bug in the surface reporting, not a slow page.
Logging from business logic
Inject ILogger<T> like any other service. Use structured logging so fields stay searchable.
using Microsoft.Extensions.Logging;
[Logic]
public class SalesOrderBL(ILogger<SalesOrderBL> logger)
{
public void Submit(SalesOrder.Logic salesOrder)
{
salesOrder.OnSubmit(order =>
{
logger.LogInformation("Submitting order {OrderId} for {CustomerId}",
order.Id, order.CustomerId);
// ...
});
}
}
Tagging the current request span
To enrich the active span without creating a new one, set tags on Activity.Current:
using System.Diagnostics;
Activity.Current?.SetTag("checkout.path", "express");
These tags appear on the request span in your tracing backend and are searchable like any other attribute.