Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* Copyright (C) 2000-2026 Vaadin Ltd
*
* This program is available under Vaadin Commercial License and Service Terms.
*
* See <https://vaadin.com/commercial-license-and-service-terms> 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()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* Copyright (C) 2000-2026 Vaadin Ltd
*
* This program is available under Vaadin Commercial License and Service Terms.
*
* See <https://vaadin.com/commercial-license-and-service-terms> 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).
* <p>
* 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}.
* <p>
* 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);
}
}
Loading
Loading