Chris Tam · Last updated 23 October 2026
Spring Boot can choose a report layout before returning its response. I use feature flags to change that choice for selected users and connect their report exports to the variant they received.
In this tutorial, you'll build a Spring report app, evaluate a flag with verified user identity, record exposure, and track an export conversion. The complete setup is below; Java product analytics explains the event-tracking foundation in more detail.
A Spring backend where POST /api/reports/preview returns a control or treatment layout, and POST /api/reports/export records Report Exported. The treatment includes a short summary. A missing assignment or failed fetch keeps the baseline available.
Prerequisites
- Java 21 and Maven 3.6.3 or later. The complete project below pins Spring Boot 3.5.16, Java SDK 1.13.1 and Experiment JVM 1.8.4.
- Your application's verified authentication for signed-in targeting. Its servlet principal must contain your canonical user ID. The included identity adapter accepts an optional trusted
VerifiedAccountattribute for plan data. You can run the anonymous baseline without sign-in. - An Amplitude project API key and a server deployment key from that same project, with access to feature flags. Keep both keys in server configuration and use the project's region for both SDKs.
Sign up for free to create an Amplitude account and project, then confirm your project's feature access.
1. Configure the report flag
Use the Experiment quick start to select or create a Server deployment in the analytics project. Create a remote-evaluation flag with key report-summary, add control and treatment variants, associate that deployment, and activate it. Keep each variant's key and value equal to its name for this recipe. Start with individual test-user inclusions and a small rollout.
The application and flag configuration agree on these names:
Report flag configuration
Flag key: report-summary
Evaluation: Remote
Deployment: Server, same project as analytics
Variants: control, treatment
Control layout: existing report
Treatment layout: existing report plus summary
Outcome event: Report ExportedUse real canonical test-user IDs from your authentication system when configuring inclusions. The device ID forwarded by a frontend remains analytics metadata; it does not authenticate a request.
2. Add the JVM SDK and configuration
The JVM SDK reference supplies the flag client. The Java Analytics SDK records exposure and report exports. The Experiment client can fetch variants independently; this complete example includes analytics to measure the result.
Create a project directory, then save the complete files below at their labeled paths. Java sources use package com.example.build.
Terminal, new project
mkdir -p java-report-flags/src/main/java/com/example/build
mkdir -p java-report-flags/src/main/resources
cd java-report-flagsFor an existing app, merge dependencies and settings, reuse its entry point and authentication, and preserve routes and any agent instrumentation. Initialize one analytics client per application. You can follow this page without completing another Build tutorial.
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>com.amplitude</groupId>
<artifactId>experiment-jvm-server</artifactId>
<version>1.8.4</version>
</dependency>
<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/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}
amplitude.flags-enabled=${AMPLITUDE_FLAGS_ENABLED:false}
amplitude.deployment-key=${AMPLITUDE_DEPLOYMENT_KEY:}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/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;
import java.util.concurrent.atomic.AtomicInteger;
public final class AnalyticsService implements AutoCloseable {
private static final Logger LOG = LoggerFactory.getLogger(AnalyticsService.class);
private final Amplitude client;
private final AtomicInteger eventIds = new AtomicInteger();
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.eventId = eventIds.updateAndGet(id -> id == Integer.MAX_VALUE ? 0 : id + 1);
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"; }
}RequestIdentity reads only the verified servlet principal for the user ID. Your authentication adapter can set RequestIdentity.VERIFIED_ACCOUNT to a matching VerifiedAccount(userId, plan) after verifying the account. The optional device-ID header carries analytics identity and cannot sign a user in. Without an authentication adapter, requests remain anonymous.
The Spring-managed analytics service creates a fresh event for each request and waits for delivery callbacks after active HTTP handlers drain. Its increasing event IDs distinguish simultaneous events; each event also retains a UUID insert ID. The explicit flags switch leaves the ordinary preview available when flags are disabled. Enabling flags with an empty deployment key fails at startup.
3. Fetch assignments and expose the report variant
I initialize one remote client for the deployment and build a fresh evaluation user for each request. The fetch options request assignment tracking and disable automatic exposure for the deployment-wide fetch. After selecting report-summary, the service sends the defined exposure event through the shared analytics client above.
src/main/java/com/example/build/ReportFlags.java
package com.example.build;
import com.amplitude.experiment.Experiment;
import com.amplitude.experiment.ExperimentUser;
import com.amplitude.experiment.FetchOptions;
import com.amplitude.experiment.RemoteEvaluationClient;
import com.amplitude.experiment.RemoteEvaluationConfig;
import com.amplitude.experiment.ServerZone;
import com.amplitude.experiment.Variant;
import com.amplitude.experiment.util.LogLevel;
import com.amplitude.experiment.util.LoggerProvider;
import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Locale;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
public final class ReportFlags {
public static final String KEY = "report-summary";
private static final Logger LOG = LoggerFactory.getLogger(ReportFlags.class);
private final RemoteEvaluationClient client;
private final AnalyticsService analytics;
public ReportFlags(String key, String zone, String serverOverride, AnalyticsService analytics) {
if (key == null || key.isBlank()) throw new IllegalArgumentException("Set AMPLITUDE_DEPLOYMENT_KEY");
this.analytics = analytics;
ServerZone serverZone = switch (zone.toUpperCase(Locale.ROOT)) {
case "US" -> ServerZone.US;
case "EU" -> ServerZone.EU;
default -> throw new IllegalArgumentException("AMPLITUDE_SERVER_ZONE must be US or EU");
};
var config = RemoteEvaluationConfig.builder().serverZone(serverZone)
.fetchTimeoutMillis(500).fetchRetries(0).logLevel(LogLevel.ERROR)
.loggerProvider(new LoggerProvider() {
@Override public void verbose(String ignored) {}
@Override public void debug(String ignored) {}
@Override public void info(String ignored) {}
@Override public void warn(String ignored) { LOG.warn("Experiment SDK warning; inspect the deployment"); }
@Override public void error(String ignored) { LOG.warn("Experiment SDK error; inspect the deployment"); }
});
if (serverOverride != null && !serverOverride.isBlank()) config.serverUrl(serverOverride);
client = Experiment.initializeRemote(key, config.build());
}
// Call only where the report response chooses its experience, not on prefetch/export.
public String chooseAndExpose(RequestIdentity identity) {
if (identity.userId() == null) return "control";
var user = ExperimentUser.builder().userId(identity.userId()).deviceId(identity.deviceId());
if (identity.plan() != null) user.userProperty("plan", identity.plan());
var options = FetchOptions.builder().setTracksAssignment(true).setTracksExposure(false).build();
CompletableFuture<java.util.Map<String, Variant>> pending = client.fetch(user.build(), options);
try {
Variant variant = pending.get(650, TimeUnit.MILLISECONDS).get(KEY);
if (variant == null || (variant.metadata != null && (Boolean.TRUE.equals(variant.metadata.get("default"))
|| Boolean.FALSE.equals(variant.metadata.get("deployed"))))) return "control";
if (!"control".equals(variant.value) && !"treatment".equals(variant.value)) return "control";
if (variant.key == null || variant.key.isBlank()) return "control";
// Fetch can return other flags; expose only the experience this handler uses.
analytics.track("$exposure", identity, new JSONObject().put("flag_key", KEY).put("variant", variant.key));
return variant.value;
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
pending.cancel(true);
return "control";
} catch (Exception ex) {
pending.cancel(true);
LOG.warn("Experiment evaluation failed; using the report baseline");
return "control";
}
}
}Why. A fetch can return several deployment flags. Sending exposure only for the chosen report variant keeps other fetched flags out of this report's exposure measurement. The layout reads Variant.value; the exposure carries Variant.key, because experiment analysis uses variant keys.
This is a server decision boundary: the event records which experience the backend chose. If your experiment requires proof that a browser displayed the report, record exposure at the client display boundary with the same canonical identity instead of recording it in both places.
The 500 ms SDK attempt, zero retries and bounded future wait keep an evaluation failure from indefinitely blocking the report. Default, undeployed, missing or unfamiliar variants return the application baseline without claiming a successful control assignment. The logger suppresses raw upstream messages and identifiers.
4. Connect the flagged preview and export
The controller above injects the ReportPreview interface. This configuration supplies the preferred implementation while preserving the original controller, routes and analytics service. Merge additional agent configuration alongside it.
src/main/java/com/example/build/ReportFlagsConfiguration.java
package com.example.build;
import org.json.JSONObject;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.core.env.Environment;
@Configuration
@ConditionalOnProperty(name = "amplitude.flags-enabled", havingValue = "true")
public class ReportFlagsConfiguration {
@Bean ReportFlags reportFlags(Environment environment, AnalyticsService analytics) {
return new ReportFlags(environment.getRequiredProperty("amplitude.deployment-key"),
environment.getProperty("amplitude.server-zone", "US"),
environment.getProperty("amplitude.experiment-server-url", ""), analytics);
}
@Bean @Primary ReportPreview flaggedReportPreview(ReportFlags flags) {
return identity -> {
String layout = flags.chooseAndExpose(identity);
JSONObject report = new JSONObject().put("report_type", "usage").put("days", 7).put("layout", layout);
if ("treatment".equals(layout)) report.put("summary", "Your last seven days are ready to export.");
return report;
};
}
}The export endpoint records the outcome after writing its CSV. It uses the same request identity and never evaluates the flag again:
src/main/java/com/example/build/ReportExportController.java
package com.example.build;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
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 ReportExportController {
private final AnalyticsService analytics;
public ReportExportController(AnalyticsService analytics) { this.analytics = analytics; }
@PostMapping("/api/reports/export")
public void export(HttpServletRequest request, HttpServletResponse response) throws IOException {
response.setContentType("text/csv");
response.setHeader("Content-Disposition", "attachment; filename=\"usage.csv\"");
response.getOutputStream().write("report_type,days\nusage,7\n".getBytes(StandardCharsets.UTF_8));
analytics.report("Report Exported", RequestIdentity.from(request));
}
}Reuse the RequestIdentity, AnalyticsService and shutdown configuration included in step 2. The export request must carry the same authenticated user and original analytics device ID as the preview.
MCP. Use the Amplitude MCP server with your project to inspect the existing event taxonomy before adding another report conversion name.
5. Run the report journey
Set the two server keys and region, enable the flag beans, and start Spring Boot. Restart the process after changing deployment or client configuration.
Terminal, project root
export AMPLITUDE_API_KEY='your-project-api-key'
export AMPLITUDE_DEPLOYMENT_KEY='your-server-deployment-key'
export AMPLITUDE_SERVER_ZONE='US'
export AMPLITUDE_FLAGS_ENABLED='true'
mvn spring-boot:runUse the existing signed-in frontend or authenticated API client to preview the report and then export it. Preserve that client's normal authentication. For a baseline smoke check, an anonymous request needs only its analytics device ID:
Terminal, second window
curl -X POST http://127.0.0.1:8080/api/reports/preview \
-H 'X-Amplitude-Device-Id: java-report-smoke-device'
curl -X POST http://127.0.0.1:8080/api/reports/export \
-H 'X-Amplitude-Device-Id: java-report-smoke-device'An anonymous preview returns control without a remote fetch or exposure. That smoke check establishes route behavior; use a verified signed-in test account included in the flag to test treatment.
Verify it worked
- HTTP responses. With a verified treatment user, preview returns
layout: treatmentandsummary. A verified control user getslayout: control. Export returns the CSV with an attachment header. Anonymous requests keep the baseline. - Upload health. Check Data > Sources > Ingestion Debugger for successful or failed requests. Keep the application's callback drain on shutdown; queuing an event is different from Amplitude processing it.
- User profile. In Users > User Profiles, look up the same user ID. Inspect
Report Previewed,[Experiment] Assignment,[Experiment] Exposure, thenReport Exported. Exposure has[Experiment] Flag Key=report-summaryand[Experiment] Variant=controlortreatment. The application sends$exposure; Amplitude transforms its name and properties during ingestion. - Conversion identity. Verify
Report Exportedhasreport_type=usage,days=7,framework=springand the expected plan. Use[Experiment] report-summaryafter exposure to inspect the variant. Analytics events use the same canonical user/device identity as evaluation. Allow processing time before judging a newly sent conversion. - Failure behavior. Disable the flag or remove the user's inclusion and check the configured outcome. A failed fetch or unrecognized variant uses the baseline without an exposure. Stopping the app must retain the configured handler drain and callback wait.
If running an A/B experiment, configure its exposure event and success metric following the event-tracking guidance. Evaluate the experiment's analysis separately from this flag rollout check.
Troubleshooting
Every request uses the baseline
Check the enable switch, active flag, server deployment, exact key, project/region, verified identity and targeting. Anonymous users deliberately skip evaluation. Missing or default assignments do not count as successful control exposure.
Exposure appears for an unrelated flag
Keep automatic remote exposure disabled. This service emits only the selected report variant; another wrapper calling fetch with automatic exposure can still emit extra events.
An export appears under another user
Preserve the same authenticated user and original device ID on preview and export. Keep the principal-to-account validation shown in step 2; an arbitrary user-ID header is not authentication.
Client configuration changes have no effect
Restart the server after changing configuration for an initialized deployment. The SDK reuses the remote client associated with that deployment key for the process.
Evaluation warnings or slow previews
Check the deployment and network from the server. The sample gives each fetch a bounded attempt and uses the baseline on failure. Inspect safe diagnostics rather than logging full users, keys or upstream responses.
FAQ
Must I install analytics before using flags?
The Experiment JVM client fetches variants with a server deployment key and evaluation user. This tutorial also installs the Java Analytics SDK to send the selected report exposure and export conversion. Both packages and their initialization are included here.
Does the server deployment key replace the analytics key?
No. The experiment client uses the server deployment key for evaluation; the analytics client sends exposure and conversion events with the project API key. Both belong to the same project and region.
Why send exposure manually?
This report consumes one flag from a fetch that can contain several deployment flags. The service disables automatic remote exposure, then sends the defined $exposure event only for the report decision. Assignment tracking remains enabled.
Is the fallback a control assignment?
The application baseline has the control layout, but a failed or missing assignment is not a successful control evaluation. The service sends exposure for recognized deployed assignments with a usable key, including explicit control.
Does exporting fetch another variant?
No. Export records Report Exported with the request's current canonical identity. It does not make a second evaluation or exposure call.
Full code reference
Every project file is included below. Use these files for a new project; merge the same additions into an existing app while preserving its authentication, routes and other instrumentation.
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>com.amplitude</groupId>
<artifactId>experiment-jvm-server</artifactId>
<version>1.8.4</version>
</dependency>
<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/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}
amplitude.flags-enabled=${AMPLITUDE_FLAGS_ENABLED:false}
amplitude.deployment-key=${AMPLITUDE_DEPLOYMENT_KEY:}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/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;
import java.util.concurrent.atomic.AtomicInteger;
public final class AnalyticsService implements AutoCloseable {
private static final Logger LOG = LoggerFactory.getLogger(AnalyticsService.class);
private final Amplitude client;
private final AtomicInteger eventIds = new AtomicInteger();
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.eventId = eventIds.updateAndGet(id -> id == Integer.MAX_VALUE ? 0 : id + 1);
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/ReportFlags.java
package com.example.build;
import com.amplitude.experiment.Experiment;
import com.amplitude.experiment.ExperimentUser;
import com.amplitude.experiment.FetchOptions;
import com.amplitude.experiment.RemoteEvaluationClient;
import com.amplitude.experiment.RemoteEvaluationConfig;
import com.amplitude.experiment.ServerZone;
import com.amplitude.experiment.Variant;
import com.amplitude.experiment.util.LogLevel;
import com.amplitude.experiment.util.LoggerProvider;
import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Locale;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
public final class ReportFlags {
public static final String KEY = "report-summary";
private static final Logger LOG = LoggerFactory.getLogger(ReportFlags.class);
private final RemoteEvaluationClient client;
private final AnalyticsService analytics;
public ReportFlags(String key, String zone, String serverOverride, AnalyticsService analytics) {
if (key == null || key.isBlank()) throw new IllegalArgumentException("Set AMPLITUDE_DEPLOYMENT_KEY");
this.analytics = analytics;
ServerZone serverZone = switch (zone.toUpperCase(Locale.ROOT)) {
case "US" -> ServerZone.US;
case "EU" -> ServerZone.EU;
default -> throw new IllegalArgumentException("AMPLITUDE_SERVER_ZONE must be US or EU");
};
var config = RemoteEvaluationConfig.builder().serverZone(serverZone)
.fetchTimeoutMillis(500).fetchRetries(0).logLevel(LogLevel.ERROR)
.loggerProvider(new LoggerProvider() {
@Override public void verbose(String ignored) {}
@Override public void debug(String ignored) {}
@Override public void info(String ignored) {}
@Override public void warn(String ignored) { LOG.warn("Experiment SDK warning; inspect the deployment"); }
@Override public void error(String ignored) { LOG.warn("Experiment SDK error; inspect the deployment"); }
});
if (serverOverride != null && !serverOverride.isBlank()) config.serverUrl(serverOverride);
client = Experiment.initializeRemote(key, config.build());
}
// Call only where the report response chooses its experience, not on prefetch/export.
public String chooseAndExpose(RequestIdentity identity) {
if (identity.userId() == null) return "control";
var user = ExperimentUser.builder().userId(identity.userId()).deviceId(identity.deviceId());
if (identity.plan() != null) user.userProperty("plan", identity.plan());
var options = FetchOptions.builder().setTracksAssignment(true).setTracksExposure(false).build();
CompletableFuture<java.util.Map<String, Variant>> pending = client.fetch(user.build(), options);
try {
Variant variant = pending.get(650, TimeUnit.MILLISECONDS).get(KEY);
if (variant == null || (variant.metadata != null && (Boolean.TRUE.equals(variant.metadata.get("default"))
|| Boolean.FALSE.equals(variant.metadata.get("deployed"))))) return "control";
if (!"control".equals(variant.value) && !"treatment".equals(variant.value)) return "control";
if (variant.key == null || variant.key.isBlank()) return "control";
// Fetch can return other flags; expose only the experience this handler uses.
analytics.track("$exposure", identity, new JSONObject().put("flag_key", KEY).put("variant", variant.key));
return variant.value;
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
pending.cancel(true);
return "control";
} catch (Exception ex) {
pending.cancel(true);
LOG.warn("Experiment evaluation failed; using the report baseline");
return "control";
}
}
}src/main/java/com/example/build/ReportFlagsConfiguration.java
package com.example.build;
import org.json.JSONObject;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.core.env.Environment;
@Configuration
@ConditionalOnProperty(name = "amplitude.flags-enabled", havingValue = "true")
public class ReportFlagsConfiguration {
@Bean ReportFlags reportFlags(Environment environment, AnalyticsService analytics) {
return new ReportFlags(environment.getRequiredProperty("amplitude.deployment-key"),
environment.getProperty("amplitude.server-zone", "US"),
environment.getProperty("amplitude.experiment-server-url", ""), analytics);
}
@Bean @Primary ReportPreview flaggedReportPreview(ReportFlags flags) {
return identity -> {
String layout = flags.chooseAndExpose(identity);
JSONObject report = new JSONObject().put("report_type", "usage").put("days", 7).put("layout", layout);
if ("treatment".equals(layout)) report.put("summary", "Your last seven days are ready to export.");
return report;
};
}
}src/main/java/com/example/build/ReportExportController.java
package com.example.build;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
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 ReportExportController {
private final AnalyticsService analytics;
public ReportExportController(AnalyticsService analytics) { this.analytics = analytics; }
@PostMapping("/api/reports/export")
public void export(HttpServletRequest request, HttpServletResponse response) throws IOException {
response.setContentType("text/csv");
response.setHeader("Content-Disposition", "attachment; filename=\"usage.csv\"");
response.getOutputStream().write("report_type,days\nusage,7\n".getBytes(StandardCharsets.UTF_8));
analytics.report("Report Exported", RequestIdentity.from(request));
}
}Hand it to your agent
Coding-agent prompt
Add report feature flags to this Spring Boot app using https://amplitude.com/build/java-feature-flags. Start from this page's complete project files, dependencies and configuration. In an existing app, merge additions and preserve its verified authentication, shared identity, routes, analytics client, callback drain, shutdown order and any agent instrumentation.
Use Java 21/Spring Boot 3.5 and experiment-jvm-server 1.8.4. Add the dependency and explicit AMPLITUDE_FLAGS_ENABLED switch; keep both API keys server-side and align project/region. Initialize the remote client once for the server deployment. Build a fresh ExperimentUser from the verified servlet principal/account and original analytics device ID for each report preview.
Set a 500 ms fetch attempt with zero retries and a bounded 650 ms future wait. Request assignment tracking but disable automatic remote exposure. Read report-summary only, reject default/undeployed/unknown/missing-key variants, choose the layout from value, and send one $exposure with flag_key and the variant key through the existing AnalyticsService. Use the application baseline without exposure on failure or anonymous requests. Keep logging free of raw users, keys or upstream errors.
Supply the flagged ReportPreview with @Primary only when enabled. Keep the base controller and add POST /api/reports/export after writing its CSV. Preserve canonical identity and plan; do not evaluate again on export. Verify both assigned variants, processed exposure and export identity in Amplitude. Treat server choice separately from browser display. Restart after remote client config changes. Report every file changed.What this unlocks
Feature flags control the report layout for selected users while the baseline remains available on evaluation failure.
Experimentation compares Report Exported after the report exposure when you configure an experiment and its metric.
Product analytics connects report previews and exports with the same user properties as the rest of the backend.
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.