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.
// 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.
// 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.
[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.
[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.
| Status | Meaning |
|---|---|
| 202 | Event accepted and queued (new) |
| 200 | Idempotency-Key already exists — duplicate: true |
| 400 | Missing/empty Idempotency-Key or invalid_immutable_trail |
| 403 | Inactive/expired subscription, scope or retention |
| 413 | payload_too_large (server limit: 16KB) |
| 429 | monthly_limit_exceeded — do not retry |
| 503 | mempool_full — transient, retryable |
This documentation reflects the current integration behavior. For questions or advanced integrations, contact the support team.
