Sign in →
1 min read

Java / Spring SDK

Spring Boot auto-configuration and servlet filter for Java metering.

Updated 2026-11-11Suggest edits
Get the Java SDK
The Java SDK lives in the public repo github.com/aforoai/SDKs under aforo-metering-sdks/java.
View on GitHub Download ZIPgit clone https://github.com/aforoai/SDKs.git

Install & Quickstart#

Add Aforo metering to your Java service. Spring Boot auto-configuration and a manual TrackEvent client.

Requires Java 17+ (uses java.net.http.HttpClient and records). The Maven coordinates are ai.aforo:metering; the Java packages you import stay com.aforo.metering.*.

pom.xml
<dependency>
  <groupId>ai.aforo</groupId>
  <artifactId>metering</artifactId>
  <version>2.0.0</version>
</dependency>

Manual Tracking

Usage.java
import com.aforo.metering.AforoClient;
import com.aforo.metering.AforoOptions;
import com.aforo.metering.TrackEvent;

// AforoClient is AutoCloseable — close() sends what is queued, for at most shutdownTimeoutMs (5 s)
try (AforoClient client = new AforoClient(new AforoOptions(System.getenv("AFORO_API_KEY")))) {
    client.track(TrackEvent.builder("cust_acme_001", "api_calls")
            .quantity(1)
            .metadata(java.util.Map.of("route", "POST /v1/charges"))
            .build());
}

Spring Boot Auto-Configuration

application.yml
aforo:
  enabled: true          # auto-config is off unless this is exactly "true"
  api-key: ${AFORO_API_KEY}
  base-url: https://api.aforo.ai
  metric-name: api_calls   # must exist in your Aforo catalog
  product-type: API        # default; top-level productType on every event
AforoConfig.java
// The customer comes from YOUR authentication, not from a header the caller can set.
@Bean
AforoServletFilter.CustomerIdResolver aforoCustomerIdResolver() {
    return request -> {
        var auth = SecurityContextHolder.getContext().getAuthentication();
        return auth == null ? null : accountService.aforoCustomerIdFor(auth.getName());
    };
}

The auto-configuration wires an AforoClient bean and a request-end servlet filter that runs after the response is committed (no added latency), and closes the client in Spring's stop phase. It resolves the customer from your AforoServletFilter.CustomerIdResolver bean, else the principal name when aforo.use-principal-as-customer-id: true, else the X-Customer-Id header only when aforo.trust-customer-header: true. With none of the three the application fails to start. A request with no customer is served as usual and its event is reported as INVALID; OPTIONS requests are skipped. Outside Spring, build the filter with AforoServletFilter.builder(client).customerIdResolver(...).build().

Who Is Billed#

Every integration that meters an incoming request — HTTP middleware, the gRPC / GraphQL / WebSocket / MQTT clients, the MCP SDKs and the MCP proxy — needs to know which customer to bill, and from 2.0.0 you tell it. Pass a customer resolver that reads identity your service already verified (a session, a JWT subject, an API-key lookup, an mTLS identity).

  • Reading a value the caller of your API controls — the X-Customer-Id header, gRPC metadata, MCP _meta.customer_id, the MQTT username — needs an explicit opt-in: trustCustomerHeader (Node, Java), trust_customer_header (Python), TrustCustomerHeader (Go). Turn it on only when a proxy you control sets the value and strips any client copy.
  • With neither a resolver nor the opt-in, construction fails (Node throws, Python raises ValueError, Go returns an error, Spring fails to start). There is no fallback to unknown, anonymous or the agent id.
  • When the resolver returns nothing for one request, the request runs normally and its event is dropped as invalid and counted.
  • A manual track() call is unchanged: you pass customerId yourself.

Delivery & Dropped Events#

All 2.0.0 SDKs send the same way. track() only queues (bounded, 10,000 events by default; on overflow the oldest is evicted). One background sender per client sends one request at a time: every 5 s, or as soon as 500 events are waiting (the batch size, capped at 1,000). Retries on network errors, timeouts, 408, 429 and 5xx use full-jitter backoff and honour Retry-After (seconds or an HTTP date, capped at 60 s).

An outage does not lose usage. After the last retry — or on 401 / 403, which usually means a wrong or revoked key and is logged as an error — the batch goes back to the front of the queue with the same idempotency keys and a breaker pauses sending (5 s, doubling to 5 min). New events keep queueing; the first successful request resumes sending, and the ingestor deduplicates the re-sent keys. Usage is lost only if the queue overflows first, so raise the queue size if you expect long outages. A 413 splits the batch in half.

Shutdown is bounded: shutdown() / close() keeps sending for at most the shutdown timeout (5 s by default) and reports what is still queued. The libraries install no signal handlers — call shutdown from your own SIGTERM path. Python also flushes at atexit, Node on beforeExit, Java in Spring's stop phase.

Drop reasonWhen
overflowThe queue was full (usually during a long outage); the oldest event was evicted.
rejectedThe ingestor refused the batch with a non-retryable 4xx, refused individual events inside an accepted batch, or answered 413 to a single event. Unknown metric names land here.
invalidThe event failed a client-side check before it was queued (blank customer or metric, quantity <= 0, a field over the ingestor limit), or a customer resolver returned nothing.
shutdownStill queued when the shutdown timeout ran out.
closedtrack() was called after shutdown. It does not throw (Go returns ErrClientClosed).
retry_exhaustedKept for API compatibility only — an outage requeues instead.

Every dropped event is counted, logged, and passed to the opt-in onDrop(events, reason) hook (on_drop in Python, OnDrop in Go). Events keep their idempotency keys, so re-tracking them after recovery does not bill twice.

See also
Aforo Documentation — Aforo Docs