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 ActivitySource named Benevia.Core with 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.id and user.id after authentication
    • workflow.id and workflow.index when those headers are present
  • An ActivitySource named Benevia.Core.Blazor with a ui.load span 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.