diff --git a/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/CachedBodyHttpServletRequest.java b/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/CachedBodyHttpServletRequest.java new file mode 100644 index 00000000..698ac328 --- /dev/null +++ b/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/CachedBodyHttpServletRequest.java @@ -0,0 +1,85 @@ +/** + * Copyright (C) 2000-2026 Vaadin Ltd + * + * This program is available under Vaadin Commercial License and Service Terms. + * + * See for the full + * license. + */ +package com.vaadin.observability.micrometer; + +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +/** + * Wraps a request and buffers its body so it can be read more than once: the + * kit inspects it for resend/resync detection and Flow still reads the same + * bytes downstream. Used by {@link ResyncDetectionFilter}. + */ +final class CachedBodyHttpServletRequest extends HttpServletRequestWrapper { + + private final byte[] body; + + CachedBodyHttpServletRequest(HttpServletRequest request) + throws IOException { + super(request); + this.body = request.getInputStream().readAllBytes(); + } + + 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() { + 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() { + return new BufferedReader( + new InputStreamReader(getInputStream(), charset())); + } +} 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 6e42a852..8d38598e 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 @@ -63,6 +63,27 @@ public final class MeterNames { /** Tag key: RPC invocation type. */ 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}. + */ + public static final String RESYNC = "vaadin.resync"; + + /** + * {@link #TAG_TYPE} value for a duplicate message the client re-sent + * because it never received the previous response; the server replays its + * cached response. + */ + public static final String RESYNC_TYPE_RESEND = "resend"; + + /** + * {@link #TAG_TYPE} value for a full client-requested resynchronization + * (the client gave up waiting for a missing server message and asked for a + * full UI-state rebuild). + */ + public static final String RESYNC_TYPE_RESYNC = "resync"; + private MeterNames() { } } 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 85fe093b..6f0b6d57 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 @@ -20,6 +20,7 @@ public final class ObservabilitySettings { private final boolean requests; private final boolean errors; private final boolean client; + private final boolean resync; private final boolean traces; private final boolean tracesSessionId; private final int routeCardinalityLimit; @@ -32,6 +33,7 @@ private ObservabilitySettings(Builder builder) { this.requests = builder.requests; this.errors = builder.errors; this.client = builder.client; + this.resync = builder.resync; this.traces = builder.traces; this.tracesSessionId = builder.tracesSessionId; this.routeCardinalityLimit = builder.routeCardinalityLimit; @@ -66,6 +68,11 @@ public boolean isClient() { return client; } + /** Whether to observe UIDL message resends and resynchronizations. */ + public boolean isResync() { + return resync; + } + public boolean isTraces() { return traces; } @@ -91,6 +98,7 @@ public static final class Builder { private boolean requests = true; private boolean errors = true; private boolean client = true; + private boolean resync = true; private boolean traces = true; private boolean tracesSessionId = false; private int routeCardinalityLimit = 200; @@ -129,6 +137,11 @@ public Builder client(boolean client) { return this; } + public Builder resync(boolean resync) { + this.resync = resync; + return this; + } + public Builder traces(boolean traces) { this.traces = traces; return this; 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 new file mode 100644 index 00000000..600027b9 --- /dev/null +++ b/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/ResyncDetectionFilter.java @@ -0,0 +1,114 @@ +/** + * Copyright (C) 2000-2026 Vaadin Ltd + * + * This program is available under Vaadin Commercial License and Service Terms. + * + * See for the full + * license. + */ +package com.vaadin.observability.micrometer; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; + +import java.io.IOException; + +import io.micrometer.core.instrument.MeterRegistry; + +import com.vaadin.flow.server.communication.UidlRequestHandler; +import com.vaadin.flow.shared.ApplicationConstants; + +/** + * Servlet filter that observes UIDL message resends and resynchronization + * requests (prototype, kit-only, no Flow changes). + *

+ * 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 from the incoming request by buffering the UIDL + * body (via {@link CachedBodyHttpServletRequest} so Flow can still read it) and + * handing it to a {@link ResyncDetector}. + *

+ * Per-UI state (the last {@code clientId} seen) is kept as an HTTP session + * attribute keyed by UI id, so it is bounded by and cleaned up with the + * session. Instrumentation never fails the request: any error while inspecting + * is swallowed. + */ +public final class ResyncDetectionFilter implements Filter { + + private static final String LAST_CLIENT_ID_ATTR_PREFIX = ResyncDetectionFilter.class + .getName() + ".lastClientId."; + + private final ResyncDetector detector; + + /** + * Creates the filter recording into the given registry. + * + * @param registry + * the meter registry, not {@code null} + */ + public ResyncDetectionFilter(MeterRegistry registry) { + this.detector = new ResyncDetector(registry); + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, + FilterChain chain) throws IOException, ServletException { + if (!(request instanceof HttpServletRequest http) || !isUidl(http)) { + chain.doFilter(request, response); + return; + } + + CachedBodyHttpServletRequest wrapped = new CachedBodyHttpServletRequest( + http); + try { + inspect(wrapped); + } catch (RuntimeException instrumentationFailure) { + // Never break a request because of observability. + } + chain.doFilter(wrapped, response); + } + + private void inspect(CachedBodyHttpServletRequest request) { + HttpSession session = request.getSession(false); + String attr = LAST_CLIENT_ID_ATTR_PREFIX + uiId(request); + int previous = ResyncDetector.NO_CLIENT_ID; + if (session != null + && session.getAttribute(attr) instanceof Integer stored) { + previous = stored; + } + + ResyncDetector.Result result = detector.inspect(request.getCachedBody(), + previous); + + if (session != null) { + session.setAttribute(attr, result.lastClientId()); + } + } + + private static String uiId(HttpServletRequest request) { + String id = request.getParameter(ApplicationConstants.UI_ID_PARAMETER); + return id != null ? id : "-"; + } + + /** + * 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. + */ + private 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); + } +} 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 new file mode 100644 index 00000000..d1bf995c --- /dev/null +++ b/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/ResyncDetector.java @@ -0,0 +1,147 @@ +/** + * Copyright (C) 2000-2026 Vaadin Ltd + * + * This program is available under Vaadin Commercial License and Service Terms. + * + * See for the full + * license. + */ +package com.vaadin.observability.micrometer; + +import io.micrometer.core.instrument.MeterRegistry; +import tools.jackson.databind.JsonNode; + +import com.vaadin.flow.internal.JacksonUtils; +import com.vaadin.flow.shared.ApplicationConstants; + +/** + * Prototype detector that classifies an incoming UIDL request body as a normal + * message, a re-sent (duplicate) message, or a full resynchronization request, + * and records a {@link MeterNames#RESYNC} counter for the two recovery cases. + *

+ * 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}: + *

+ *

+ * 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/test/java/com/vaadin/observability/micrometer/ResyncDetectorTest.java b/observability-kit-micrometer/src/test/java/com/vaadin/observability/micrometer/ResyncDetectorTest.java new file mode 100644 index 00000000..21f540cf --- /dev/null +++ b/observability-kit-micrometer/src/test/java/com/vaadin/observability/micrometer/ResyncDetectorTest.java @@ -0,0 +1,109 @@ +/** + * Copyright (C) 2000-2026 Vaadin Ltd + * + * This program is available under Vaadin Commercial License and Service Terms. + * + * See for the full + * license. + */ +package com.vaadin.observability.micrometer; + +import io.micrometer.core.instrument.search.MeterNotFoundException; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.vaadin.observability.micrometer.ResyncDetector.Kind; +import com.vaadin.observability.micrometer.ResyncDetector.Result; + +class ResyncDetectorTest { + + private SimpleMeterRegistry registry; + private ResyncDetector detector; + + @BeforeEach + void setUp() { + registry = new SimpleMeterRegistry(); + detector = new ResyncDetector(registry); + } + + private static String message(int clientId, int syncId) { + return "{\"csrfToken\":\"x\",\"rpc\":[],\"syncId\":" + syncId + + ",\"clientId\":" + clientId + "}"; + } + + private static String resyncMessage(int clientId, int syncId) { + return "{\"csrfToken\":\"x\",\"rpc\":[],\"syncId\":" + syncId + + ",\"clientId\":" + clientId + ",\"resynchronize\":true}"; + } + + private double count(String type) { + try { + return registry.get(MeterNames.RESYNC) + .tag(MeterNames.TAG_TYPE, type).counter().count(); + } catch (MeterNotFoundException notRecorded) { + return 0d; + } + } + + @Test + void inOrderMessages_areNormal_andAdvanceClientId() { + int last = ResyncDetector.NO_CLIENT_ID; + for (int id = 0; id < 5; id++) { + Result r = detector.inspect(message(id, id - 1), last); + Assertions.assertEquals(Kind.NORMAL, r.kind()); + Assertions.assertEquals(id, r.lastClientId()); + last = r.lastClientId(); + } + Assertions.assertEquals(0d, count(MeterNames.RESYNC_TYPE_RESEND)); + Assertions.assertEquals(0d, count(MeterNames.RESYNC_TYPE_RESYNC)); + } + + @Test + void resentDuplicate_isDetected_andDoesNotAdvanceClientId() { + // client sent and the server processed message 0 and 1 + Result first = detector.inspect(message(0, -1), + ResyncDetector.NO_CLIENT_ID); + Result second = detector.inspect(message(1, 0), first.lastClientId()); + Assertions.assertEquals(Kind.NORMAL, second.kind()); + + // response to message 1 was lost; client re-sends message 1 verbatim + Result resend = detector.inspect(message(1, 0), second.lastClientId()); + Assertions.assertEquals(Kind.RESEND, resend.kind()); + Assertions.assertEquals(1, resend.lastClientId(), + "a resend must not advance the remembered clientId"); + + // the next genuine message is normal again + Result next = detector.inspect(message(2, 1), resend.lastClientId()); + Assertions.assertEquals(Kind.NORMAL, next.kind()); + + Assertions.assertEquals(1d, count(MeterNames.RESYNC_TYPE_RESEND)); + Assertions.assertEquals(0d, count(MeterNames.RESYNC_TYPE_RESYNC)); + } + + @Test + void resynchronizeFlag_isDetected_evenWithAdvancingClientId() { + Result first = detector.inspect(message(0, -1), + ResyncDetector.NO_CLIENT_ID); + // resync request carries the next, advancing clientId plus the flag + Result resync = detector.inspect(resyncMessage(1, 0), + first.lastClientId()); + Assertions.assertEquals(Kind.RESYNC, resync.kind()); + Assertions.assertEquals(1, resync.lastClientId()); + + Assertions.assertEquals(1d, count(MeterNames.RESYNC_TYPE_RESYNC)); + Assertions.assertEquals(0d, count(MeterNames.RESYNC_TYPE_RESEND)); + } + + @Test + void emptyOrMalformedBody_isNormal_andDoesNotThrow() { + Assertions.assertEquals(Kind.NORMAL, detector.inspect(null, 3).kind()); + Assertions.assertEquals(Kind.NORMAL, detector.inspect("", 3).kind()); + Assertions.assertEquals(Kind.NORMAL, + detector.inspect("not json", 3).kind()); + // remembered id is preserved across an unparseable body + Assertions.assertEquals(3, + detector.inspect("not json", 3).lastClientId()); + } +} diff --git a/observability-kit-starter/pom.xml b/observability-kit-starter/pom.xml index cac92534..4a05ea58 100644 --- a/observability-kit-starter/pom.xml +++ b/observability-kit-starter/pom.xml @@ -40,6 +40,15 @@ spring-boot-configuration-processor true + + + jakarta.servlet + jakarta.servlet-api + ${servlet.api.version} + provided + diff --git a/observability-kit-starter/src/main/java/com/vaadin/observability/spring/boot/ObservabilityAutoConfiguration.java b/observability-kit-starter/src/main/java/com/vaadin/observability/spring/boot/ObservabilityAutoConfiguration.java index fb450ef9..b118154f 100644 --- a/observability-kit-starter/src/main/java/com/vaadin/observability/spring/boot/ObservabilityAutoConfiguration.java +++ b/observability-kit-starter/src/main/java/com/vaadin/observability/spring/boot/ObservabilityAutoConfiguration.java @@ -19,11 +19,14 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.micrometer.metrics.autoconfigure.CompositeMeterRegistryAutoConfiguration; import org.springframework.boot.micrometer.metrics.autoconfigure.MetricsAutoConfiguration; +import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; +import org.springframework.core.Ordered; import com.vaadin.flow.server.VaadinService; import com.vaadin.observability.micrometer.MetricsServiceInitListener; import com.vaadin.observability.micrometer.ObservabilitySettings; +import com.vaadin.observability.micrometer.ResyncDetectionFilter; import com.vaadin.observability.spring.SpringMetricsServiceInitListener; /** @@ -66,4 +69,24 @@ MetricsServiceInitListener metricsServiceInitListener( return new SpringMetricsServiceInitListener(registry, observationRegistry.getIfAvailable(), settings); } + + /** + * Registers the prototype {@link ResyncDetectionFilter}, which observes + * UIDL message resends and client-requested resynchronizations by + * inspecting incoming UIDL request bodies. Runs at highest precedence so + * the body is buffered before any other filter consumes it, and gated by + * {@code vaadin.observability.resync} (default {@code true}). + */ + @Bean + @ConditionalOnBean(MeterRegistry.class) + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = "vaadin.observability", name = "resync", havingValue = "true", matchIfMissing = true) + FilterRegistrationBean resyncDetectionFilter( + MeterRegistry registry) { + FilterRegistrationBean registration = new FilterRegistrationBean<>( + new ResyncDetectionFilter(registry)); + registration.addUrlPatterns("/*"); + registration.setOrder(Ordered.HIGHEST_PRECEDENCE); + return registration; + } } diff --git a/observability-kit-starter/src/main/java/com/vaadin/observability/spring/boot/ObservabilityProperties.java b/observability-kit-starter/src/main/java/com/vaadin/observability/spring/boot/ObservabilityProperties.java index 7b556d22..209a29ea 100644 --- a/observability-kit-starter/src/main/java/com/vaadin/observability/spring/boot/ObservabilityProperties.java +++ b/observability-kit-starter/src/main/java/com/vaadin/observability/spring/boot/ObservabilityProperties.java @@ -27,6 +27,7 @@ public class ObservabilityProperties { private boolean requests = true; private boolean errors = true; private boolean client = true; + private boolean resync = true; private boolean traces = true; private boolean tracesSessionId = false; private int routeCardinalityLimit = 200; @@ -88,6 +89,14 @@ public void setClient(boolean client) { this.client = client; } + public boolean isResync() { + return resync; + } + + public void setResync(boolean resync) { + this.resync = resync; + } + public boolean isTraces() { return traces; } @@ -131,7 +140,8 @@ public void setClientRatePerSession(int clientRatePerSession) { public ObservabilitySettings toSettings() { return ObservabilitySettings.builder().sessions(sessions).uis(uis) .navigation(navigation).requests(requests).errors(errors) - .client(client).traces(traces).tracesSessionId(tracesSessionId) + .client(client).resync(resync).traces(traces) + .tracesSessionId(tracesSessionId) .routeCardinalityLimit(routeCardinalityLimit) .clientRatePerSession(clientRatePerSession).build(); } diff --git a/observability-kit-tests/observability-kit-tests-starter/src/test/java/com/vaadin/observability/tests/starter/ResyncMetricsIT.java b/observability-kit-tests/observability-kit-tests-starter/src/test/java/com/vaadin/observability/tests/starter/ResyncMetricsIT.java new file mode 100644 index 00000000..41ba5591 --- /dev/null +++ b/observability-kit-tests/observability-kit-tests-starter/src/test/java/com/vaadin/observability/tests/starter/ResyncMetricsIT.java @@ -0,0 +1,149 @@ +/** + * Copyright (C) 2000-2026 Vaadin Ltd + * + * This program is available under Vaadin Commercial License and Service Terms. + * + * See for the full + * license. + */ +package com.vaadin.observability.tests.starter; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.openqa.selenium.Cookie; + +import com.vaadin.flow.component.html.testbench.SpanElement; +import com.vaadin.observability.tests.common.AbstractIT; +import com.vaadin.testbench.BrowserTest; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Drives the Spring Boot + {@code observability-kit-starter} app and verifies + * the prototype {@code vaadin.resync} counter is exported via Prometheus when + * the UIDL request stream contains a re-sent (duplicate) message and a + * client-requested resynchronization. + * + *

+ * 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 incoming UIDL body, so this + * test replays forged UIDL POSTs through the real servlet filter chain: the + * filter classifies and counts them before Flow ever validates them. 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. + * + *

+ * The Micrometer counter {@code vaadin.resync} is exposed by Prometheus as + * {@code vaadin_resync_total} with a {@code type} label. + */ +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"; + + @Override + protected String getTestPath() { + return "/"; + } + + @BrowserTest + public void resendAndResyncAreCountedAndExported() throws IOException { + // Ensure the app has loaded and a server session exists. + 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(); + + assertThat(meterValue(prometheus, "vaadin_resync_total", "resend")) + .as("vaadin_resync_total{type=\"resend\"}") + .isGreaterThanOrEqualTo(1.0); + assertThat(meterValue(prometheus, "vaadin_resync_total", "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(); + } + + /** + * Returns the value of the first Prometheus sample line for {@code name} + * carrying the label {@code type=""}, or {@code -1.0} if absent. + */ + private static double meterValue(String prometheusBody, String name, + String type) { + Pattern pattern = Pattern.compile( + "^" + Pattern.quote(name) + "\\{[^}]*type=\"" + + Pattern.quote(type) + "\"[^}]*\\}\\s+" + + "([0-9]+(?:\\.[0-9]+)?(?:[eE][-+]?[0-9]+)?)", + Pattern.MULTILINE); + Matcher m = pattern.matcher(prometheusBody); + return m.find() ? Double.parseDouble(m.group(1)) : -1.0; + } +}