Chris Tam · Last updated 22 October 2026
Spring controllers are a useful place to track actions that reach your backend. Amplitude product analytics connects those actions with the same users and properties your frontend already sends.
In this tutorial, you'll return a report preview, attach verified user or anonymous device identity, set a plan property, and give queued events a bounded opportunity to finish before Spring releases the SDK.
A POST /api/reports/preview endpoint that returns a seven-day usage report and queues Report Previewed with report_type=usage, days=7, and framework=spring. Signed-in events carry the account's plan; anonymous events keep their original device ID.
Prerequisites
- Java 21, Maven, and a Spring Boot 3.5 application using the servlet web starter. This complete example pins Spring Boot 3.5.16 and uses package
com.example.build. - Your application's existing authentication. Its
Principal.getName()must be the same canonical user ID you use in product analytics. - An Amplitude project API key from Settings > Projects > your project > General > Manage next to API Key.
Sign up for free to create an Amplitude account and get a project API key.
For an existing application, keep its package, entry point, dependencies, authentication, authorization, and routes when merging the files below. The standalone example also supports anonymous device events and does not implement fake sign-in.
1. Install the Java SDK
I'll use the artifact from the Java SDK reference, with pinned SDK and JSON versions. Add these dependencies to an existing Maven project; the complete example POM also includes Spring's web and test starters.
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<!-- Keep dependencies and plugins added by later hub pages and your application. -->
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.16</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>amplitude-build-spring</artifactId>
<version>1.0.0</version>
<properties><java.version>21</java.version></properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.amplitude</groupId>
<artifactId>java-sdk</artifactId>
<version>1.13.1</version>
</dependency>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>20260814</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build><plugins><plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin></plugins></build>
</project>Keep the existing plugins and dependencies from other hub pages. The Java SDK runs in the server process, so initialize one Spring-managed client and reuse it for requests.
2. Read verified request identity
I'll read the user from the verified servlet principal. The optional device header carries the frontend's current analytics device ID; it has no authentication authority.
src/main/java/com/example/build/RequestIdentity.java
package com.example.build;
import jakarta.servlet.http.HttpServletRequest;
import java.nio.charset.StandardCharsets;
import java.security.Principal;
import java.util.Locale;
import java.util.Set;
public record RequestIdentity(String userId, String deviceId, String plan) {
public static final String VERIFIED_ACCOUNT = RequestIdentity.class.getName() + ".account";
private static final Set<String> INVALID = Set.of("anonymous", "nil", "none", "null", "n/a", "na",
"undefined", "unknown", "\"\"\"\"", "00000000-0000-0000-0000-000000000000", "{}", "lmy47d", "0", "-1");
// Set by the app's existing authentication adapter after verifying the account.
public record VerifiedAccount(String userId, String plan) {}
public static RequestIdentity from(HttpServletRequest request) {
Principal principal = request.getUserPrincipal();
String userId = principal == null ? null : principal.getName();
if (!validId(userId)) userId = null;
String plan = null;
if (userId != null && request.getAttribute(VERIFIED_ACCOUNT) instanceof VerifiedAccount account
&& userId.equals(account.userId())) plan = account.plan();
String device = request.getHeader("X-Amplitude-Device-Id");
return new RequestIdentity(userId, validId(device) ? device : null, plan);
}
static boolean validId(String value) {
return value != null && value.codePointCount(0, value.length()) >= 5
&& value.getBytes(StandardCharsets.UTF_8).length <= 1024
&& !INVALID.contains(value.toLowerCase(Locale.ROOT));
}
public boolean hasIdentity() { return userId != null || deviceId != null; }
}Your existing authentication adapter can attach a VerifiedAccount after it verifies the session and loads the account. The helper accepts its plan only when the account ID matches the principal. A request body or X-User-ID header cannot select the user.
Keep the exact canonical user ID your frontend uses. The helper rejects short and reserved placeholder IDs and applies a 1,024-byte cap. Anonymous requests carry only a valid device ID; requests with neither identifier still receive their report but skip analytics.
3. Configure the client and delivery callbacks
I'll register each event before it enters the SDK and remove it from the pending set when its callback arrives. This lets shutdown distinguish a queued event from an acknowledged or failed delivery.
src/main/java/com/example/build/AnalyticsService.java
package com.example.build;
import com.amplitude.Amplitude;
import com.amplitude.AmplitudeCallbacks;
import com.amplitude.AmplitudeLog;
import com.amplitude.Event;
import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URI;
import java.time.Duration;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
public final class AnalyticsService implements AutoCloseable {
private static final Logger LOG = LoggerFactory.getLogger(AnalyticsService.class);
private final Amplitude client;
private final Object lock = new Object();
private final Set<String> pending = new HashSet<>();
private boolean accepting = true;
private long acknowledged;
private long failed;
public record Delivery(long acknowledged, long failed, int pending) {}
public AnalyticsService(String apiKey, String zone, String serverOverride) {
if (apiKey == null || apiKey.isBlank()) throw new IllegalArgumentException("Set AMPLITUDE_API_KEY");
String endpoint = switch (zone.toUpperCase(Locale.ROOT)) {
case "US" -> "https://api2.amplitude.com/2/httpapi";
case "EU" -> "https://api.eu.amplitude.com/2/httpapi";
default -> throw new IllegalArgumentException("AMPLITUDE_SERVER_ZONE must be US or EU");
};
if (serverOverride != null && !serverOverride.isBlank()) endpoint = serverOverride;
if (!"https".equalsIgnoreCase(URI.create(endpoint).getScheme()))
throw new IllegalArgumentException("The Java SDK endpoint must use HTTPS");
client = Amplitude.getInstance("build-spring-" + UUID.randomUUID());
client.setServerUrl(endpoint);
client.setFlushTimeout(2000L);
client.setLogger(new AmplitudeLog() {
@Override public void log(String tag, String message, LogMode mode) {
if (mode == LogMode.ERROR || mode == LogMode.WARN)
LOG.warn("Amplitude SDK warning; inspect the project's Ingestion Debugger");
}
});
client.init(apiKey);
}
public boolean report(String name, RequestIdentity identity) {
return track(name, identity, new JSONObject().put("report_type", "usage").put("days", 7).put("framework", "spring"));
}
public boolean track(String name, RequestIdentity identity, JSONObject properties) {
if (!identity.hasIdentity()) return false;
Event event = new Event(name, identity.userId(), identity.deviceId());
event.sessionId = -1;
event.eventProperties = new JSONObject(properties.toString());
if (identity.userId() != null && identity.plan() != null && !identity.plan().isBlank())
event.userProperties = new JSONObject().put("plan", identity.plan());
String token = UUID.randomUUID().toString();
synchronized (lock) {
if (!accepting) return false;
pending.add(token);
try {
client.logEvent(event, new AmplitudeCallbacks() {
@Override public void onLogEventServerResponse(Event ignored, int status, String message) {
if (status != 200) LOG.warn("Amplitude delivery failed (status={})", status);
synchronized (lock) {
if (pending.remove(token)) {
if (status == 200) acknowledged++; else failed++;
lock.notifyAll();
}
}
}
});
} catch (RuntimeException ex) {
pending.remove(token);
failed++;
lock.notifyAll();
LOG.warn("Amplitude could not queue an event");
return false;
}
}
return true;
}
public Delivery flushAndAwait(Duration timeout) {
client.flushEvents();
long deadline = System.nanoTime() + timeout.toNanos();
synchronized (lock) {
while (!pending.isEmpty()) {
long remaining = deadline - System.nanoTime();
if (remaining <= 0) break;
try { TimeUnit.NANOSECONDS.timedWait(lock, remaining); }
catch (InterruptedException ex) { Thread.currentThread().interrupt(); break; }
}
return new Delivery(acknowledged, failed, pending.size());
}
}
@Override public void close() {
synchronized (lock) {
if (!accepting) return;
accepting = false;
}
Delivery delivery = flushAndAwait(Duration.ofSeconds(5));
if (delivery.pending() != 0) LOG.warn("Amplitude shutdown has {} unresolved deliveries", delivery.pending());
try { client.shutdown(); }
catch (InterruptedException ex) { Thread.currentThread().interrupt(); }
catch (RuntimeException ex) { LOG.warn("Amplitude SDK shutdown did not finish cleanly"); }
}
}track builds a fresh event and properties for each request. A signed-in event sets plan through its userProperties; no process-wide user ID is changed. SessionID: -1 keeps these server events outside an analytics visit.
Why. flushEvents() starts background delivery. The callback wait gives that delivery a bounded window to finish. An HTTP acknowledgement still does not prove that Amplitude has processed the user's profile or a chart. The two-second flushing-task timeout is separate from the application's five-second callback wait.
The custom logger retains generic warnings and status codes without printing SDK payloads, keys, user identifiers, or response bodies. Network errors, exhausted retries, timeouts, and a forced process termination can still lose in-memory events; use a durable event pipeline when delivery must survive process loss.
Register the client as a Spring bean so its close method participates in application teardown:
src/main/java/com/example/build/AnalyticsConfiguration.java
package com.example.build;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
@Configuration
public class AnalyticsConfiguration {
@Bean(destroyMethod = "close")
AnalyticsService analyticsService(Environment environment) {
return new AnalyticsService(environment.getRequiredProperty("amplitude.api-key"),
environment.getProperty("amplitude.server-zone", "US"),
environment.getProperty("amplitude.server-url", ""));
}
}The explicit US and EU ingestion endpoints match the project's data residency. The optional server override supports an HTTPS endpoint; keep that configuration under server control.
4. Track the report response
I'll keep report rendering behind a small interface so later pages can add a flag-controlled experience without replacing this route.
src/main/java/com/example/build/ReportPreview.java
package com.example.build;
import org.json.JSONObject;
// Later hub pages can supply a primary implementation without replacing routes.
public interface ReportPreview {
JSONObject build(RequestIdentity identity);
}src/main/java/com/example/build/BasicReportPreview.java
package com.example.build;
import org.json.JSONObject;
import org.springframework.stereotype.Component;
@Component
public class BasicReportPreview implements ReportPreview {
@Override public JSONObject build(RequestIdentity identity) {
return new JSONObject().put("report_type", "usage").put("days", 7);
}
}The controller writes the response before queuing the event. Keep your real report computation and authorization checks in the application when replacing the small example report.
src/main/java/com/example/build/ReportController.java
package com.example.build;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@RestController
public class ReportController {
private final AnalyticsService analytics;
private final ReportPreview preview;
public ReportController(AnalyticsService analytics, ReportPreview preview) {
this.analytics = analytics;
this.preview = preview;
}
@PostMapping("/api/reports/preview")
public void preview(HttpServletRequest request, HttpServletResponse response) throws IOException {
RequestIdentity identity = RequestIdentity.from(request);
byte[] body = preview.build(identity).toString().getBytes(StandardCharsets.UTF_8);
response.setContentType("application/json");
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.getOutputStream().write(body);
analytics.report("Report Previewed", identity);
}
@GetMapping("/healthz")
public String health() { return "ok"; }
}A failed response write skips tracking. A successful server write means the server wrote the body; it does not prove that a browser displayed it. The health route does not send a product event.
MCP. Ask your coding agent to inspect your existing event taxonomy through Amplitude MCP before choosing a new report event name.
5. Connect Spring startup and shutdown
Keep your existing Spring entry point if you already have one. For the standalone example:
src/main/java/com/example/build/BuildApplication.java
package com.example.build;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
// Keep application configuration and components added by the other hub pages.
@SpringBootApplication
public class BuildApplication {
public static void main(String[] args) {
SpringApplication.run(BuildApplication.class, args);
}
}I'll allow active requests to drain before bean teardown, then let the analytics service flush and wait for its callbacks.
src/main/resources/application.properties
# Keep configuration added by the flags/agent pages and your application.
server.address=127.0.0.1
server.port=8080
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=15s
amplitude.api-key=${AMPLITUDE_API_KEY:}
amplitude.server-zone=${AMPLITUDE_SERVER_ZONE:US}The HTTP grace period is 15 seconds, followed by the analytics callback window during bean destruction. Keep application calls bounded and allow enough time in the deployment's termination grace period. An immediate kill cannot run this shutdown path.
Terminal
mvn test
mvn package
export AMPLITUDE_API_KEY='your-project-api-key'
export AMPLITUDE_SERVER_ZONE=US
java -jar target/amplitude-build-spring-1.0.0.jarUse EU for an EU project. The application reads these process variables through Spring configuration; it does not load a .env file. The example binds to localhost. Use your deployment's listening address when hosting it, and keep your existing authentication middleware active.
Verify it worked
Send an anonymous preview from another terminal:
Terminal
curl -i -X POST http://127.0.0.1:8080/api/reports/preview \
-H 'X-Amplitude-Device-Id: java-report-test-20261021'- Confirm a
200report response withreport_type: usageanddays: 7. Let the SDK batch send or stop the server gracefully and wait for shutdown to finish. The report response alone does not confirm event delivery. - In Amplitude, open Data > Sources > Ingestion Debugger and check request counts and errors. That view shows aggregates rather than individual events.
- Open Users > User Profiles, search for device ID
java-report-test-20261021, and inspectReport Previewedin the event stream. Allow ten seconds to one minute. Open Raw and check the event properties, device ID, null user ID, and out-of-session value. - Repeat through your application's real sign-in flow. Search the canonical user ID and confirm the plan matches the verified account. Exercise two accounts concurrently and confirm events stay under the right users.
- Check shutdown while a request is active. The request should finish before analytics closes, and its final event should reach the project's stream when delivery succeeds. Investigate failed or unresolved-delivery warnings rather than treating every queued event as acknowledged.
Troubleshooting
The report succeeds but no event appears
Anonymous requests need a valid original device ID. Check the API key, project region, Ingestion Debugger errors, and the batch interval before terminating the process.
Authenticated events are anonymous or have no plan
Check that the servlet principal contains the canonical analytics ID. Attach VerifiedAccount from trusted account data after verification, with the same ID. The standalone app has no sign-in flow of its own.
Two accounts share events
Keep identity on each Event, use the same canonical IDs as the frontend, and forward the frontend's existing device ID. Do not store the current user on the shared analytics service or generate a new device ID on every request.
Events disappear during deployment
Keep graceful HTTP shutdown enabled, drain request work, and wait for delivery callbacks before SDK shutdown. A timeout or network failure can leave delivery failed or unresolved. Align the deployment grace period with both shutdown windows.
A configured endpoint fails before delivery
The tested Java SDK uses an HTTPS connection. Keep the default regional endpoint or configure an HTTPS proxy; an HTTP URL is rejected by this wrapper.
Frequently asked questions
Do I create a client for every request?
I keep one client per Spring application context and create a separate event and identity for each request.
Does flushEvents() mean the event is delivered?
It starts asynchronous work. The registered callback reports the delivery result. The wrapper waits for those callbacks during shutdown, with a deadline, and records failure separately from acknowledgement.
Does a device header authenticate a user?
The header is analytics metadata. Keep authentication and report authorization in your existing application controls.
Does this capture frontend screens or replay?
The Java SDK sends backend events. Use a frontend integration to capture screen activity and provide its device ID when connecting browser or mobile activity with server events.
Full code reference
The complete example uses package com.example.build. Keep all existing application and hub additions when merging these files.
// pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<!-- Keep dependencies and plugins added by later hub pages and your application. -->
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.16</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>amplitude-build-spring</artifactId>
<version>1.0.0</version>
<properties><java.version>21</java.version></properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.amplitude</groupId>
<artifactId>java-sdk</artifactId>
<version>1.13.1</version>
</dependency>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>20260814</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build><plugins><plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin></plugins></build>
</project>
// src/main/java/com/example/build/RequestIdentity.java
package com.example.build;
import jakarta.servlet.http.HttpServletRequest;
import java.nio.charset.StandardCharsets;
import java.security.Principal;
import java.util.Locale;
import java.util.Set;
public record RequestIdentity(String userId, String deviceId, String plan) {
public static final String VERIFIED_ACCOUNT = RequestIdentity.class.getName() + ".account";
private static final Set<String> INVALID = Set.of("anonymous", "nil", "none", "null", "n/a", "na",
"undefined", "unknown", "\"\"\"\"", "00000000-0000-0000-0000-000000000000", "{}", "lmy47d", "0", "-1");
// Set by the app's existing authentication adapter after verifying the account.
public record VerifiedAccount(String userId, String plan) {}
public static RequestIdentity from(HttpServletRequest request) {
Principal principal = request.getUserPrincipal();
String userId = principal == null ? null : principal.getName();
if (!validId(userId)) userId = null;
String plan = null;
if (userId != null && request.getAttribute(VERIFIED_ACCOUNT) instanceof VerifiedAccount account
&& userId.equals(account.userId())) plan = account.plan();
String device = request.getHeader("X-Amplitude-Device-Id");
return new RequestIdentity(userId, validId(device) ? device : null, plan);
}
static boolean validId(String value) {
return value != null && value.codePointCount(0, value.length()) >= 5
&& value.getBytes(StandardCharsets.UTF_8).length <= 1024
&& !INVALID.contains(value.toLowerCase(Locale.ROOT));
}
public boolean hasIdentity() { return userId != null || deviceId != null; }
}
// src/main/java/com/example/build/AnalyticsService.java
package com.example.build;
import com.amplitude.Amplitude;
import com.amplitude.AmplitudeCallbacks;
import com.amplitude.AmplitudeLog;
import com.amplitude.Event;
import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URI;
import java.time.Duration;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
public final class AnalyticsService implements AutoCloseable {
private static final Logger LOG = LoggerFactory.getLogger(AnalyticsService.class);
private final Amplitude client;
private final Object lock = new Object();
private final Set<String> pending = new HashSet<>();
private boolean accepting = true;
private long acknowledged;
private long failed;
public record Delivery(long acknowledged, long failed, int pending) {}
public AnalyticsService(String apiKey, String zone, String serverOverride) {
if (apiKey == null || apiKey.isBlank()) throw new IllegalArgumentException("Set AMPLITUDE_API_KEY");
String endpoint = switch (zone.toUpperCase(Locale.ROOT)) {
case "US" -> "https://api2.amplitude.com/2/httpapi";
case "EU" -> "https://api.eu.amplitude.com/2/httpapi";
default -> throw new IllegalArgumentException("AMPLITUDE_SERVER_ZONE must be US or EU");
};
if (serverOverride != null && !serverOverride.isBlank()) endpoint = serverOverride;
if (!"https".equalsIgnoreCase(URI.create(endpoint).getScheme()))
throw new IllegalArgumentException("The Java SDK endpoint must use HTTPS");
client = Amplitude.getInstance("build-spring-" + UUID.randomUUID());
client.setServerUrl(endpoint);
client.setFlushTimeout(2000L);
client.setLogger(new AmplitudeLog() {
@Override public void log(String tag, String message, LogMode mode) {
if (mode == LogMode.ERROR || mode == LogMode.WARN)
LOG.warn("Amplitude SDK warning; inspect the project's Ingestion Debugger");
}
});
client.init(apiKey);
}
public boolean report(String name, RequestIdentity identity) {
return track(name, identity, new JSONObject().put("report_type", "usage").put("days", 7).put("framework", "spring"));
}
public boolean track(String name, RequestIdentity identity, JSONObject properties) {
if (!identity.hasIdentity()) return false;
Event event = new Event(name, identity.userId(), identity.deviceId());
event.sessionId = -1;
event.eventProperties = new JSONObject(properties.toString());
if (identity.userId() != null && identity.plan() != null && !identity.plan().isBlank())
event.userProperties = new JSONObject().put("plan", identity.plan());
String token = UUID.randomUUID().toString();
synchronized (lock) {
if (!accepting) return false;
pending.add(token);
try {
client.logEvent(event, new AmplitudeCallbacks() {
@Override public void onLogEventServerResponse(Event ignored, int status, String message) {
if (status != 200) LOG.warn("Amplitude delivery failed (status={})", status);
synchronized (lock) {
if (pending.remove(token)) {
if (status == 200) acknowledged++; else failed++;
lock.notifyAll();
}
}
}
});
} catch (RuntimeException ex) {
pending.remove(token);
failed++;
lock.notifyAll();
LOG.warn("Amplitude could not queue an event");
return false;
}
}
return true;
}
public Delivery flushAndAwait(Duration timeout) {
client.flushEvents();
long deadline = System.nanoTime() + timeout.toNanos();
synchronized (lock) {
while (!pending.isEmpty()) {
long remaining = deadline - System.nanoTime();
if (remaining <= 0) break;
try { TimeUnit.NANOSECONDS.timedWait(lock, remaining); }
catch (InterruptedException ex) { Thread.currentThread().interrupt(); break; }
}
return new Delivery(acknowledged, failed, pending.size());
}
}
@Override public void close() {
synchronized (lock) {
if (!accepting) return;
accepting = false;
}
Delivery delivery = flushAndAwait(Duration.ofSeconds(5));
if (delivery.pending() != 0) LOG.warn("Amplitude shutdown has {} unresolved deliveries", delivery.pending());
try { client.shutdown(); }
catch (InterruptedException ex) { Thread.currentThread().interrupt(); }
catch (RuntimeException ex) { LOG.warn("Amplitude SDK shutdown did not finish cleanly"); }
}
}
// src/main/java/com/example/build/AnalyticsConfiguration.java
package com.example.build;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
@Configuration
public class AnalyticsConfiguration {
@Bean(destroyMethod = "close")
AnalyticsService analyticsService(Environment environment) {
return new AnalyticsService(environment.getRequiredProperty("amplitude.api-key"),
environment.getProperty("amplitude.server-zone", "US"),
environment.getProperty("amplitude.server-url", ""));
}
}
// src/main/java/com/example/build/ReportPreview.java
package com.example.build;
import org.json.JSONObject;
// Later hub pages can supply a primary implementation without replacing routes.
public interface ReportPreview {
JSONObject build(RequestIdentity identity);
}
// src/main/java/com/example/build/BasicReportPreview.java
package com.example.build;
import org.json.JSONObject;
import org.springframework.stereotype.Component;
@Component
public class BasicReportPreview implements ReportPreview {
@Override public JSONObject build(RequestIdentity identity) {
return new JSONObject().put("report_type", "usage").put("days", 7);
}
}
// src/main/java/com/example/build/ReportController.java
package com.example.build;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@RestController
public class ReportController {
private final AnalyticsService analytics;
private final ReportPreview preview;
public ReportController(AnalyticsService analytics, ReportPreview preview) {
this.analytics = analytics;
this.preview = preview;
}
@PostMapping("/api/reports/preview")
public void preview(HttpServletRequest request, HttpServletResponse response) throws IOException {
RequestIdentity identity = RequestIdentity.from(request);
byte[] body = preview.build(identity).toString().getBytes(StandardCharsets.UTF_8);
response.setContentType("application/json");
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.getOutputStream().write(body);
analytics.report("Report Previewed", identity);
}
@GetMapping("/healthz")
public String health() { return "ok"; }
}
// src/main/java/com/example/build/BuildApplication.java
package com.example.build;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
// Keep application configuration and components added by the other hub pages.
@SpringBootApplication
public class BuildApplication {
public static void main(String[] args) {
SpringApplication.run(BuildApplication.class, args);
}
}
// src/main/resources/application.properties
# Keep configuration added by the flags/agent pages and your application.
server.address=127.0.0.1
server.port=8080
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=15s
amplitude.api-key=${AMPLITUDE_API_KEY:}
amplitude.server-zone=${AMPLITUDE_SERVER_ZONE:US}Hand it to your agent
Use https://amplitude.com/build/java-product-analytics to add Amplitude to this
Java/Spring report backend. Inspect the existing Maven project, servlet auth,
canonical user IDs, account properties, routes and shutdown lifecycle first.
Reuse one Java SDK client per application context. Put identity on fresh events,
never on global current-user state. Read only a verified principal/account and
the frontend's original device ID; do not authenticate from headers or JSON.
Track Report Previewed with report_type, days and framework after the report
body write succeeds. Set plan from trusted account data. Keep keys in process
configuration, payloads out of logs, and existing authorization intact. Preserve
report-renderer extensions, feature flags, agent routes and other app additions.
Drain HTTP requests before flushing, wait for event callbacks with a deadline,
then shut down the SDK. Test anonymous/verified identities, concurrent users,
failed writes, rejected delivery and final events during Spring shutdown.
Verify remote user profiles separately from an HTTP acknowledgement. Report
changed files, actual test results and remaining verification limits.What this unlocks
Product analytics connects Report Previewed with the plan and other actions for the same canonical user.
Feature flags can change the report renderer while keeping identity and conversion events in the same app.
Agent analytics can connect a report assistant's work with the same user and report outcomes.
Next steps
Reference docs
Amplitude is the leading all-in-one analytics platform for building better products. Product and engineering teams use Amplitude to connect product analytics and web analytics with session replay, heatmaps, experimentation, feature flags, and guides and surveys in one unified workflow, with AI agents working across all of it. Learn how Amplitude is building self-improving products with Wave, helping teams turn product data into PRs.