Skip to content

Set up end-to-end tracing

Granit.Observability configures Serilog (structured logs) and OpenTelemetry (traces and metrics) with OTLP export to the LGTM stack (Loki/Grafana/Tempo/Mimir). Combined with Granit.Wolverine’s trace context propagation, you get a single trace spanning HTTP requests, asynchronous message handlers, and database queries.

  • A .NET 10 project with Granit module system configured
  • An OpenTelemetry Collector (or Grafana Alloy) reachable via gRPC
  • Grafana with Tempo (traces) and Loki (logs) data sources configured
Terminal window
dotnet add package Granit.Observability

If you use Wolverine messaging, the Granit.Wolverine package provides trace context propagation automatically — no additional packages are needed.

Add the Observability section to appsettings.json:

{
"Observability": {
"ServiceName": "my-backend",
"ServiceVersion": "1.0.0",
"OtlpEndpoint": "http://otel-collector:4317",
"ServiceNamespace": "my-company",
"Environment": "production",
"EnableTracing": true,
"EnableMetrics": true
}
}
PropertyTypeDefaultDescription
ServiceNamestringunknown-serviceIdentifies the service in Tempo and Loki
ServiceVersionstring0.0.0Displayed in trace attributes
OtlpEndpointstringhttp://localhost:4317gRPC endpoint of the OpenTelemetry Collector
ServiceNamespacestringmy-companyGroups related services in dashboards
EnvironmentstringdevelopmentDeployment environment tag
EnableTracingbooltrueEnable/disable distributed tracing
EnableMetricsbooltrueEnable/disable metrics export

With the Granit module system, GranitObservabilityModule is loaded automatically via AddGranit<T>(). No additional code is needed.

This single call configures:

  • Serilog with console sink (development) and OpenTelemetry sink (OTLP to Loki)
  • OpenTelemetry tracing with ASP.NET Core, HttpClient, and EF Core instrumentation
  • OpenTelemetry metrics with ASP.NET Core and HttpClient instrumentation
  • Resource attributes: service.name, service.version, service.namespace, deployment.environment

Serilog reads additional configuration from appsettings.json:

{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
}
}
}
InstrumentationData collected
ASP.NET CoreIncoming HTTP requests (except /healthz)
HttpClientOutgoing HTTP requests
Entity Framework CoreSQL queries

Exceptions are recorded automatically (RecordException = true).

InstrumentationMetrics collected
ASP.NET CoreRequest duration, response codes
HttpClientOutgoing call duration

Correlating across Wolverine (async boundaries)

Section titled “Correlating across Wolverine (async boundaries)”

The main challenge in distributed tracing is maintaining trace correlation across asynchronous boundaries. When an HTTP request publishes a Wolverine message via the transactional outbox, the worker that processes that message seconds (or hours) later must appear under the same trace in Tempo.

Granit.Wolverine handles this automatically:

  1. Sender sideOutgoingContextMiddleware captures the current Activity.Current.Id (W3C traceparent) and injects it into the Wolverine envelope headers along with TenantId and UserId

  2. Receiver sideTraceContextBehavior reads the traceparent header, parses it with ActivityContext.TryParse(), and starts a bridge activity named wolverine.message.handle with the original trace as parent

The result: every span created during handler execution (EF Core queries, HTTP calls, custom spans) appears as a child of the original HTTP request trace.

[Incoming message]
-> TenantContextBehavior.Before() -- restores ICurrentTenant
-> UserContextBehavior.Before() -- restores ICurrentUserService
-> TraceContextBehavior.Before() -- restores trace-id (Activity bridge)
-> [Handler execution]
-> TraceContextBehavior.After() -- disposes the bridge activity
-> UserContextBehavior.After()
-> TenantContextBehavior.After()

HTTP endpoint — publishes an event:

