Java
Complete integration guide for ImmutableLog in Java. Choose between automatic middleware for Spring Boot, Quarkus, and Micronaut, direct HTTP client for workers and jobs, or @AuditEvent via Spring AOP to instrument service methods.
Framework filters
The fastest way to integrate is via the framework's native HTTP filter. Every request is automatically captured — latency, status, client IP, and body hash — without modifying any controller. Click the framework to see the complete code.
OncePerRequestFilter with ContentCachingWrapper, HttpClient.sendAsync() fire-and-forget, and @AuditEvent via Spring AOP.
@Component
public class ImmutableLogFilter
extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest req,
HttpServletResponse res,
FilterChain chain) throws ... {
// wrap → chain → emit (async)
}
}View full documentation →
JAX-RS ContainerRequestFilter + ContainerResponseFilter with @Provider. Compatible with RESTEasy Reactive and GraalVM native compilation.
@Provider
@Priority(Priorities.USER - 100)
public class ImmutableLogFilter
implements ContainerRequestFilter,
ContainerResponseFilter {
@Override
public void filter(ContainerRequestContext req) { ... }
@Override
public void filter(ContainerRequestContext req,
ContainerResponseContext res) { ... }
}View full documentation →
HttpServerFilter with reactive Publishers.map(). Zero runtime reflection — compatible with GraalVM Native Image by default.
@Filter("/**")
@Singleton
public class ImmutableLogFilter
implements HttpServerFilter {
@Override
public Publisher<MutableHttpResponse<?>> doFilter(
HttpRequest<?> req, ServerFilterChain chain) {
return Publishers.map(chain.proceed(req),
res -> { emit(req, res); return res; });
}
}View full documentation →
Direct HTTP client
Use java.net.http.HttpClient (Java 11+) to send events directly to ImmutableLog without depending on a web framework. Ideal for workers, Kafka consumers, scheduled jobs, and any JVM code that needs to record events in the ledger.
import java.net.URI;
import java.net.http.*;
import java.util.*;
import com.fasterxml.jackson.databind.ObjectMapper;
public class ImmutableLogClient {
private static final String API_URL = "https://api.immutablelog.com/v1/events";
private final String apiKey;
private final String serviceName;
private final String env;
private final HttpClient http = HttpClient.newHttpClient();
private final ObjectMapper mapper = new ObjectMapper();
public ImmutableLogClient(String apiKey, String serviceName, String env) {
this.apiKey = apiKey;
this.serviceName = serviceName;
this.env = env;
}
// Normaliza o immutable_trail (trim, max 256, sem ':').
// O servidor responde 400 invalid_immutable_trail se a regra for violada.
private static String sanitizeTrail(String v) {
if (v == null) return null;
v = v.trim();
if (v.isEmpty()) return null;
v = v.replace(":", "-");
if (v.length() > 256) v = v.substring(0, 256);
return v;
}
public CompletableFuture<Void> sendEvent(
String eventName,
String type, // "success" | "info" | "error"
Map<String, Object> payload
) {
return sendEvent(eventName, type, payload, null);
}
public CompletableFuture<Void> sendEvent(
String eventName,
String type, // "success" | "info" | "error"
Map<String, Object> payload,
String immutableTrail // optional: groups related events into a trail
) {
try {
String requestId = UUID.randomUUID().toString();
String trail = sanitizeTrail(immutableTrail);
// Todos os valores de meta precisam ser strings.
Map<String, Object> meta = new HashMap<>();
meta.put("type", type);
meta.put("event_name", eventName);
meta.put("service", serviceName);
meta.put("request_id", requestId);
meta.put("env", env);
if (trail != null) meta.put("immutable_trail", trail);
Map<String, Object> body = Map.of(
"payload", mapper.writeValueAsString(payload),
"meta", meta
);
String json = mapper.writeValueAsString(body);
var builder = HttpRequest.newBuilder()
.uri(URI.create(API_URL))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + apiKey)
// Idempotency-Key e OBRIGATORIO (sem ele a API responde 400).
.header("Idempotency-Key", eventName + "-" + requestId)
.header("Request-Id", requestId)
.header("X-Client-TZ", "America/Sao_Paulo")
.header("X-Client-Offset-Minutes", "-180");
if (trail != null) builder.header("X-Imtbl-Trail", trail);
var request = builder
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
// Fire and forget — never block the caller
return http.sendAsync(request, HttpResponse.BodyHandlers.discarding())
.thenApply(_ -> null);
} catch (Exception e) {
return CompletableFuture.failedFuture(e);
}
}
}
// Usage:
var client = new ImmutableLogClient(
System.getenv("IMTBL_API_KEY"), "my-service", "production"
);
client.sendEvent("user.created", "success", Map.of(
"user_id", "usr_123",
"email_hash", sha256("user@example.com"),
"plan", "pro"
));
// Com trilha (agrupa eventos do mesmo fluxo) / With trail:
client.sendEvent("order.paid", "success",
Map.of("order_id", "ord_7782"), "order-7782");sendAsync() returns a CompletableFuture without blocking the thread. The event is sent asynchronously — code execution continues immediately after the call.
Retry with exponential backoff
For high-availability production, add automatic retry with exponential backoff. ImmutableLog returns 202 on success and 429 when the monthly limit is reached — never retry on 429.
public CompletableFuture<Void> sendEventWithRetry(
String eventName,
String type,
Map<String, Object> payload,
int maxAttempts
) {
return sendWithRetry(eventName, type, payload, maxAttempts, 0, 500);
}
private CompletableFuture<Void> sendWithRetry(
String eventName, String type, Map<String, Object> payload,
int maxAttempts, int attempt, long delayMs
) {
return sendEventRaw(eventName, type, payload)
.handle((res, ex) -> {
// 202 = success, 429 = rate limited (never retry)
if (ex == null) return CompletableFuture.<Void>completedFuture(null);
if (attempt + 1 >= maxAttempts) return CompletableFuture.<Void>failedFuture(ex);
return CompletableFuture
.runAsync(() -> {}, CompletableFuture.delayedExecutor(
delayMs, TimeUnit.MILLISECONDS
))
.thenCompose(_ -> sendWithRetry(
eventName, type, payload,
maxAttempts, attempt + 1, delayMs * 2 // exponential backoff
));
})
.thenCompose(f -> f);
}Batch sending
In high-frequency workers, accumulate events and send in parallel using CompletableFuture.allOf(). Each event is sent individually — allOf() parallelizes without failing the entire batch.
public CompletableFuture<Void> sendBatch(List<EventDto> events) {
// Send all events in parallel — fire and forget each one
var futures = events.stream()
.map(e -> sendEvent(e.eventName(), e.type(), e.payload())
.exceptionally(ex -> null) // never fail the batch
)
.toList();
return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));
}
// Usage:
client.sendBatch(List.of(
new EventDto("order.created", "success", Map.of("order_id", "ord_1")),
new EventDto("payment.captured", "success", Map.of("amount", 9900)),
new EventDto("email.sent", "info", Map.of("template", "welcome"))
));AOP @AuditEvent
With Spring AOP, instrument any service method without modifying business logic. The event is emitted in the finally block — recorded even when the method throws an exception.
// 1. Add dependency: spring-boot-starter-aop
// pom.xml: <artifactId>spring-boot-starter-aop</artifactId>
// 2. Define the annotation
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AuditEvent {
String value() default "";
String type() default "success";
}
// 3. Implement the aspect
@Aspect
@Component
public class AuditEventAspect {
private final ImmutableLogClient client;
public AuditEventAspect(ImmutableLogClient client) {
this.client = client;
}
@Around("@annotation(auditEvent)")
public Object audit(ProceedingJoinPoint pjp, AuditEvent auditEvent)
throws Throwable {
long startMs = System.currentTimeMillis();
String name = auditEvent.value().isBlank()
? pjp.getSignature().getDeclaringTypeName() + "." + pjp.getSignature().getName()
: auditEvent.value();
try {
Object result = pjp.proceed();
long latency = System.currentTimeMillis() - startMs;
client.sendEvent(name, auditEvent.type(), Map.of("latency_ms", latency));
return result;
} catch (Throwable ex) {
long latency = System.currentTimeMillis() - startMs;
client.sendEvent(name, "error", Map.of(
"latency_ms", latency,
"error_type", ex.getClass().getName(),
"error_message", ex.getMessage()
));
throw ex;
}
}
}
// 4. Use on any Spring bean:
@Service
public class OrderService {
@AuditEvent("order.processed")
public Order process(OrderRequest req) {
// ... business logic
}
@AuditEvent(value = "payment.captured", type = "success")
public void capturePayment(String orderId, long amountCents) {
// ...
}
}@Around captures exceptions
@Around is used instead of @AfterReturning/@AfterThrowing to ensure the event is emitted in all cases — success, failure, or exception.
Auto name via reflection
If value() is empty, the aspect uses the method name via pjp.getSignature(). Always prefer an explicit semantic name via @AuditEvent("domain.action").
Spring Bean configuration
For Spring Boot projects, register ImmutableLogClient as a @Bean for injection via @Autowired or constructor in any component.
// Register ImmutableLogClient as a Spring bean for injection
@Configuration
public class ImmutableLogConfig {
@Bean
public ImmutableLogClient immutableLogClient(
@Value("${immutablelog.api-key}") String apiKey,
@Value("${immutablelog.service-name:my-service}") String service,
@Value("${immutablelog.env:production}") String env
) {
return new ImmutableLogClient(apiKey, service, env);
}
}
// application.properties:
// immutablelog.api-key=${IMTBL_API_KEY}
// immutablelog.service-name=my-service
// immutablelog.env=productionmeta 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.
String clientIp(HttpServletRequest req) {
String xff = req.getHeader("X-Forwarded-For");
if (xff != null && !xff.isBlank()) {
return xff.split(",")[0].trim(); // primeiro hop = IP do usuario
}
return req.getRemoteAddr();
}
Map<String, String> meta = Map.of("event_name", "user.login", "client_ip", clientIp(req));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 API behavior. For questions or advanced integrations, contact the support team.
