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 |
meta fields
Beyond the payload, the meta object carries the fields the SIEM uses to normalize (ECS) and enrich the event. All meta values are strings.
| Field in meta | What it does in the SIEM |
|---|---|
event_name | The event label (e.g. http.GET.auth-me-user, Payment error) — the identifier shown in the listing. Becomes event.action and derives event.category (e.g. a name with login → authentication). |
client_ip | Becomes source.ip with maximum precedence and provenance client_asserted. See the section below. |
immutable_trail | Becomes imtbl.immutable_trail — groups related events for investigation (e.g. one order, one user). |
type | Event severity: error / warning / info / success. Colors the badge in the listing. (event_type is accepted as a synonym.) |
service, env, request_id | Metadata: indexed and queryable in the dashboard (search/filters). |
client_ip — end-user IP
The problem it solves
Between the browser and the core there are proxies/ALB. The IP the core sees on the connection is the previous hop (the client's backend or an AWS proxy), not the user's. Without action, source.ip would be the proxy IP — useless for geo, threat and IP-based detection. (Real example: XFF arrived as 44.192.13.3 (AWS) while the user was 179.110.4.205.)
The solution
The client's backend is the only one that sees the browser's real IP — so it forwards that IP in meta.client_ip when calling POST /v1/events.
How the SIEM handles it
- Validates it is an IP (v4/v6). A non-IP value is ignored — it does not drop the event.
- Writes to
source.ipwith maximum precedence (see order below). - Stamps
imtbl.source_ip_origin = "client_asserted"— the UI shows the “User IP” badge. - Feeds GeoIP, threat intel and IP-based detection rules.
client_ipX-Forwarded-For (1º hop)X-Real-IPconnection IPProvenance and trust
client_ip is asserted by the tenant — the client's backend claims the IP, and it is spoofable by whoever controls that backend. However it is self-contained to the tenant (it never crosses another tenant's boundary). A threat/geo hit on a client_asserted value is a claim, not an edge observation.
How to obtain the IP in your backend
Take the first IP from X-Forwarded-For (closest to the user); as a fallback, use the connection IP.
static string? ClientIp(HttpContext ctx)
{
var xff = ctx.Request.Headers["X-Forwarded-For"].ToString();
if (!string.IsNullOrEmpty(xff))
return xff.Split(',')[0].Trim(); // primeiro hop = IP do usuario
return ctx.Connection.RemoteIpAddress?.ToString();
}
var meta = new Dictionary<string, string>
{
["event_name"] = "user.login",
["client_ip"] = ClientIp(ctx) ?? "",
};Payload example
{
"payload": "{\"user\":\"bob\",\"action\":\"login\"}",
"meta": {
"event_name": "user.login",
"client_ip": "179.110.4.205",
"immutable_trail": "user-bob"
}
}PII note: client_ip is PII and gets sealed into the immutable block (permanent). Decide consciously between forensic evidence and “right to be forgotten” before sending it.
This documentation reflects the current integration behavior. For questions or advanced integrations, contact the support team.
