How to add product analytics to a Java Spring backend

Track report previews with the Amplitude Java SDK and keep each request's user identity on its event. I'll wire the client into Spring's lifecycle and wait for delivery callbacks during shutdown. Allow about 25 minutes.

  • Java 21
  • Spring Boot 3.5
  • Java SDK 1.x
  • 25 min

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

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

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

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

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

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

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

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

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

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

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.jar

Use 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

Terminal
curl -i -X POST http://127.0.0.1:8080/api/reports/preview \
  -H 'X-Amplitude-Device-Id: java-report-test-20261021'
  1. Confirm a 200 report response with report_type: usage and days: 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.
  2. In Amplitude, open Data > Sources > Ingestion Debugger and check request counts and errors. That view shows aggregates rather than individual events.
  3. Open Users > User Profiles, search for device ID java-report-test-20261021, and inspect Report Previewed in 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.
  4. 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.
  5. 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.

Full code reference
// 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

Using Cursor or Claude Code? Paste this prompt
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

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.

Ship the instrumentation, then read the data

Use the report events to see which users reach the preview and return to it.