diff --git a/README.md b/README.md index e61701a1..81110517 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,6 @@ flowchart LR subgraph Server["Vaadin application"] httpobs["Spring HTTP observation
http.server.requests"] - resync["Resync detection filter
vaadin.resync"] flow["VaadinService + Flow SPIs
requests, RPC, navigation,
data queries, sessions, UIs, errors"] subgraph Kit["Observability Kit binders"] @@ -46,6 +45,7 @@ flowchart LR datab["DataQueryMetricsBinder
vaadin.data.fetch / count"] errb["ErrorMetricsBinder
vaadin.errors"] lifeb["Session / UI / Lock / State binders
vaadin.sessions.*, vaadin.ui.*"] + resyncb["ResyncMetricsBinder
vaadin.resync"] clientb["ClientMetricsBinder
vaadin.client.*"] dbproxy["DataSource proxy, Boot opt-in
vaadin.db.query / fetch.rows"] end @@ -68,10 +68,9 @@ flowchart LR collector -- "rate-limited batches
over a ClientCallable" --> clientb flow -- "listener and event-bus callbacks" --> Kit - resync --> meters reqb -- "enrich and mark" --> hooks --> httpobs reqb & rpcb & navb & datab --> obsreg - errb & lifeb & clientb & dbproxy --> meters + errb & lifeb & resyncb & clientb & dbproxy --> meters errb & reqb -- "failed / slow interactions" --> insights texec --> obsreg obsreg --> handler --> meters @@ -394,7 +393,7 @@ vaadin.observability.traces=false | `vaadin.observability.data` | `true` | Data provider count/fetch query timing and page sizes for lazy-loading components. | | `vaadin.observability.errors` | `true` | Error counters. | | `vaadin.observability.client` | `true` | Browser-side timing, connection state and errors collected from the client (see [Connection and client-side problems](#connection-and-client-side-problems)). | -| `vaadin.observability.resync` | `true` | Observe UIDL message resends and client-requested resynchronizations. | +| `vaadin.observability.resync` | `true` | Count client message resends, client-requested resynchronizations and unexpected message ids. | | `vaadin.observability.database` | `false` | Wrap `DataSource` beans to record JDBC result-set sizes per route, report the SQL queries behind each [slow data query insight](#what-a-slow-data-query-cost-in-the-database), and (when tracing is on) emit a span per query (Spring Boot starter only). | | `vaadin.observability.database-statement` | `false` | Attach the (parameterized) SQL as `db.statement` on the query span. Off by default since SQL is higher cardinality and may be sensitive. | | `vaadin.observability.database-span-limit` | `100` | Most `vaadin.db.query` spans under any one parent span (request, RPC, data fetch). Queries past it are still timed and counted but get no span; the parent carries how many as `vaadin.db.queries.unspanned`. `0` spans no query that has a parent span. See [N+1 loads and the span limit](#n1-loads-and-the-span-limit). | @@ -451,7 +450,7 @@ ObservabilitySettings.builder() | `vaadin.data.fetch.requested` | DistributionSummary | Items a fetch query asked for, tagged by `route`. | | `vaadin.data.fetch.rows` | DistributionSummary | Items a fetch query actually returned, tagged by `route`. Compared against `vaadin.data.fetch.requested` it shows a component asking for far more than it renders, or a data provider returning short pages. | | `vaadin.errors` | Counter | Server-side errors (tagged by `exception`, `route`, `component`). See [Error metrics](#error-metrics). | -| `vaadin.resync` | Counter | UIDL message recovery events observed on incoming requests, tagged by `type`: `resend` for a duplicate message the client re-sent because it never got the previous response, `resync` for a full client-requested UI-state rebuild. Both mean the client lost a server message. Disable with `vaadin.observability.resync=false`. | +| `vaadin.resync` | Counter | Client message recovery events, for both HTTP and push requests, tagged by `type`: `resend` for a duplicate message the client re-sent because it never got the previous response, `resync` for a full client-requested UI-state rebuild, `out_of_sync` for a message with an id the server did not expect, which shows the user the session synchronization error. `resend` and `resync` mean the client lost a server message; `out_of_sync` usually means the UI continued on a server with an older state. Disable with `vaadin.observability.resync=false`. | | `vaadin.client.bootstrap.duration` | Timer | Browser application bootstrap time. | | `vaadin.client.navigation.duration` | Timer | Browser-observed navigation time, tagged by `route` and by the `trigger` that started it (`back` or `programmatic`). | | `vaadin.client.request.duration` | Timer | One UIDL request as the browser saw it, from queueing to the last byte of the response, tagged by `route`. The browser's side of `vaadin.request.duration`. See [Interaction timing from the browser](#interaction-timing-from-the-browser). | 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 deleted file mode 100644 index 5f8b47c0..00000000 --- a/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/CachedBodyHttpServletRequest.java +++ /dev/null @@ -1,163 +0,0 @@ -/** - * 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}. - *

- * 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 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; - -/** - * Portable, framework-agnostic servlet filter that observes UIDL message - * resends and resynchronization requests (prototype, kit-only, no Flow - * changes). It depends only on the servlet API and Micrometer, so it is meant - * for plain (non-Spring) deployments; a Spring application does not need this - * class and should use {@code SpringResyncDetectionFilter} instead. - *

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

{@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 }/{@code } in - * {@code web.xml}. The {@code MeterRegistry} is whichever instance the - * application records into. - *

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

    - *
  • Resync — the request carries - * {@link ApplicationConstants#RESYNCHRONIZE_ID}{@code = true}. This is checked - * first because a resync request also carries a normally advancing - * {@code clientId}.
  • - *
  • Resend — the request's - * {@link ApplicationConstants#CLIENT_TO_SERVER_ID} is not greater than the last - * one seen for the same UI, i.e. the client re-sent a message the server had - * already processed (Flow compares against - * {@code lastProcessedClientToServerId + 1}).
  • - *
- *

- * 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 for the full - * license. - */ -package com.vaadin.observability.micrometer; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpSession; - -import io.micrometer.core.instrument.MeterRegistry; - -import com.vaadin.flow.shared.ApplicationConstants; - -/** - * Servlet-level glue shared by the resync/resend detection filters: it - * recognizes UIDL requests, derives the per-UI key, and drives a - * {@link ResyncDetector} against per-UI state kept in the HTTP session. - *

- * 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 for the full + * license. + */ +package com.vaadin.observability.micrometer; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; + +import com.vaadin.flow.server.VaadinServiceEventBus; +import com.vaadin.flow.server.communication.ClientMessageResentEvent; +import com.vaadin.flow.server.communication.MessageIdSyncErrorEvent; +import com.vaadin.flow.server.communication.UIResynchronizationEvent; +import com.vaadin.flow.shared.Registration; + +/** + * Counts client message recovery from the events on the + * {@link com.vaadin.flow.server.VaadinService#getEventBus() service event bus} + * into {@link MeterNames#RESYNC}. + *

+ * 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 for the full - * license. - */ -package com.vaadin.observability.micrometer; - -import jakarta.servlet.ReadListener; -import jakarta.servlet.ServletInputStream; -import jakarta.servlet.http.HttpServletRequest; - -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.nio.charset.StandardCharsets; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -class CachedBodyHttpServletRequestTest { - - @Test - void bodyWithinLimitIsCachedAndReplayable() throws IOException { - CachedBodyHttpServletRequest wrapped = new CachedBodyHttpServletRequest( - requestWithBody("{\"clientId\":3}"), 64); - - Assertions.assertFalse(wrapped.isTruncated()); - Assertions.assertEquals("{\"clientId\":3}", wrapped.getCachedBody()); - Assertions.assertEquals("{\"clientId\":3}", - new String(wrapped.getInputStream().readAllBytes(), - StandardCharsets.UTF_8)); - } - - @Test - void bodyExactlyAtLimitIsNotTruncated() throws IOException { - CachedBodyHttpServletRequest wrapped = new CachedBodyHttpServletRequest( - requestWithBody("12345678"), 8); - - Assertions.assertFalse(wrapped.isTruncated()); - Assertions.assertEquals("12345678", wrapped.getCachedBody()); - } - - @Test - void bodyOverLimitIsTruncatedButStillReadInFull() throws IOException { - String body = "x".repeat(100) + "{\"clientId\":3}"; - CachedBodyHttpServletRequest wrapped = new CachedBodyHttpServletRequest( - requestWithBody(body), 16); - - Assertions.assertTrue(wrapped.isTruncated()); - Assertions.assertEquals(body, - new String(wrapped.getInputStream().readAllBytes(), - StandardCharsets.UTF_8)); - Assertions.assertTrue(wrapped.getInputStream().isFinished()); - } - - private static HttpServletRequest requestWithBody(String body) - throws IOException { - ByteArrayInputStream in = new ByteArrayInputStream( - body.getBytes(StandardCharsets.UTF_8)); - ServletInputStream stream = new ServletInputStream() { - @Override - public int read() { - return in.read(); - } - - @Override - public boolean isFinished() { - return in.available() == 0; - } - - @Override - public boolean isReady() { - return true; - } - - @Override - public void setReadListener(ReadListener readListener) { - // not used - } - }; - HttpServletRequest request = Mockito.mock(HttpServletRequest.class); - Mockito.when(request.getInputStream()).thenReturn(stream); - return request; - } -} diff --git a/observability-kit-micrometer/src/test/java/com/vaadin/observability/micrometer/MetricsServiceInitListenerTest.java b/observability-kit-micrometer/src/test/java/com/vaadin/observability/micrometer/MetricsServiceInitListenerTest.java index 6533bd70..b64c9085 100644 --- a/observability-kit-micrometer/src/test/java/com/vaadin/observability/micrometer/MetricsServiceInitListenerTest.java +++ b/observability-kit-micrometer/src/test/java/com/vaadin/observability/micrometer/MetricsServiceInitListenerTest.java @@ -15,6 +15,8 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.ArgumentCaptor; import com.vaadin.flow.component.UI; @@ -31,9 +33,12 @@ import com.vaadin.flow.server.VaadinServiceEventBus; import com.vaadin.flow.server.VaadinSession; import com.vaadin.flow.server.communication.AbstractRpcInvocationEvent; +import com.vaadin.flow.server.communication.ClientMessageResentEvent; +import com.vaadin.flow.server.communication.MessageIdSyncErrorEvent; import com.vaadin.flow.server.communication.RpcInvocationEndedEvent; import com.vaadin.flow.server.communication.RpcInvocationFailedEvent; import com.vaadin.flow.server.communication.RpcInvocationStartedEvent; +import com.vaadin.flow.server.communication.UIResynchronizationEvent; import com.vaadin.observability.micrometer.insights.CapturedInteraction; import com.vaadin.observability.micrometer.insights.RecentInteractions; @@ -200,6 +205,27 @@ void skipsSessionBinderWhenSessionsDisabled() { "sessions disabled should not subscribe the lock binder"); } + @ParameterizedTest + @ValueSource(booleans = { true, false }) + void subscribesResyncBinderOnlyWhenResyncEnabled(boolean resync) { + ObservabilityKit.install(new SimpleMeterRegistry(), + ObservabilitySettings.builder().resync(resync).build()); + VaadinService service = licensedService(); + ServiceInitEvent event = mock(ServiceInitEvent.class); + when(event.getSource()).thenReturn(service); + + new MetricsServiceInitListener().serviceInit(event); + + VaadinServiceEventBus bus = service.getEventBus(); + int expected = resync ? 1 : 0; + assertEquals(expected, + bus.getListeners(ClientMessageResentEvent.class).size()); + assertEquals(expected, + bus.getListeners(UIResynchronizationEvent.class).size()); + assertEquals(expected, + bus.getListeners(MessageIdSyncErrorEvent.class).size()); + } + @Test void registersErrorBinderWhenErrorsEnabled() { // The session error handler is where Flow routes the failures a user 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 deleted file mode 100644 index 21f540cf..00000000 --- a/observability-kit-micrometer/src/test/java/com/vaadin/observability/micrometer/ResyncDetectorTest.java +++ /dev/null @@ -1,109 +0,0 @@ -/** - * 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-micrometer/src/test/java/com/vaadin/observability/micrometer/ResyncMetricsBinderTest.java b/observability-kit-micrometer/src/test/java/com/vaadin/observability/micrometer/ResyncMetricsBinderTest.java new file mode 100644 index 00000000..8403f3bf --- /dev/null +++ b/observability-kit-micrometer/src/test/java/com/vaadin/observability/micrometer/ResyncMetricsBinderTest.java @@ -0,0 +1,83 @@ +/** + * 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.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.vaadin.flow.component.UI; +import com.vaadin.flow.server.VaadinService; +import com.vaadin.flow.server.VaadinServiceEventBus; +import com.vaadin.flow.server.communication.ClientMessageResentEvent; +import com.vaadin.flow.server.communication.MessageIdSyncErrorEvent; +import com.vaadin.flow.server.communication.UIResynchronizationEvent; +import com.vaadin.flow.shared.Registration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; + +class ResyncMetricsBinderTest { + + private final UI ui = mock(UI.class); + + private SimpleMeterRegistry registry; + private VaadinServiceEventBus eventBus; + + @BeforeEach + void setUp() { + registry = new SimpleMeterRegistry(); + eventBus = new VaadinServiceEventBus(mock(VaadinService.class)); + } + + @Test + void everyTypeReportsZeroBeforeItsFirstEvent() { + new ResyncMetricsBinder(registry).register(eventBus); + + for (String type : new String[] { MeterNames.RESYNC_TYPE_RESEND, + MeterNames.RESYNC_TYPE_RESYNC, + MeterNames.RESYNC_TYPE_OUT_OF_SYNC }) { + assertEquals(0d, count(type), type); + } + } + + @Test + void eachRecoveryEventIsCountedUnderItsType() { + new ResyncMetricsBinder(registry).register(eventBus); + + eventBus.fireEvent(new ClientMessageResentEvent(ui)); + eventBus.fireEvent(new ClientMessageResentEvent(ui)); + eventBus.fireEvent(new UIResynchronizationEvent(ui)); + eventBus.fireEvent(new MessageIdSyncErrorEvent(ui, 3, 7)); + + assertEquals(2d, count(MeterNames.RESYNC_TYPE_RESEND)); + assertEquals(1d, count(MeterNames.RESYNC_TYPE_RESYNC)); + assertEquals(1d, count(MeterNames.RESYNC_TYPE_OUT_OF_SYNC)); + } + + @Test + void removingTheRegistrationStopsCounting() { + Registration registration = new ResyncMetricsBinder(registry) + .register(eventBus); + registration.remove(); + + eventBus.fireEvent(new ClientMessageResentEvent(ui)); + eventBus.fireEvent(new UIResynchronizationEvent(ui)); + eventBus.fireEvent(new MessageIdSyncErrorEvent(ui, 3, 7)); + + assertEquals(0d, count(MeterNames.RESYNC_TYPE_RESEND)); + assertEquals(0d, count(MeterNames.RESYNC_TYPE_RESYNC)); + assertEquals(0d, count(MeterNames.RESYNC_TYPE_OUT_OF_SYNC)); + } + + private double count(String type) { + return registry.get(MeterNames.RESYNC).tag(MeterNames.TAG_TYPE, type) + .counter().count(); + } +} diff --git a/observability-kit-micrometer/src/test/java/com/vaadin/observability/micrometer/client/MetricsCollectorElementTest.java b/observability-kit-micrometer/src/test/java/com/vaadin/observability/micrometer/client/MetricsCollectorElementTest.java index bb03bf28..44cb01f8 100644 --- a/observability-kit-micrometer/src/test/java/com/vaadin/observability/micrometer/client/MetricsCollectorElementTest.java +++ b/observability-kit-micrometer/src/test/java/com/vaadin/observability/micrometer/client/MetricsCollectorElementTest.java @@ -8,6 +8,7 @@ */ package com.vaadin.observability.micrometer.client; +import java.util.ArrayList; import java.util.List; import java.util.stream.Stream; @@ -18,13 +19,31 @@ import org.junit.jupiter.api.Test; import org.mockito.Mockito; +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.HasComponents; +import com.vaadin.flow.component.Tag; +import com.vaadin.flow.component.UI; +import com.vaadin.flow.server.Command; import com.vaadin.flow.server.VaadinService; import com.vaadin.flow.server.VaadinSession; import com.vaadin.observability.micrometer.MeterNames; import com.vaadin.observability.micrometer.ObservabilitySettings; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + class MetricsCollectorElementTest { + @Tag("div") + private static final class Layout extends Component + implements HasComponents { + } + + /** The tasks {@code UI.access} queued, run by the test. */ + private final List accessTasks = new ArrayList<>(); + private SimpleMeterRegistry registry; private ObservabilitySettings settings; private ClientMetricsBinder binder; @@ -64,6 +83,28 @@ private static List samples(int count) { return Stream.generate(ClientSample::new).limit(count).toList(); } + /** + * A UI whose session queues {@code UI.access} tasks for the test to run. + */ + private UI uiQueueingAccessTasks() { + VaadinSession session = mock(VaadinSession.class, RETURNS_DEEP_STUBS); + when(session.hasLock()).thenReturn(true); + when(session.access(any())).thenAnswer(invocation -> { + accessTasks.add(invocation.getArgument(0)); + return null; + }); + UI ui = new UI(); + ui.getInternals().setSession(session); + return ui; + } + + /** Runs the queued tasks, including ones queued while running them. */ + private void runAccessTasks() { + for (int i = 0; i < accessTasks.size() && i < 10; i++) { + accessTasks.get(i).execute(); + } + } + @Test void tabsOfOneSessionShareTheBudget() { VaadinSession.setCurrent(lockedSession()); @@ -98,4 +139,48 @@ void withoutASessionTheElementStillLimits() { Assertions.assertEquals(2.0, throttled()); } + + @Test + void removedElementIsAddedBackToItsUi() { + UI ui = uiQueueingAccessTasks(); + MetricsCollectorElement element = tab(); + ui.add(element); + + ui.remove(element); + runAccessTasks(); + + Assertions.assertSame(ui, element.getParent().orElse(null)); + } + + @Test + void elementInARemovedLayoutIsAddedBackToItsUi() { + UI ui = uiQueueingAccessTasks(); + Layout layout = new Layout(); + MetricsCollectorElement element = tab(); + layout.add(element); + ui.add(layout); + + ui.remove(layout); + runAccessTasks(); + + Assertions.assertSame(ui, element.getParent().orElse(null)); + } + + /** + * A resynchronization detaches and re-attaches every node in place. The + * element is still in the UI then, so adding it again would only move it, + * detach it once more and queue another add, forever. + */ + @Test + void resynchronizationDoesNotAddTheElementAgain() { + UI ui = uiQueueingAccessTasks(); + MetricsCollectorElement element = tab(); + ui.add(element); + + ui.getInternals().getStateTree().prepareForResync(); + runAccessTasks(); + + Assertions.assertEquals(1, accessTasks.size()); + Assertions.assertSame(ui, element.getParent().orElse(null)); + } } diff --git a/observability-kit-spring/src/main/java/com/vaadin/observability/spring/SpringMetricsServiceInitListener.java b/observability-kit-spring/src/main/java/com/vaadin/observability/spring/SpringMetricsServiceInitListener.java index 3d240da2..76f73eff 100644 --- a/observability-kit-spring/src/main/java/com/vaadin/observability/spring/SpringMetricsServiceInitListener.java +++ b/observability-kit-spring/src/main/java/com/vaadin/observability/spring/SpringMetricsServiceInitListener.java @@ -10,6 +10,7 @@ import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.observation.ObservationRegistry; +import org.springframework.util.ClassUtils; import com.vaadin.flow.server.VaadinRequest; import com.vaadin.observability.micrometer.MetricsServiceInitListener; @@ -27,7 +28,9 @@ * {@link SpringHttpObservationEnricher}, making the parent HTTP span render as * e.g. {@code http post /vaadin/uidl} instead of the generic * {@code http post /**}, and marking it errored when Vaadin request handling - * raises an exception the servlet chain never sees. + * raises an exception the servlet chain never sees. The enrichment is skipped + * when spring-web is not on the classpath: there is then no HTTP observation to + * enrich, and the enricher could not even be loaded. *

* 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 for the full - * license. - */ -package com.vaadin.observability.spring; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import jakarta.servlet.http.HttpSession; - -import java.io.IOException; -import java.nio.charset.Charset; -import java.nio.charset.StandardCharsets; - -import io.micrometer.core.instrument.MeterRegistry; -import org.springframework.web.filter.OncePerRequestFilter; -import org.springframework.web.util.ContentCachingRequestWrapper; -import org.springframework.web.util.WebUtils; - -import com.vaadin.flow.server.communication.UidlRequestHandler; -import com.vaadin.observability.micrometer.ResyncInspector; - -/** - * Spring-optimized servlet filter that observes UIDL message resends and - * resynchronization requests (prototype, kit-only, no Flow changes). Shares its - * detection logic with the portable - * {@link com.vaadin.observability.micrometer.ResyncDetectionFilter} via - * {@link ResyncInspector}, but is preferred wherever Spring is on the - * classpath. - *

- * 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 for the full + * license. + */ +package com.vaadin.observability.spring; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.micrometer.observation.ObservationRegistry; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.http.server.observation.ServerRequestObservationContext; +import org.springframework.web.filter.ServerHttpObservationFilter; + +import com.vaadin.flow.server.VaadinRequest; +import com.vaadin.observability.micrometer.MetricsServiceInitListener; +import com.vaadin.observability.micrometer.ObservabilitySettings; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +class SpringMetricsServiceInitListenerTest { + + @Test + void enrichmentReachesTheSpringObservationWithSpringWeb() { + SpringMetricsServiceInitListener listener = new SpringMetricsServiceInitListener( + new SimpleMeterRegistry(), ObservationRegistry.NOOP, + ObservabilitySettings.builder().build()); + ServerRequestObservationContext context = new ServerRequestObservationContext( + Mockito.mock(HttpServletRequest.class), + Mockito.mock(HttpServletResponse.class)); + VaadinRequest request = Mockito.mock(VaadinRequest.class); + Mockito.when(request.getMethod()).thenReturn("POST"); + Mockito.when(request.getAttribute( + ServerHttpObservationFilter.CURRENT_OBSERVATION_CONTEXT_ATTRIBUTE)) + .thenReturn(context); + + listener.enrichHttpObservation(request, "uidl"); + + assertThat(context.getPathPattern()).isEqualTo("/vaadin/uidl"); + } + + /** + * Neither the starter nor vaadin-spring bring spring-web at runtime, so the + * listener must not touch Spring's HTTP observation classes when they are + * missing. Linking against them there would throw + * {@link NoClassDefFoundError}, which no best-effort catch stops. + */ + @Test + void enrichmentIsSkippedWithoutSpringWeb() throws Exception { + ClassLoader withoutSpringWeb = new WithoutSpringWebClassLoader( + getClass().getClassLoader()); + Class listenerClass = withoutSpringWeb + .loadClass(SpringMetricsServiceInitListener.class.getName()); + Constructor constructor = listenerClass.getConstructor( + MeterRegistry.class, ObservationRegistry.class, + ObservabilitySettings.class); + Object listener = constructor.newInstance(new SimpleMeterRegistry(), + ObservationRegistry.NOOP, + ObservabilitySettings.builder().build()); + VaadinRequest request = Mockito.mock(VaadinRequest.class); + + assertThatCode(() -> { + invoke(listener, "enrichHttpObservation", String.class, request, + "uidl"); + invoke(listener, "enrichHttpObservationRoute", String.class, + request, "orders/:id"); + invoke(listener, "markHttpObservationError", Throwable.class, + request, new IllegalStateException("boom")); + }).doesNotThrowAnyException(); + } + + private static void invoke(Object listener, String name, + Class argumentType, VaadinRequest request, Object argument) + throws Exception { + Method method = MetricsServiceInitListener.class.getDeclaredMethod(name, + VaadinRequest.class, argumentType); + method.setAccessible(true); + method.invoke(listener, request, argument); + } + + /** + * Loads this module's classes itself and hides spring-web, delegating + * everything else to the test class loader. + */ + private static final class WithoutSpringWebClassLoader extends ClassLoader { + + private static final String OWN_PACKAGE = SpringMetricsServiceInitListener.class + .getPackageName() + "."; + + WithoutSpringWebClassLoader(ClassLoader parent) { + super(parent); + } + + @Override + protected Class loadClass(String name, boolean resolve) + throws ClassNotFoundException { + synchronized (getClassLoadingLock(name)) { + if (name.startsWith("org.springframework.web.") + || name.startsWith("org.springframework.http.")) { + throw new ClassNotFoundException(name); + } + if (!name.startsWith(OWN_PACKAGE)) { + return super.loadClass(name, resolve); + } + Class loaded = findLoadedClass(name); + if (loaded == null) { + loaded = define(name); + } + if (resolve) { + resolveClass(loaded); + } + return loaded; + } + } + + private Class define(String name) throws ClassNotFoundException { + String resource = name.replace('.', '/') + ".class"; + try (InputStream in = getParent().getResourceAsStream(resource)) { + if (in == null) { + throw new ClassNotFoundException(name); + } + byte[] bytes = in.readAllBytes(); + return defineClass(name, bytes, 0, bytes.length); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + } +} diff --git a/observability-kit-spring/src/test/java/com/vaadin/observability/spring/SpringResyncDetectionFilterTest.java b/observability-kit-spring/src/test/java/com/vaadin/observability/spring/SpringResyncDetectionFilterTest.java deleted file mode 100644 index 02094e3f..00000000 --- a/observability-kit-spring/src/test/java/com/vaadin/observability/spring/SpringResyncDetectionFilterTest.java +++ /dev/null @@ -1,108 +0,0 @@ -/** - * 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.spring; - -import jakarta.servlet.FilterChain; - -import java.nio.charset.StandardCharsets; - -import io.micrometer.core.instrument.simple.SimpleMeterRegistry; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.mock.web.MockHttpServletResponse; -import org.springframework.mock.web.MockHttpSession; - -import com.vaadin.observability.micrometer.ResyncInspector; - -class SpringResyncDetectionFilterTest { - - private static final String LAST_CLIENT_ID_ATTR = ResyncInspector.class - .getName() + ".lastClientId.0"; - - private final SpringResyncDetectionFilter filter = new SpringResyncDetectionFilter( - new SimpleMeterRegistry()); - - /** Stands in for Flow: reads the whole body, as UidlRequestHandler does. */ - private final FilterChain readBody = (req, res) -> req.getInputStream() - .readAllBytes(); - - @Test - void regularUidlBodyIsInspected() throws Exception { - MockHttpServletRequest request = uidl(message(0)); - - filter.doFilter(request, new MockHttpServletResponse(), readBody); - - Assertions.assertEquals(0, - request.getSession().getAttribute(LAST_CLIENT_ID_ATTR)); - } - - @Test - void forgedContentLengthDoesNotDriveTheAllocation() throws Exception { - // Before the cache was bounded, Spring sized its first buffer from - // this header and allocated ~2 GB for a tiny body. - MockHttpServletRequest request = new MockHttpServletRequest("POST", - "/") { - @Override - public int getContentLength() { - return Integer.MAX_VALUE - 8; - } - - @Override - public long getContentLengthLong() { - return Integer.MAX_VALUE - 8; - } - }; - prepareUidl(request, message(0)); - - filter.doFilter(request, new MockHttpServletResponse(), readBody); - - Assertions.assertEquals(0, - request.getSession().getAttribute(LAST_CLIENT_ID_ATTR)); - } - - @Test - void bodyOverTheLimitReachesFlowButIsNotInspected() throws Exception { - String body = "{\"rpc\":[\"" - + "x".repeat(ResyncInspector.MAX_INSPECTED_BODY_BYTES) - + "\"],\"syncId\":0,\"clientId\":0}"; - MockHttpServletRequest request = uidl(body); - byte[][] seenByFlow = new byte[1][]; - - filter.doFilter(request, new MockHttpServletResponse(), (req, - res) -> seenByFlow[0] = req.getInputStream().readAllBytes()); - - Assertions.assertEquals(body, - new String(seenByFlow[0], StandardCharsets.UTF_8)); - Assertions.assertNull( - request.getSession().getAttribute(LAST_CLIENT_ID_ATTR)); - } - - private static MockHttpServletRequest uidl(String body) { - MockHttpServletRequest request = new MockHttpServletRequest("POST", - "/"); - prepareUidl(request, body); - return request; - } - - private static void prepareUidl(MockHttpServletRequest request, - String body) { - request.setQueryString("v-r=uidl&v-uiId=0"); - request.addParameter("v-r", "uidl"); - request.addParameter("v-uiId", "0"); - request.setCharacterEncoding("UTF-8"); - request.setContent(body.getBytes(StandardCharsets.UTF_8)); - request.setSession(new MockHttpSession()); - } - - private static String message(int clientId) { - return "{\"csrfToken\":\"x\",\"rpc\":[],\"syncId\":" + (clientId - 1) - + ",\"clientId\":" + clientId + "}"; - } -} diff --git a/observability-kit-starter/pom.xml b/observability-kit-starter/pom.xml index 92b77d23..77154f98 100644 --- a/observability-kit-starter/pom.xml +++ b/observability-kit-starter/pom.xml @@ -61,15 +61,6 @@ spring-boot-actuator 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 90b4f674..1472f7d3 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 @@ -23,7 +23,6 @@ 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.context.annotation.Configuration; import org.springframework.context.annotation.Role; @@ -34,7 +33,6 @@ import com.vaadin.observability.micrometer.MetricsServiceInitListener; import com.vaadin.observability.micrometer.ObservabilitySettings; import com.vaadin.observability.spring.SpringMetricsServiceInitListener; -import com.vaadin.observability.spring.SpringResyncDetectionFilter; /** * Auto-configures the Observability Kit {@link MetricsServiceInitListener} when @@ -77,26 +75,6 @@ MetricsServiceInitListener metricsServiceInitListener( observationRegistry.getIfAvailable(), settings); } - /** - * Registers the prototype {@link SpringResyncDetectionFilter}, which - * observes UIDL message resends and client-requested resynchronizations by - * inspecting UIDL request bodies. Runs at highest precedence so it wraps - * the request before any other filter consumes the body, 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 SpringResyncDetectionFilter(registry)); - registration.addUrlPatterns("/*"); - registration.setOrder(Ordered.HIGHEST_PRECEDENCE); - return registration; - } - /** * Wraps {@link DataSource} beans to record {@code vaadin.db.fetch.rows}. * Opt-in via {@code vaadin.observability.database=true} since it reaches 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 index 99972c6f..3bb55239 100644 --- 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 @@ -8,16 +8,12 @@ */ 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.time.Duration; -import org.openqa.selenium.Cookie; +import org.openqa.selenium.JavascriptExecutor; +import com.vaadin.flow.component.html.testbench.NativeButtonElement; import com.vaadin.flow.component.html.testbench.SpanElement; import com.vaadin.observability.tests.common.AbstractIT; import com.vaadin.testbench.BrowserTest; @@ -26,19 +22,20 @@ /** * 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. + * the {@code vaadin.resync} counter is exported via Prometheus for each kind of + * client message recovery Flow reports on the service event bus. * *

- * 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 same message again is a resend,
  • + *
  • a message id the server does not expect is out of sync,
  • + *
  • the next message id with the resynchronize flag is a resync.
  • + *
* *

* 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(); - } }