- * At most {@code limit} bytes are buffered. A longer body is not inspected
- * ({@link #isTruncated()}): the buffered prefix is replayed and the rest is
- * streamed from the original request, so Flow still reads it unchanged while
- * the kit never holds more than {@code limit} bytes of it.
- */
-final class CachedBodyHttpServletRequest extends HttpServletRequestWrapper {
-
- private final byte[] body;
- private final boolean truncated;
- private ServletInputStream overflowStream;
-
- CachedBodyHttpServletRequest(HttpServletRequest request, int limit)
- throws IOException {
- super(request);
- // readNBytes grows its buffer as bytes arrive, so a forged
- // Content-Length cannot make it allocate up front.
- byte[] read = request.getInputStream().readNBytes(limit + 1);
- this.truncated = read.length > limit;
- this.body = read;
- }
-
- /**
- * Whether the body exceeded the buffering limit; the cached body is then
- * only a prefix and must not be inspected.
- *
- * @return {@code true} if the body was longer than the limit
- */
- boolean isTruncated() {
- return truncated;
- }
-
- String getCachedBody() {
- return new String(body, charset());
- }
-
- private Charset charset() {
- String enc = getCharacterEncoding();
- if (enc != null) {
- try {
- return Charset.forName(enc);
- } catch (RuntimeException unsupported) {
- // fall through to default
- }
- }
- return StandardCharsets.UTF_8;
- }
-
- @Override
- public ServletInputStream getInputStream() throws IOException {
- if (truncated) {
- // The rest of the body can only be read once from the original
- // stream, so hand out one stream that continues where the
- // buffered prefix ends.
- if (overflowStream == null) {
- overflowStream = new PrefixedInputStream(body,
- super.getInputStream());
- }
- return overflowStream;
- }
- ByteArrayInputStream buffer = new ByteArrayInputStream(body);
- return new ServletInputStream() {
- @Override
- public int read() {
- return buffer.read();
- }
-
- @Override
- public boolean isFinished() {
- return buffer.available() == 0;
- }
-
- @Override
- public boolean isReady() {
- return true;
- }
-
- @Override
- public void setReadListener(ReadListener readListener) {
- // synchronous replay only; not used for async reads
- }
- };
- }
-
- @Override
- public BufferedReader getReader() throws IOException {
- return new BufferedReader(
- new InputStreamReader(getInputStream(), charset()));
- }
-
- /**
- * Replays an already-read prefix, then continues with the remainder of the
- * original request stream.
- */
- private static final class PrefixedInputStream extends ServletInputStream {
-
- private final ByteArrayInputStream prefix;
- private final ServletInputStream rest;
-
- PrefixedInputStream(byte[] prefix, ServletInputStream rest) {
- this.prefix = new ByteArrayInputStream(prefix);
- this.rest = rest;
- }
-
- @Override
- public int read() throws IOException {
- int b = prefix.read();
- return b != -1 ? b : rest.read();
- }
-
- @Override
- public int read(byte[] b, int off, int len) throws IOException {
- if (len == 0) {
- return 0;
- }
- if (prefix.available() > 0) {
- return prefix.read(b, off, len);
- }
- return rest.read(b, off, len);
- }
-
- @Override
- public boolean isFinished() {
- return prefix.available() == 0 && rest.isFinished();
- }
-
- @Override
- public boolean isReady() {
- return prefix.available() > 0 || rest.isReady();
- }
-
- @Override
- public void setReadListener(ReadListener readListener) {
- // synchronous replay only; not used for async reads
- }
- }
-}
diff --git a/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/MeterNames.java b/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/MeterNames.java
index ff5994f1..1f07e8d1 100644
--- a/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/MeterNames.java
+++ b/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/MeterNames.java
@@ -392,9 +392,9 @@ public final class MeterNames {
public static final String TAG_TYPE = "type";
/**
- * Counter: UIDL message recovery events observed on incoming requests.
- * Tagged by {@link #TAG_TYPE} with {@link #RESYNC_TYPE_RESEND} or
- * {@link #RESYNC_TYPE_RESYNC}.
+ * Counter: client message recovery events. Tagged by {@link #TAG_TYPE} with
+ * {@link #RESYNC_TYPE_RESEND}, {@link #RESYNC_TYPE_RESYNC} or
+ * {@link #RESYNC_TYPE_OUT_OF_SYNC}.
*/
public static final String RESYNC = "vaadin.resync";
@@ -412,6 +412,13 @@ public final class MeterNames {
*/
public static final String RESYNC_TYPE_RESYNC = "resync";
+ /**
+ * {@link #TAG_TYPE} value for a message with an id the server did not
+ * expect; the message is not processed and the user is shown the session
+ * synchronization error.
+ */
+ public static final String RESYNC_TYPE_OUT_OF_SYNC = "out_of_sync";
+
private MeterNames() {
}
}
diff --git a/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/MetricsServiceInitListener.java b/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/MetricsServiceInitListener.java
index 48433e17..df01014b 100644
--- a/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/MetricsServiceInitListener.java
+++ b/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/MetricsServiceInitListener.java
@@ -255,6 +255,10 @@ void bind(ServiceInitEvent event, MeterRegistry registry,
.register(service.getEventBus());
}
+ if (settings.isResync()) {
+ new ResyncMetricsBinder(registry).register(service.getEventBus());
+ }
+
// Browser errors are counted by the client collector and described
// here: the message, the script and the first stack frame are what a
// counter cannot hold, and they are the difference between knowing
diff --git a/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/ObservabilitySettings.java b/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/ObservabilitySettings.java
index 26986242..902bc387 100644
--- a/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/ObservabilitySettings.java
+++ b/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/ObservabilitySettings.java
@@ -135,7 +135,10 @@ public boolean isClient() {
return client;
}
- /** Whether to observe UIDL message resends and resynchronizations. */
+ /**
+ * Whether to count client message resends, resynchronizations and
+ * unexpected message ids.
+ */
public boolean isResync() {
return resync;
}
diff --git a/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/ResyncDetectionFilter.java b/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/ResyncDetectionFilter.java
deleted file mode 100644
index ab1c9ffa..00000000
--- a/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/ResyncDetectionFilter.java
+++ /dev/null
@@ -1,103 +0,0 @@
-/**
- * Copyright (C) 2000-2026 Vaadin Ltd
- *
- * This program is available under Vaadin Commercial License and Service Terms.
- *
- * See
- * Registering it in a plain servlet app. There is no auto-configuration
- * outside Spring, so register the filter yourself against every request. It
- * should run early (before anything else consumes the request body). For
- * example, in a {@code ServletContextListener} or
- * {@code ServletContainerInitializer}:
- *
- *
- * Flow recovers from lost responses entirely inside {@link UidlRequestHandler}
- * by catching {@code ClientResentPayloadException} (replay the cached response)
- * and {@code ResynchronizationRequiredException} (rebuild the UI state);
- * neither surfaces to any Flow listener SPI the kit uses. This filter
- * reconstructs the same signal by buffering the UIDL body (via
- * {@link CachedBodyHttpServletRequest} so Flow can still read it) and handing
- * it to a {@link ResyncInspector}.
- *
- * Because it has no Spring APIs available it buffers the body eagerly, up to
- * {@link ResyncInspector#MAX_INSPECTED_BODY_BYTES} (a longer body is passed on
- * uninspected); the Spring variant instead captures the bytes lazily as Flow
- * reads them and only counts requests Flow actually consumed. Instrumentation
- * never fails the request: any error while inspecting is swallowed.
- */
-public final class ResyncDetectionFilter implements Filter {
-
- private final ResyncInspector inspector;
-
- /**
- * Creates the filter recording into the given registry.
- *
- * @param registry
- * the meter registry, not {@code null}
- */
- public ResyncDetectionFilter(MeterRegistry registry) {
- this.inspector = new ResyncInspector(registry);
- }
-
- @Override
- public void doFilter(ServletRequest request, ServletResponse response,
- FilterChain chain) throws IOException, ServletException {
- if (!(request instanceof HttpServletRequest http)
- || !ResyncInspector.isUidl(http)) {
- chain.doFilter(request, response);
- return;
- }
-
- CachedBodyHttpServletRequest wrapped = new CachedBodyHttpServletRequest(
- http, ResyncInspector.MAX_INSPECTED_BODY_BYTES);
- if (wrapped.isTruncated()) {
- // Too large to buffer for inspection; let Flow handle it as usual.
- chain.doFilter(wrapped, response);
- return;
- }
- try {
- HttpSession session = wrapped.getSession(false);
- Object mutex = session != null ? session : this;
- inspector.inspect(wrapped.getCachedBody(), session,
- ResyncInspector.uiId(wrapped), mutex);
- } catch (RuntimeException instrumentationFailure) {
- // Never break a request because of observability.
- }
- chain.doFilter(wrapped, response);
- }
-}
diff --git a/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/ResyncDetector.java b/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/ResyncDetector.java
deleted file mode 100644
index dafa22db..00000000
--- a/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/ResyncDetector.java
+++ /dev/null
@@ -1,154 +0,0 @@
-/**
- * Copyright (C) 2000-2026 Vaadin Ltd
- *
- * This program is available under Vaadin Commercial License and Service Terms.
- *
- * See
- * The classification mirrors Flow's own server-side logic in
- * {@code ServerRpcHandler#handleRpc}, which is otherwise invisible to the kit
- * because the relevant {@code ClientResentPayloadException} and
- * {@code ResynchronizationRequiredException} are caught internally by
- * {@code UidlRequestHandler} and never surface to a {@code
- * VaadinRequestInterceptor}:
- *
- * This is an approximation of Flow's own test. Flow only replays a cached
- * response when {@code clientId == lastProcessedClientToServerId} and
- * the message hash matches, discarding still-older ids as out-of-order. The
- * message hash is not exposed to the kit, so we cannot discriminate on it and
- * count every {@code clientId <= previous} as a resend; this may over-report
- * relative to what Flow actually replays.
- *
- * The detector is stateless: the caller supplies the previously seen
- * {@code clientId} and stores the {@link Result#lastClientId()} returned here
- * (e.g. as an HTTP session attribute keyed by UI id), so there is no unbounded
- * per-session state held inside the kit.
- */
-public final class ResyncDetector {
-
- /** No {@code clientId} has been seen yet for a UI. */
- public static final int NO_CLIENT_ID = -1;
-
- /** Classification of a single UIDL request. */
- public enum Kind {
- /** An ordinary, in-order message. */
- NORMAL,
- /** A duplicate the client re-sent; the server replays its response. */
- RESEND,
- /** A client-requested full UI-state resynchronization. */
- RESYNC
- }
-
- /**
- * Result of classifying a request.
- *
- * @param kind
- * the classification
- * @param lastClientId
- * the {@code clientId} the caller should remember for this UI
- * for the next request (unchanged from the input for a resend)
- */
- public record Result(Kind kind, int lastClientId) {
- }
-
- private final MeterRegistry registry;
-
- /**
- * Creates a detector recording into the given registry.
- *
- * @param registry
- * the meter registry, not {@code null}
- */
- public ResyncDetector(MeterRegistry registry) {
- this.registry = registry;
- }
-
- /**
- * Classifies a UIDL request body and records a counter for resend/resync
- * events.
- *
- * @param requestBody
- * the raw UIDL request body (JSON); may be {@code null} or empty
- * @param previousClientId
- * the last {@code clientId} seen for this UI, or
- * {@link #NO_CLIENT_ID} if none
- * @return the classification and the {@code clientId} to remember next
- */
- public Result inspect(String requestBody, int previousClientId) {
- Result result = classify(requestBody, previousClientId);
- switch (result.kind()) {
- case RESEND -> registry.counter(MeterNames.RESYNC, MeterNames.TAG_TYPE,
- MeterNames.RESYNC_TYPE_RESEND).increment();
- case RESYNC -> registry.counter(MeterNames.RESYNC, MeterNames.TAG_TYPE,
- MeterNames.RESYNC_TYPE_RESYNC).increment();
- default -> {
- // NORMAL: nothing to record
- }
- }
- return result;
- }
-
- private static Result classify(String requestBody, int previousClientId) {
- if (requestBody == null || requestBody.isBlank()) {
- return new Result(Kind.NORMAL, previousClientId);
- }
- JsonNode json;
- try {
- json = JacksonUtils.readTree(requestBody);
- } catch (RuntimeException malformed) {
- // Not our concern to validate the payload; let Flow handle it.
- return new Result(Kind.NORMAL, previousClientId);
- }
- if (json == null) {
- return new Result(Kind.NORMAL, previousClientId);
- }
-
- int clientId = json.has(ApplicationConstants.CLIENT_TO_SERVER_ID)
- ? json.get(ApplicationConstants.CLIENT_TO_SERVER_ID).intValue()
- : NO_CLIENT_ID;
-
- // Resync takes precedence: such a request also carries a normally
- // advancing clientId, so it must be classified before the comparison.
- if (json.has(ApplicationConstants.RESYNCHRONIZE_ID) && json
- .get(ApplicationConstants.RESYNCHRONIZE_ID).booleanValue()) {
- int next = clientId >= 0 ? Math.max(previousClientId, clientId)
- : previousClientId;
- return new Result(Kind.RESYNC, next);
- }
-
- if (clientId < 0) {
- return new Result(Kind.NORMAL, previousClientId);
- }
- if (previousClientId != NO_CLIENT_ID && clientId <= previousClientId) {
- // The client re-sent a message id the server already advanced past;
- // do not advance the remembered id.
- return new Result(Kind.RESEND, previousClientId);
- }
- return new Result(Kind.NORMAL, clientId);
- }
-}
diff --git a/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/ResyncInspector.java b/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/ResyncInspector.java
deleted file mode 100644
index 05dfb30f..00000000
--- a/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/ResyncInspector.java
+++ /dev/null
@@ -1,126 +0,0 @@
-/**
- * Copyright (C) 2000-2026 Vaadin Ltd
- *
- * This program is available under Vaadin Commercial License and Service Terms.
- *
- * See
- * This class is deliberately framework-agnostic (servlet API only) so it can
- * back both the portable {@link ResyncDetectionFilter} and Spring-specific
- * filters. It holds no state of its own; the {@code clientId} history lives in
- * the session, keyed by UI id, so it is bounded by and cleaned up with the
- * session.
- */
-public final class ResyncInspector {
-
- /**
- * The largest UIDL body, in bytes, the detection filters buffer for
- * inspection. A longer body is passed on to Flow untouched but not
- * classified: buffering it without a bound would let any client (the
- * filters run before Flow and security, and a session is optional) make the
- * server hold arbitrarily large copies of a request, and inspecting only a
- * prefix could miss the {@code clientId}/{@code resynchronize} fields,
- * which the UIDL JSON places after the potentially large {@code rpc} array.
- * Regular UIDL messages are a few kilobytes, so this only skips the rare
- * huge one.
- */
- public static final int MAX_INSPECTED_BODY_BYTES = 1024 * 1024;
-
- private static final String LAST_CLIENT_ID_ATTR_PREFIX = ResyncInspector.class
- .getName() + ".lastClientId.";
-
- private final ResyncDetector detector;
-
- /**
- * Creates an inspector recording into the given registry.
- *
- * @param registry
- * the meter registry, not {@code null}
- */
- public ResyncInspector(MeterRegistry registry) {
- this.detector = new ResyncDetector(registry);
- }
-
- /**
- * A UIDL request is a POST whose query string carries {@code v-r=uidl}.
- * Checking the query string (rather than {@code getParameter}) avoids
- * triggering body parsing on the original request.
- *
- * @param request
- * the request to test
- * @return {@code true} if this is a UIDL POST
- */
- public static boolean isUidl(HttpServletRequest request) {
- if (!"POST".equalsIgnoreCase(request.getMethod())) {
- return false;
- }
- String query = request.getQueryString();
- return query != null
- && query.contains(ApplicationConstants.REQUEST_TYPE_PARAMETER
- + "=" + ApplicationConstants.REQUEST_TYPE_UIDL);
- }
-
- /**
- * Returns the UI id carried by the request, or {@code "-"} when absent.
- *
- * @param request
- * the request
- * @return the UI id, never {@code null}
- */
- public static String uiId(HttpServletRequest request) {
- String id = request.getParameter(ApplicationConstants.UI_ID_PARAMETER);
- return id != null ? id : "-";
- }
-
- /**
- * Classifies a UIDL body against the {@code clientId} last seen for the
- * same UI, records a counter for resend/resync, and stores the new
- * {@code clientId} back on the session.
- *
- * The read-modify-write on the session attribute is guarded by
- * {@code mutex}: a resend can overlap the original request for the same UI
- * (both reach the filter before Flow's per-session lock), and without the
- * guard the two could clobber each other's stored {@code clientId}.
- *
- * @param body
- * the UIDL request body (JSON); may be {@code null}/empty
- * @param session
- * the HTTP session holding per-UI state, or {@code null}
- * @param uiId
- * the UI id used to key the session attribute
- * @param mutex
- * the monitor to guard the read-modify-write on
- */
- public void inspect(String body, HttpSession session, String uiId,
- Object mutex) {
- String attr = LAST_CLIENT_ID_ATTR_PREFIX + uiId;
- synchronized (mutex) {
- int previous = ResyncDetector.NO_CLIENT_ID;
- if (session != null
- && session.getAttribute(attr) instanceof Integer stored) {
- previous = stored;
- }
-
- ResyncDetector.Result result = detector.inspect(body, previous);
-
- if (session != null) {
- session.setAttribute(attr, result.lastClientId());
- }
- }
- }
-}
diff --git a/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/ResyncMetricsBinder.java b/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/ResyncMetricsBinder.java
new file mode 100644
index 00000000..563bd432
--- /dev/null
+++ b/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/ResyncMetricsBinder.java
@@ -0,0 +1,78 @@
+/**
+ * Copyright (C) 2000-2026 Vaadin Ltd
+ *
+ * This program is available under Vaadin Commercial License and Service Terms.
+ *
+ * See
+ * Flow fires the events while handling the client's message ids, for both HTTP
+ * and push requests: a re-sent message it answers with the previous response
+ * ({@link MeterNames#RESYNC_TYPE_RESEND}), a resynchronization the client asked
+ * for ({@link MeterNames#RESYNC_TYPE_RESYNC}), and an unexpected message id
+ * that shows the user the session synchronization error
+ * ({@link MeterNames#RESYNC_TYPE_OUT_OF_SYNC}). The counters are registered up
+ * front, so each type reports 0 before its first event instead of no data.
+ */
+final class ResyncMetricsBinder {
+
+ private final Counter resent;
+ private final Counter resynchronized;
+ private final Counter outOfSync;
+
+ ResyncMetricsBinder(MeterRegistry registry) {
+ this.resent = counter(registry, MeterNames.RESYNC_TYPE_RESEND);
+ this.resynchronized = counter(registry, MeterNames.RESYNC_TYPE_RESYNC);
+ this.outOfSync = counter(registry, MeterNames.RESYNC_TYPE_OUT_OF_SYNC);
+ }
+
+ /**
+ * Subscribes to the message recovery events on the given bus.
+ *
+ * @param eventBus
+ * the service event bus to listen on
+ * @return a handle removing every subscription made here
+ */
+ Registration register(VaadinServiceEventBus eventBus) {
+ return Registration.combine(
+ eventBus.addListener(ClientMessageResentEvent.class,
+ this::messageResent),
+ eventBus.addListener(UIResynchronizationEvent.class,
+ this::uiResynchronized),
+ eventBus.addListener(MessageIdSyncErrorEvent.class,
+ this::messageIdSyncError));
+ }
+
+ void messageResent(ClientMessageResentEvent event) {
+ resent.increment();
+ }
+
+ void uiResynchronized(UIResynchronizationEvent event) {
+ resynchronized.increment();
+ }
+
+ void messageIdSyncError(MessageIdSyncErrorEvent event) {
+ outOfSync.increment();
+ }
+
+ private static Counter counter(MeterRegistry registry, String type) {
+ return Counter.builder(MeterNames.RESYNC).tag(MeterNames.TAG_TYPE, type)
+ .register(registry);
+ }
+}
diff --git a/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/client/MetricsCollectorElement.java b/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/client/MetricsCollectorElement.java
index 3ab0565b..dfaf38e0 100644
--- a/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/client/MetricsCollectorElement.java
+++ b/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/client/MetricsCollectorElement.java
@@ -76,7 +76,15 @@ public MetricsCollectorElement(ClientMetricsBinder binder,
addDetachListener(event -> {
UI ui = event.getUI();
if (ui != null && !ui.isClosing()) {
- ui.access(() -> ui.add(this));
+ // A resynchronization detaches and re-attaches in place:
+ // adding the element again would move it, detach it once more
+ // and loop forever. Checking attachment rather than the parent
+ // also re-adds it when a layout around it was removed.
+ ui.access(() -> {
+ if (!isAttached()) {
+ ui.add(this);
+ }
+ });
}
});
}
diff --git a/observability-kit-micrometer/src/test/java/com/vaadin/observability/micrometer/CachedBodyHttpServletRequestTest.java b/observability-kit-micrometer/src/test/java/com/vaadin/observability/micrometer/CachedBodyHttpServletRequestTest.java
deleted file mode 100644
index a289e48e..00000000
--- a/observability-kit-micrometer/src/test/java/com/vaadin/observability/micrometer/CachedBodyHttpServletRequestTest.java
+++ /dev/null
@@ -1,88 +0,0 @@
-/**
- * Copyright (C) 2000-2026 Vaadin Ltd
- *
- * This program is available under Vaadin Commercial License and Service Terms.
- *
- * See
* This class is declared {@code public} so it can be reused by both
* {@link ObservabilityConfiguration} (plain-Spring import) and the Boot
@@ -36,6 +39,16 @@
public final class SpringMetricsServiceInitListener
extends MetricsServiceInitListener {
+ /**
+ * Whether spring-web, which {@link SpringHttpObservationEnricher} links
+ * against, is on the classpath. Neither the starter nor vaadin-spring bring
+ * it at runtime, and a missing class would surface as a
+ * {@link NoClassDefFoundError} on the first request.
+ */
+ private static final boolean SPRING_WEB_PRESENT = ClassUtils.isPresent(
+ "org.springframework.web.filter.ServerHttpObservationFilter",
+ SpringMetricsServiceInitListener.class.getClassLoader());
+
/**
* Creates a new listener.
*
@@ -65,18 +78,24 @@ protected void installDefaultObservationHandlers(
@Override
protected void enrichHttpObservation(VaadinRequest request,
String requestType) {
- SpringHttpObservationEnricher.enrich(request, requestType);
+ if (SPRING_WEB_PRESENT) {
+ SpringHttpObservationEnricher.enrich(request, requestType);
+ }
}
@Override
protected void enrichHttpObservationRoute(VaadinRequest request,
String routeTemplate) {
- SpringHttpObservationEnricher.route(request, routeTemplate);
+ if (SPRING_WEB_PRESENT) {
+ SpringHttpObservationEnricher.route(request, routeTemplate);
+ }
}
@Override
protected void markHttpObservationError(VaadinRequest request,
Throwable failure) {
- SpringHttpObservationEnricher.error(request, failure);
+ if (SPRING_WEB_PRESENT) {
+ SpringHttpObservationEnricher.error(request, failure);
+ }
}
}
diff --git a/observability-kit-spring/src/main/java/com/vaadin/observability/spring/SpringResyncDetectionFilter.java b/observability-kit-spring/src/main/java/com/vaadin/observability/spring/SpringResyncDetectionFilter.java
deleted file mode 100644
index d443e577..00000000
--- a/observability-kit-spring/src/main/java/com/vaadin/observability/spring/SpringResyncDetectionFilter.java
+++ /dev/null
@@ -1,140 +0,0 @@
-/**
- * Copyright (C) 2000-2026 Vaadin Ltd
- *
- * This program is available under Vaadin Commercial License and Service Terms.
- *
- * See
- * Flow recovers from lost responses entirely inside {@link UidlRequestHandler}
- * by catching {@code ClientResentPayloadException} (replay the cached response)
- * and {@code ResynchronizationRequiredException} (rebuild the UI state);
- * neither surfaces to any Flow listener SPI the kit uses.
- *
- * Rather than buffer the body eagerly, this filter wraps the request in a
- * {@link ContentCachingRequestWrapper} and inspects after the chain
- * has run: ordering is irrelevant for a counter, and this way we read exactly
- * the bytes Flow read and only count requests Flow actually consumed. The
- * per-UI read-modify-write is guarded by the session mutex. Instrumentation
- * never fails the request: any error while inspecting is swallowed.
- */
-public final class SpringResyncDetectionFilter extends OncePerRequestFilter {
-
- private final ResyncInspector inspector;
-
- /**
- * Creates the filter recording into the given registry.
- *
- * @param registry
- * the meter registry, not {@code null}
- */
- public SpringResyncDetectionFilter(MeterRegistry registry) {
- this.inspector = new ResyncInspector(registry);
- }
-
- @Override
- protected void doFilterInternal(HttpServletRequest request,
- HttpServletResponse response, FilterChain chain)
- throws ServletException, IOException {
- if (!ResyncInspector.isUidl(request)) {
- chain.doFilter(request, response);
- return;
- }
-
- // Bounded cache: Spring sizes its first buffer from the client-sent
- // Content-Length (capped by this limit), so an unbounded limit would
- // let a forged header allocate gigabytes. A body over the limit is
- // not inspected, as a truncated one could hide the resync/clientId
- // fields, which the UIDL JSON places after the (potentially large)
- // rpc array.
- LimitedCachingRequestWrapper wrapped = new LimitedCachingRequestWrapper(
- request, ResyncInspector.MAX_INSPECTED_BODY_BYTES);
- try {
- chain.doFilter(wrapped, response);
- } finally {
- try {
- inspect(wrapped);
- } catch (RuntimeException instrumentationFailure) {
- // Never break a request because of observability.
- }
- }
- }
-
- private void inspect(LimitedCachingRequestWrapper request) {
- if (request.overflowed) {
- // Only a prefix was cached; nothing reliable to classify.
- return;
- }
- byte[] cached = request.getContentAsByteArray();
- if (cached.length == 0) {
- // Flow never read the body; nothing to classify.
- return;
- }
- String body = new String(cached, charset(request));
-
- HttpSession session = request.getSession(false);
- Object mutex = session != null ? WebUtils.getSessionMutex(session)
- : this;
- inspector.inspect(body, session, ResyncInspector.uiId(request), mutex);
- }
-
- private static Charset charset(HttpServletRequest request) {
- String enc = request.getCharacterEncoding();
- if (enc != null) {
- try {
- return Charset.forName(enc);
- } catch (RuntimeException unsupported) {
- // fall through to default
- }
- }
- return StandardCharsets.UTF_8;
- }
-
- /**
- * Caching wrapper that remembers whether the body outgrew the cache limit.
- * Spring stops caching at the limit but keeps passing the bytes through, so
- * Flow still reads the whole body.
- */
- private static final class LimitedCachingRequestWrapper
- extends ContentCachingRequestWrapper {
-
- private boolean overflowed;
-
- LimitedCachingRequestWrapper(HttpServletRequest request, int limit) {
- super(request, limit);
- }
-
- @Override
- protected void handleContentOverflow(int contentCacheLimit) {
- overflowed = true;
- }
- }
-}
diff --git a/observability-kit-spring/src/test/java/com/vaadin/observability/spring/SpringMetricsServiceInitListenerTest.java b/observability-kit-spring/src/test/java/com/vaadin/observability/spring/SpringMetricsServiceInitListenerTest.java
new file mode 100644
index 00000000..a79ba037
--- /dev/null
+++ b/observability-kit-spring/src/test/java/com/vaadin/observability/spring/SpringMetricsServiceInitListenerTest.java
@@ -0,0 +1,143 @@
+/**
+ * Copyright (C) 2026 Vaadin Ltd
+ *
+ * This program is available under Vaadin Commercial License and Service Terms.
+ *
+ * See
- * Flow's own resend/resync recovery is invisible to listener SPIs (it is caught
- * internally in {@code UidlRequestHandler}). The kit reconstructs the signal in
- * {@code ResyncDetectionFilter} by inspecting the UIDL body, so this test
- * replays forged UIDL POSTs through the real servlet filter chain: the filter
- * wraps each request, lets Flow read the body, then classifies and counts from
- * the bytes Flow consumed. A real browser load first establishes the HTTP
- * session whose cookie the replayed requests reuse, so the filter's per-UI
- * {@code clientId} state persists across the duplicate.
+ * Flow only fires the events for messages it accepts: the UI must exist and the
+ * CSRF token must match. So the test records the UIDL messages the real client
+ * sends, stops the client from sending more once a click has been answered, and
+ * replays that last message from the page, as is, and with the message id
+ * changed:
+ *
* The Micrometer counter {@code vaadin.resync} is exposed by Prometheus as
@@ -46,8 +43,54 @@
*/
public class ResyncMetricsIT extends AbstractIT {
- /** A UI id unlikely to collide with the browser's own UIDL traffic. */
- private static final String UI_ID = "999";
+ private static final Duration TIMEOUT = Duration.ofSeconds(20);
+
+ /**
+ * Records every UIDL message the client sends, and drops the ones sent
+ * after {@code blocked} is set so the server's last processed message id
+ * stays put while the test replays.
+ */
+ private static final String RECORD_UIDL = """
+ window.__uidl = { sent: [], blocked: false };
+ const open = XMLHttpRequest.prototype.open;
+ const send = XMLHttpRequest.prototype.send;
+ XMLHttpRequest.prototype.open = function (method, url) {
+ this.__url = String(url);
+ return open.apply(this, arguments);
+ };
+ XMLHttpRequest.prototype.send = function (body) {
+ if (this.__url && this.__url.indexOf('v-r=uidl') >= 0) {
+ if (window.__uidl.blocked) {
+ return;
+ }
+ const entry = { url: this.__url, body: body, done: false };
+ window.__uidl.sent.push(entry);
+ this.addEventListener('loadend', () => entry.done = true);
+ }
+ return send.apply(this, arguments);
+ };
+ """;
+
+ /**
+ * Replays the last recorded message as is (a resend), with a message id the
+ * server does not expect (out of sync), and with the next message id and
+ * the resynchronize flag (a resync).
+ */
+ private static final String REPLAY_UIDL = """
+ const done = arguments[arguments.length - 1];
+ const last = window.__uidl.sent[window.__uidl.sent.length - 1];
+ const message = JSON.parse(last.body);
+ const post = body => fetch(last.url, { method: 'POST',
+ headers: { 'Content-Type': 'application/json; charset=UTF-8' },
+ body: body });
+ post(last.body)
+ .then(() => post(JSON.stringify(
+ Object.assign({}, message, { clientId: 99999 }))))
+ .then(() => post(JSON.stringify(Object.assign({}, message,
+ { clientId: message.clientId + 1, rpc: [],
+ resynchronize: true }))))
+ .then(() => done(true), () => done(false));
+ """;
@Override
protected String getTestPath() {
@@ -55,78 +98,47 @@ protected String getTestPath() {
}
@BrowserTest
- public void resendAndResyncAreCountedAndExported() throws IOException {
- // Ensure the app has loaded and a server session exists.
+ public void resendResyncAndOutOfSyncAreCountedAndExported()
+ throws IOException {
SpanElement greeting = $(SpanElement.class).id("greeting");
assertThat(greeting.getText()).isEqualTo("Hello micrometer boot");
- String sessionCookie = jsessionId();
- assertThat(sessionCookie).as("JSESSIONID cookie from the page load")
- .isNotNull();
-
- String uidlUrl = getRootURL() + "/?v-r=uidl&v-uiId=" + UI_ID;
-
- // 1) baseline message establishes the last seen clientId for this UI
- postUidl(uidlUrl, sessionCookie, uidlBody(10, false));
- // 2) same clientId again: the client re-sent a message the server
- // already processed -> resend
- postUidl(uidlUrl, sessionCookie, uidlBody(10, false));
- // 3) advancing clientId carrying the resynchronize flag -> resync
- postUidl(uidlUrl, sessionCookie, uidlBody(11, true));
-
- String prometheus = fetchPrometheus();
+ JavascriptExecutor js = (JavascriptExecutor) getDriver();
+ js.executeScript(RECORD_UIDL);
+
+ $(NativeButtonElement.class).id("bump").click();
+ waitUntil(driver -> "1"
+ .equals($(SpanElement.class).id("clicks").getText()));
+
+ // A dropped message never completes, so Flow never reports itself
+ // idle again: stop waiting for it before blocking the client.
+ testBench().disableWaitForVaadin();
+ js.executeScript("window.__uidl.blocked = true;");
+ waitUntil(driver -> Boolean.TRUE.equals(js.executeScript(
+ "const s = window.__uidl.sent; return s.length > 0 && s.every(e => e.done);")));
+
+ assertThat(js.executeAsyncScript(REPLAY_UIDL))
+ .as("the replayed UIDL requests should complete")
+ .isEqualTo(Boolean.TRUE);
+
+ String prometheus = awaitPrometheus(
+ body -> prometheusValue(body, "vaadin_resync_total",
+ "type=\"resend\"") >= 1.0
+ && prometheusValue(body, "vaadin_resync_total",
+ "type=\"out_of_sync\"") >= 1.0
+ && prometheusValue(body, "vaadin_resync_total",
+ "type=\"resync\"") >= 1.0,
+ "a resend, an out-of-sync message and a resync", TIMEOUT);
assertThat(prometheusValue(prometheus, "vaadin_resync_total",
"type=\"resend\"")).as("vaadin_resync_total{type=\"resend\"}")
.isGreaterThanOrEqualTo(1.0);
+ assertThat(prometheusValue(prometheus, "vaadin_resync_total",
+ "type=\"out_of_sync\""))
+ .as("vaadin_resync_total{type=\"out_of_sync\"}")
+ .isGreaterThanOrEqualTo(1.0);
assertThat(prometheusValue(prometheus, "vaadin_resync_total",
"type=\"resync\"")).as("vaadin_resync_total{type=\"resync\"}")
.isGreaterThanOrEqualTo(1.0);
}
-
- private String jsessionId() {
- Cookie cookie = getDriver().manage().getCookieNamed("JSESSIONID");
- return cookie != null ? cookie.getValue() : null;
- }
-
- private static String uidlBody(int clientId, boolean resync) {
- String base = "{\"csrfToken\":\"x\",\"rpc\":[],\"syncId\":0,\"clientId\":"
- + clientId;
- return resync ? base + ",\"resynchronize\":true}" : base + "}";
- }
-
- private static void postUidl(String url, String jsessionId, String body)
- throws IOException {
- HttpURLConnection conn = (HttpURLConnection) URI.create(url).toURL()
- .openConnection();
- conn.setRequestMethod("POST");
- conn.setDoOutput(true);
- conn.setRequestProperty("Content-Type",
- "application/json; charset=UTF-8");
- conn.setRequestProperty("Cookie", "JSESSIONID=" + jsessionId);
- try (OutputStream out = conn.getOutputStream()) {
- out.write(body.getBytes(StandardCharsets.UTF_8));
- }
- // The forged UIDL is rejected by Flow (bad CSRF / no matching UI), but
- // the filter has already counted it; the response status is irrelevant.
- conn.getResponseCode();
- conn.disconnect();
- }
-
- private String fetchPrometheus() throws IOException {
- HttpURLConnection conn = (HttpURLConnection) URI
- .create(getRootURL() + "/actuator/prometheus").toURL()
- .openConnection();
- conn.setRequestMethod("GET");
- assertThat(conn.getResponseCode()).isEqualTo(200);
- StringBuilder out = new StringBuilder();
- try (BufferedReader reader = new BufferedReader(new InputStreamReader(
- conn.getInputStream(), StandardCharsets.UTF_8))) {
- String line;
- while ((line = reader.readLine()) != null) {
- out.append(line).append('\n');
- }
- }
- return out.toString();
- }
}
{@code
- * FilterRegistration.Dynamic reg = servletContext.addFilter(
- * "vaadinResyncDetection", new ResyncDetectionFilter(meterRegistry));
- * // isMatchAfter=false so it precedes already-registered filters
- * reg.addMappingForUrlPatterns(null, false, "/*");
- * }
- *
- * or the equivalent {@code
- *
- *
+ *
*
*