internal static class OrderEndpoints
{
internal static async Task<Accepted> PlaceOrderAsync(
PlaceOrderRequest request,
IMessageBus bus,
ILogger<PlaceOrderRequest> logger,
CancellationToken cancellationToken)
{
logger.LogInformation(
"Placing order for {ProductId}, quantity {Quantity}",
request.ProductId, request.Quantity);
await bus.PublishAsync(new OrderPlacedEvent(
request.ProductId,
request.Quantity));
return TypedResults.Accepted(value: (string?)null);
}
}
public sealed record PlaceOrderRequest(Guid ProductId, int Quantity);
public sealed record OrderPlacedEvent(Guid ProductId, int Quantity);

Wolverine handler — processes the event with the same trace-id:

public static class OrderPlacedEventHandler
{
public static async Task HandleAsync(
OrderPlacedEvent evt,
AppDbContext db,
ILogger logger,
CancellationToken cancellationToken)
{
// This log shares the same TraceId as the HTTP request
logger.LogInformation(
"Processing order for {ProductId}, quantity {Quantity}",
evt.ProductId, evt.Quantity);
var order = new Order
{
ProductId = evt.ProductId,
Quantity = evt.Quantity,
Status = OrderStatus.Processing
};
db.Orders.Add(order);
await db.SaveChangesAsync(cancellationToken);
}
}

Each Granit module that performs significant I/O declares a dedicated ActivitySource. Follow this pattern for your own modules:

using System.Diagnostics;
internal static class InventoryActivitySource
{
internal const string Name = "MyApp.Inventory";
internal static readonly ActivitySource Source = new(Name);
internal const string CheckStock = "inventory.check-stock";
}
using Granit.Observability;
public static IServiceCollection AddInventory(this IServiceCollection services)
{
GranitActivitySourceRegistry.Register(InventoryActivitySource.Name);
// ... other registrations
return services;
}
public async Task<int> CheckStockAsync(
Guid productId, CancellationToken cancellationToken)
{
using var activity = InventoryActivitySource.Source.StartActivity(
InventoryActivitySource.CheckStock);
activity?.SetTag("inventory.product_id", productId.ToString());
var stock = await _dbContext.Stock
.Where(s => s.ProductId == productId)
.Select(s => s.Quantity)
.FirstOrDefaultAsync(cancellationToken);
activity?.SetTag("inventory.stock_level", stock);
return stock;
}
SourceModuleSpans
Granit.WolverineGranit.Wolverinewolverine.message.handle
Granit.WebhooksGranit.Webhookswebhooks.deliver, webhooks.fanout
Granit.NotificationsGranit.Notificationsnotifications.deliver, notifications.fanout
Granit.BackgroundJobsGranit.BackgroundJobsbackgroundjobs.trigger
Granit.BlobStorage.S3Granit.BlobStorage.S3blobstorage.upload-ticket, blobstorage.download-url, blobstorage.delete
Granit.Identity.KeycloakGranit.Identity.Keycloakidentity.keycloak.*

Search by trace ID to see the complete waterfall:

POST /api/orders (12ms)
|-- PublishAsync: OrderPlacedEvent (2ms)
+-- [Wolverine] OrderPlacedEventHandler (45ms)
|-- EF Core: INSERT INTO orders (8ms)
+-- Commit transaction (3ms)

Filter logs by TraceId to see all log entries from a single operation:

{service_name="my-backend"} | json | TraceId = "abc-123"

Every Serilog log entry includes TraceId and SpanId. In Grafana, clicking a log entry opens the corresponding trace in Tempo.

The typical production pipeline:

Application -> OTLP gRPC -> OpenTelemetry Collector -> Tempo (traces)
-> Mimir (metrics)
-> Loki (logs)
SituationBehavior
No traceparent header in Wolverine envelopeNo-op — handler runs normally
Malformed traceparent headerWarning log + no-op (no exception)
Granit.Wolverine source not listenedStartActivity() returns null — silent no-op
No active Activity at publish timetraceparent header is absent