Skip to main content
ImmutableLog logo
Back
ASP.NET CoreC#

ASP.NET Core Integration

Integrate ImmutableLog into any ASP.NET Core application with an `IMiddleware` that automatically captures all HTTP requests. Sending is fire-and-forget — it never blocks the response.

Middleware code

Save it in `ImmutableLogMiddleware.cs`. The middleware implements `IMiddleware` (resolved via DI), captures `Response.StatusCode` after `next`, and sends the event in a discarded Task.

csharp
// ImmutableLogMiddleware.cs
using System.Diagnostics;
using System.Net.Http.Json;
using System.Text.Json;

public sealed class ImmutableLogOptions
{
    public string ApiKey { get; init; } = "";
    public string ApiUrl { get; init; } = "https://api.immutablelog.com";
    public string Service { get; init; } = "aspnet-service";
    public string Env { get; init; } = "production";
    // Timezone do cliente — usado pelo dashboard para exibir os timestamps.
    public string ClientTz { get; init; } = "America/Sao_Paulo";
    public int ClientOffsetMinutes { get; init; } = -180;
    public HashSet<string> SkipPaths { get; init; } = new() { "/health", "/healthz" };
}

public sealed class ImmutableLogMiddleware : IMiddleware
{
    // Chaves cujos valores sao mascarados antes do envio (PII/segredos).
    private static readonly HashSet<string> Sensitive = new(StringComparer.OrdinalIgnoreCase)
    {
        "password", "pwd", "senha", "token", "access_token", "refresh_token", "api_key",
        "apikey", "secret", "client_secret", "authorization", "auth", "credit_card",
        "card_number", "cvv", "cpf", "otp", "code",
    };

    private readonly HttpClient _http;
    private readonly ImmutableLogOptions _opts;

    public ImmutableLogMiddleware(IHttpClientFactory factory, ImmutableLogOptions opts)
    {
        _http = factory.CreateClient("immutablelog");
        _opts = opts;
    }

    public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
    {
        if (_opts.SkipPaths.Contains(ctx.Request.Path))
        {
            await next(ctx);
            return;
        }

        var sw = Stopwatch.StartNew();
        var requestId = ctx.Request.Headers["X-Request-Id"].FirstOrDefault()
            ?? Guid.NewGuid().ToString();

        await next(ctx);
        sw.Stop();

        // fire-and-forget — nunca bloqueia a resposta.
        _ = EmitAsync(ctx, ctx.Response.StatusCode, sw.Elapsed, requestId);
    }

    private async Task EmitAsync(HttpContext ctx, int status, TimeSpan elapsed, string requestId)
    {
        try
        {
            if (string.IsNullOrEmpty(_opts.ApiKey)) return;

            var kind = status >= 400 ? "error"
                : status >= 300 ? "info"
                : status >= 200 ? "success" : "info";
            var method = ctx.Request.Method;
            var path = ctx.Request.Path.Value ?? "/";

            var eventName = ctx.Items["imtbl.eventName"] as string ?? $"http.{method}.{path}";
            var trail = SanitizeTrail(
                ctx.Items["imtbl.trail"] as string
                ?? ctx.Request.Headers["X-Imtbl-Trail"].FirstOrDefault());

            // query params passam por redact() — nunca enviar segredos em claro.
            var query = ctx.Request.Query.ToDictionary(
                kv => kv.Key,
                kv => Sensitive.Contains(kv.Key) ? "***REDACTED***" : kv.Value.ToString());

            var payload = new Dictionary<string, object?>
            {
                ["id"] = Guid.NewGuid().ToString(),
                ["kind"] = kind,
                ["message"] = $"{method} {path} -> {status}",
                ["timestamp"] = DateTimeOffset.UtcNow.ToString("o"),
                ["request"] = new { request_id = requestId, method, path,
                    query_params = query.Count > 0 ? query : null },
                ["metrics"] = new { latency_ms = (long)elapsed.TotalMilliseconds, status_code = status },
                ["severity"] = kind == "error" ? "high" : "low",
            };
            if (kind == "error")
            {
                payload["error"] = new
                {
                    status_code = status,
                    retryable = new[] { 408, 429, 500, 502, 503, 504 }.Contains(status),
                };
            }

            // Todos os valores de meta precisam ser strings.
            var meta = new Dictionary<string, string>
            {
                ["type"] = kind,
                ["event_name"] = eventName,
                ["service"] = _opts.Service,
                ["request_id"] = requestId,
                ["env"] = _opts.Env,
            };
            if (trail is not null) meta["immutable_trail"] = trail;

            var body = new { payload = JsonSerializer.Serialize(payload), meta };

            using var req = new HttpRequestMessage(HttpMethod.Post, $"{_opts.ApiUrl}/v1/events")
            {
                Content = JsonContent.Create(body),
            };
            req.Headers.Add("Authorization", $"Bearer {_opts.ApiKey}");
            // Idempotency-Key e OBRIGATORIO (sem ele a API responde 400).
            req.Headers.Add("Idempotency-Key", $"{eventName}-{requestId}");
            req.Headers.Add("Request-Id", requestId);
            req.Headers.Add("X-Client-TZ", _opts.ClientTz);
            req.Headers.Add("X-Client-Offset-Minutes", _opts.ClientOffsetMinutes.ToString());
            if (trail is not null) req.Headers.Add("X-Imtbl-Trail", trail);

            await _http.SendAsync(req);
        }
        catch
        {
            // Never let audit logging break the application.
        }
    }

