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
Expand Up @@ -44,6 +44,20 @@ public final class MeterNames {
public static final String TAG_ROUTE = "route";
public static final String TAG_OUTCOME = "outcome";
public static final String TAG_EXCEPTION = "exception";

/**
* Tag key: simple class name of the exception that ended the operation, or
* {@link #ERROR_NONE} when it raised none. This mirrors the tag that
* {@code DefaultMeterObservationHandler} adds by itself on the Observation
* path; the binders add it explicitly on their direct-recording path so
* both paths publish the same tag-key set. Distinct from
* {@link #TAG_EXCEPTION}, which tags the {@link #ERRORS} counter.
*/
public static final String TAG_ERROR = "error";

/** {@link #TAG_ERROR} value for an operation that raised no exception. */
public static final String ERROR_NONE = "none";

public static final String TAG_TRIGGER = "trigger";
public static final String TAG_KIND = "kind";
public static final String TAG_CONTEXT = "context";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@
* {@code DefaultMeterObservationHandler}, the Timer). Otherwise the binder
* falls back to direct Timer recording. Per-UI state is stored as a UI
* attribute so concurrent UIs are tracked independently.
* <p>
* Both paths publish {@link MeterNames#NAVIGATION} with the same tag keys:
* {@code route}, {@code outcome} and {@code error}. A navigation that throws
* never reaches {@code afterNavigation}, so no sample is recorded for it on
* either path and {@code error} is always {@link MeterNames#ERROR_NONE} — the
* key is still emitted because {@code DefaultMeterObservationHandler} emits it
* on the Observation path, and a metrics backend such as Prometheus rejects
* same-named meters whose tag-key sets differ.
*/
final class NavigationMetricsBinder
implements BeforeEnterListener, AfterNavigationListener {
Expand Down Expand Up @@ -109,7 +117,8 @@ public void afterNavigation(AfterNavigationEvent event) {
if (sample instanceof Timer.Sample s) {
s.stop(registry.timer(MeterNames.NAVIGATION, MeterNames.TAG_ROUTE,
route instanceof String r ? r : MeterNames.ROUTE_UNKNOWN,
MeterNames.TAG_OUTCOME, MeterNames.OUTCOME_SUCCESS));
MeterNames.TAG_OUTCOME, MeterNames.OUTCOME_SUCCESS,
MeterNames.TAG_ERROR, MeterNames.ERROR_NONE));
}
if (scopeObj instanceof Observation.Scope scope) {
scope.close();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,20 @@
* <li>Otherwise (no obs registry / traces disabled / observation handler
* unavailable), the binder falls back to recording the Timer directly.</li>
* </ul>
* <p>
* Both paths publish {@link MeterNames#REQUEST_DURATION} with the same tag
* keys, all bounded: {@code vaadin.request.type}, {@code vaadin.interaction},
* {@code http.method}, {@code outcome} and {@code error}. Keeping the two in
* step matters because a metrics backend such as Prometheus rejects same-named
* meters whose tag-key sets differ, and dashboards must not have to know which
* path recorded a sample. The {@code error} tag is the one
* {@code DefaultMeterObservationHandler} adds by itself on the Observation
* path, so the direct-recording path adds it explicitly.
* <p>
* The UI id and the client location are attached as high-cardinality
* key-values, so they enrich the span without multiplying the Timer's time
* series: a UI id is unbounded over an application's lifetime, and the client
* location is deliberately kept un-templated.
*/
final class RequestMetricsBinder implements VaadinRequestInterceptor {

Expand All @@ -51,6 +65,10 @@ final class RequestMetricsBinder implements VaadinRequestInterceptor {
private final ThreadLocal<Timer.Sample> sample = new ThreadLocal<>();
private final ThreadLocal<Boolean> errored = ThreadLocal
.withInitial(() -> Boolean.FALSE);
// Simple class name of the exception passed to handleException, mirroring
// what DefaultMeterObservationHandler reads off the Observation context so
// the direct-recording path can tag its Timer the same way.
private final ThreadLocal<String> errorType = new ThreadLocal<>();
private final ThreadLocal<Observation> observation = new ThreadLocal<>();
private final ThreadLocal<Observation.Scope> observationScope = new ThreadLocal<>();

Expand Down Expand Up @@ -88,6 +106,7 @@ public void requestStart(VaadinRequest request, VaadinResponse response) {
// this a pooled thread could carry errored=TRUE into the next request
// and misreport it as an error.
errored.remove();
errorType.remove();
sample.remove();
observation.remove();
observationScope.remove();
Expand All @@ -108,9 +127,12 @@ public void requestStart(VaadinRequest request, VaadinResponse response) {
type)
.lowCardinalityKeyValue(ObservationNames.KEY_HTTP_METHOD,
httpMethod(request))
.lowCardinalityKeyValue(ObservationNames.KEY_UI_ID,
// Span-only: the UI id is unbounded over an application's
// lifetime and the client location is un-templated, so
// neither may become a Timer tag.
.highCardinalityKeyValue(ObservationNames.KEY_UI_ID,
uiId(request))
.lowCardinalityKeyValue(
.highCardinalityKeyValue(
ObservationNames.KEY_CLIENT_LOCATION,
clientLocation(request))
// Always emit the interaction key so every
Expand Down Expand Up @@ -146,11 +168,12 @@ private static String uiId(VaadinRequest request) {

/**
* Extracts the page path the UIDL request was sent from. Falls back to the
* Referer header path so we always emit something useful for dashboards
* filtering by view, without ever exposing PII (the path goes through the
* parent navigation observation's route template mapping in dashboards; we
* deliberately keep it un-templated here so the span captures the literal
* client path).
* Referer header path so we always emit something useful when reading a
* trace, without ever exposing PII. The path is deliberately kept
* un-templated so the span captures the literal client path; that is also
* why it is attached as a high-cardinality key-value and never as a Timer
* tag. For a templated, cardinality-capped view attribution use the
* {@code route} tag of the navigation meters instead.
*/
private static String clientLocation(VaadinRequest request) {
if (request == null) {
Expand Down Expand Up @@ -192,6 +215,9 @@ private static String clientLocation(VaadinRequest request) {
public void handleException(VaadinRequest request, VaadinResponse response,
VaadinSession vaadinSession, Exception t) {
errored.set(Boolean.TRUE);
if (t != null) {
errorType.set(t.getClass().getSimpleName());
}
if (settings.isErrors() && t != null) {
Counter.builder(MeterNames.ERRORS)
.tag(MeterNames.TAG_EXCEPTION, t.getClass().getSimpleName())
Expand All @@ -208,14 +234,10 @@ public void requestEnd(VaadinRequest request, VaadinResponse response,
VaadinSession session) {
boolean wasError = errored.get();
errored.remove();
String error = errorType.get();
errorType.remove();
String outcome = wasError ? MeterNames.OUTCOME_ERROR
: MeterNames.OUTCOME_SUCCESS;
Timer.Sample s = sample.get();
sample.remove();
if (s != null) {
s.stop(registry.timer(MeterNames.REQUEST_DURATION,
MeterNames.TAG_OUTCOME, outcome));
}
Observation.Scope scope = observationScope.get();
observationScope.remove();
if (scope != null) {
Expand All @@ -227,11 +249,33 @@ public void requestEnd(VaadinRequest request, VaadinResponse response,
// request so the span name reflects what actually happened instead
// of the opaque protocol-level "uidl".
String interaction = RequestInteraction.take();
String type = requestType(request);
// Resolve the interaction once, for whichever path records: a UIDL
// request takes the listener's marker (defaulting to the generic
// "rpc"), anything else has no interaction to report.
String kind = ObservationNames.REQUEST_TYPE_UIDL.equals(type)
? (interaction != null ? interaction
: ObservationNames.INTERACTION_RPC)
: ObservationNames.INTERACTION_NONE;
Timer.Sample s = sample.get();
sample.remove();
if (s != null) {
// Tag with the very constants the Observation path uses above, so
// the two paths cannot drift into publishing
// vaadin.request.duration under differing tag-key sets. The error
// tag replicates the one DefaultMeterObservationHandler adds for
// us there.
s.stop(Timer.builder(MeterNames.REQUEST_DURATION)
.tag(ObservationNames.KEY_REQUEST_TYPE, type)
.tag(ObservationNames.KEY_HTTP_METHOD, httpMethod(request))
.tag(ObservationNames.KEY_INTERACTION, kind)
.tag(ObservationNames.KEY_OUTCOME, outcome)
.tag(MeterNames.TAG_ERROR,
error != null ? error : MeterNames.ERROR_NONE)
.register(registry));
}
if (obs != null) {
String type = requestType(request);
if (ObservationNames.REQUEST_TYPE_UIDL.equals(type)) {
String kind = interaction != null ? interaction
: ObservationNames.INTERACTION_RPC;
obs.lowCardinalityKeyValue(ObservationNames.KEY_INTERACTION,
kind);
obs.contextualName(ObservationNames.REQUEST + "." + kind);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,14 @@
* unavailable), the binder falls back to recording the Timer directly.</li>
* </ul>
* <p>
* Timer tags (low cardinality): {@code type} (RPC invocation type) and
* {@code outcome} ({@code success}/{@code error}). The invocation name and node
* ID are deliberately omitted from the Timer tags because they are
* high-cardinality.
* Timer tags (low cardinality), identical on both paths: {@code type} (RPC
* invocation type), {@code outcome} ({@code success}/{@code error}) and
* {@code error} (the failing exception's simple class name, or {@code none}) —
* the last of these added by {@code DefaultMeterObservationHandler} on the
* Observation path and explicitly on the direct-recording one, so neither
* publishes {@link MeterNames#RPC_DURATION} under a tag-key set the other
* lacks. The invocation name and node ID are deliberately omitted from the
* Timer tags because they are high-cardinality.
* <p>
* When tracing is enabled, the span additionally carries the invocation name
* ({@link ObservationNames#KEY_EVENT_NAME}) and the targeted component class
Expand All @@ -60,6 +64,10 @@ final class RpcMetricsBinder implements RpcInvocationListener {

private final ThreadLocal<Boolean> errored = ThreadLocal
.withInitial(() -> Boolean.FALSE);
// Simple class name of the failing exception, mirroring what
// DefaultMeterObservationHandler reads off the Observation context so the
// direct-recording path can tag its Timer the same way.
private final ThreadLocal<String> errorType = new ThreadLocal<>();
private final ThreadLocal<Timer.Sample> sample = new ThreadLocal<>();
private final ThreadLocal<Observation> observation = new ThreadLocal<>();
private final ThreadLocal<Observation.Scope> observationScope = new ThreadLocal<>();
Expand All @@ -80,6 +88,7 @@ public void invocationStarted(RpcInvocationEvent event) {
// server shutdown). Without this, a pooled thread could carry
// errored=TRUE into the next invocation and misreport it.
errored.remove();
errorType.remove();
sample.remove();
observation.remove();
observationScope.remove();
Expand Down Expand Up @@ -151,6 +160,9 @@ private static Optional<String> resolveComponentType(
@Override
public void invocationFailed(RpcInvocationEvent event, Throwable error) {
errored.set(Boolean.TRUE);
if (error != null) {
errorType.set(error.getClass().getSimpleName());
}
Observation obs = observation.get();
if (obs != null && error != null) {
obs.error(error);
Expand All @@ -160,6 +172,7 @@ public void invocationFailed(RpcInvocationEvent event, Throwable error) {
@Override
public void invocationEnded(RpcInvocationEvent event) {
boolean wasError = errored.get();
String error = errorType.get();
String outcome = wasError ? MeterNames.OUTCOME_ERROR
: MeterNames.OUTCOME_SUCCESS;
String type = event.getType();
Expand All @@ -170,6 +183,7 @@ public void invocationEnded(RpcInvocationEvent event) {

// Clear all thread-locals before any calls that could throw.
errored.remove();
errorType.remove();
sample.remove();
observationScope.remove();
observation.remove();
Expand All @@ -181,9 +195,15 @@ public void invocationEnded(RpcInvocationEvent event) {
}
obs.stop();
} else if (s != null) {
// The error tag replicates the one
// DefaultMeterObservationHandler adds for us on the Observation
// path, keeping both paths' tag-key sets identical.
s.stop(Timer.builder(MeterNames.RPC_DURATION)
.tag(MeterNames.TAG_TYPE, type)
.tag(MeterNames.TAG_OUTCOME, outcome).register(registry));
.tag(MeterNames.TAG_OUTCOME, outcome)
.tag(MeterNames.TAG_ERROR,
error != null ? error : MeterNames.ERROR_NONE)
.register(registry));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,20 @@ public final class ObservationNames {
public static final String KEY_ROUTE = "route";
public static final String KEY_HTTP_METHOD = "http.method";
public static final String KEY_SESSION_ID = "vaadin.session.id";

/**
* High-cardinality span attribute: the id of the UI the request belongs to,
* or {@link #UI_ID_UNKNOWN}. Span-only; UI ids are unbounded over an
* application's lifetime, so this is never added as a Timer tag.
*/
public static final String KEY_UI_ID = "ui.id";

/**
* High-cardinality span attribute: the literal, un-templated browser path
* the request was sent from, or {@link #LOCATION_UNKNOWN}. Span-only; use
* the {@link #KEY_ROUTE} tag of the navigation meters for templated,
* cardinality-capped view attribution.
*/
public static final String KEY_CLIENT_LOCATION = "vaadin.client.location";

/**
Expand Down
Loading
Loading