    // Normaliza o immutable_trail (trim, nao-vazio, max 256, sem ':').
    private static string? SanitizeTrail(string? value)
    {
        if (string.IsNullOrWhiteSpace(value)) return null;
        var v = value.Trim().Replace(":", "-");
        if (v.Length > 256) v = v[..256];
        return v;
    }
}

Registration and Configuration

Register the options, the `IHttpClientFactory`, and the middleware in `Program.cs`. Use environment variables for credentials.

csharp
// Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHttpClient("immutablelog", c => c.Timeout = TimeSpan.FromSeconds(5));
builder.Services.AddSingleton(new ImmutableLogOptions
{
    ApiKey = Environment.GetEnvironmentVariable("IMTBL_API_KEY") ?? "",
    Service = "my-aspnet-service",
    Env = builder.Environment.EnvironmentName,
    SkipPaths = new() { "/health", "/healthz", "/metrics" },
});
builder.Services.AddSingleton<ImmutableLogMiddleware>(); // IMiddleware resolvido via DI

var app = builder.Build();

// Registre cedo no pipeline para capturar todas as requisições.
app.UseMiddleware<ImmutableLogMiddleware>();

app.MapControllers();
app.Run();

Never hardcode the token. Load IMTBL_API_KEY from environment variables or user-secrets in development.

How it works

InvokeAsync

Records request_id and Stopwatch before next(ctx)

await next(ctx)

Pipeline runs; Response.StatusCode becomes available

_ = EmitAsync(...)

Discarded Task sends the event — never blocks the response

Custom event and trail

Set `HttpContext.Items["imtbl.eventName"]` and `HttpContext.Items["imtbl.trail"]` in any controller or endpoint. The middleware reads these values when emitting the event.

csharp
[ApiController]
[Route("payments")]
public class PaymentsController : ControllerBase
{
    [HttpPost]
    public IActionResult Create()
    {
        // Sobrescreve o nome do evento e agrupa numa trilha auditavel.
        HttpContext.Items["imtbl.eventName"] = "payment.created";
        HttpContext.Items["imtbl.trail"] = "order-7782";

        return Ok(new { ok = true, payment_id = "pay_123" });
    }
}

Auto default: http.METHOD.path.

Immutable trail (immutable_trail)

A trail groups related events into a single auditable timeline. Set `HttpContext.Items["imtbl.trail"]` in an endpoint, or propagate it across services with the `X-Imtbl-Trail` header. The value goes through `SanitizeTrail` before sending.

csharp
[HttpPost("flows/{id}/run")]
public IActionResult RunFlow(string id)
{
    HttpContext.Items["imtbl.trail"] = $"flow-{id}";
    // ... business logic ...
    return Ok(new { status = "ok" });
}

The trail cannot be empty, exceed 256 characters, or contain `:` — violations return `400 invalid_immutable_trail`. `SanitizeTrail` fixes the value before sending.

Response and status codes

The ingestion API is asynchronous. Success returns 202 (new) or 200 (idempotent duplicate). Since the middleware is fire-and-forget, these codes matter mostly if you send events manually.

StatusMeaning
202Event accepted and queued (new)
200Idempotency-Key already exists — duplicate: true
400Missing/empty Idempotency-Key or invalid_immutable_trail
403Inactive/expired subscription, scope or retention
413payload_too_large (server limit: 16KB)
429monthly_limit_exceeded — do not retry
503mempool_full — transient, retryable

This documentation reflects the current integration behavior. For questions or advanced integrations, contact the support team.