Skip to content
Draft
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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ vaadin.observability.traces=false
| `vaadin.observability.requests` | `true` | Server-side request and RPC timing. |
| `vaadin.observability.errors` | `true` | Error counters. |
| `vaadin.observability.client` | `true` | Browser-side timing collected from the client. |
| `vaadin.observability.database` | `false` | Wrap `DataSource` beans to record JDBC result-set sizes per route (Spring Boot starter only). |
| `vaadin.observability.traces` | `true` | Emit tracing spans via the Observation API. |
| `vaadin.observability.traces-session-id` | `false` | Include the session id as a span attribute. |
| `vaadin.observability.route-cardinality-limit` | `200` | Maximum number of distinct `route` tag values before they collapse to `_other`. |
Expand Down Expand Up @@ -218,6 +219,29 @@ ObservabilitySettings.builder()
| `vaadin.client.errors` | Counter | Errors reported by the browser. |
| `vaadin.client.dropped` | Counter | Client samples dropped before recording. |
| `vaadin.client.throttled` | Counter | Client samples rejected by the per-session rate limit. |
| `vaadin.db.fetch.rows` | DistributionSummary | Rows read from a JDBC result set, tagged by `route` (opt-in, see `vaadin.observability.database`). |

## Database fetch size

With the Spring Boot starter you can have the kit watch how many rows your
queries return, without touching application code. Enable it with:

```properties
vaadin.observability.database=true
```

Every `DataSource` bean is then wrapped so each JDBC `ResultSet` reports its row
count into the `vaadin.db.fetch.rows` distribution summary, **tagged by the
Vaadin `route`** that triggered the fetch — so you can see which view issues the
large reads. Watch the p95/p99 of that summary, and alert on it in your metrics
backend (for example a Prometheus rule on `vaadin_db_fetch_rows`) to catch
runaway result sets in production.

This is off by default: it reaches outside the Vaadin runtime into the
persistence layer and adds a small per-row cost. It covers all JDBC access
(Spring Data, `JdbcTemplate`, raw JDBC) that flows through a managed
`DataSource`; row counting is best-effort and attributes to `_unknown` when no
view is active (for example background tasks).

## Tracing

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ public final class MeterNames {
/** Timer: server-side RPC invocation duration. */
public static final String RPC_DURATION = "vaadin.rpc.duration";

/**
* DistributionSummary: number of rows read from a JDBC {@code ResultSet},
* tagged by {@link #TAG_ROUTE} of the Vaadin view that triggered the fetch.
* Recorded only when database monitoring is enabled.
*/
public static final String DB_FETCH_ROWS = "vaadin.db.fetch.rows";

/** Tag key: RPC invocation type. */
public static final String TAG_TYPE = "type";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ public void beforeEnter(BeforeEnterEvent event) {
UI ui = event.getUI();
String route = routes.tagFor(event.getNavigationTarget());
ComponentUtil.setData(ui, ROUTE_KEY, route);
// Persist the route up front (before the view renders) so
// out-of-runtime
// instrumentation (e.g. the DataSource fetch-size proxy) attributes
// even
// construction-time queries on this request thread to the target view.
VaadinTelemetryContext.setCurrentRoute(ui, route);
if (useObservation()) {
// Tell the enclosing request span this UIDL request navigated.
RequestInteraction.mark(ObservationNames.INTERACTION_NAVIGATION);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public final class ObservabilitySettings {
private final boolean client;
private final boolean traces;
private final boolean tracesSessionId;
private final boolean database;
private final int routeCardinalityLimit;
private final int clientRatePerSession;

Expand All @@ -34,6 +35,7 @@ private ObservabilitySettings(Builder builder) {
this.client = builder.client;
this.traces = builder.traces;
this.tracesSessionId = builder.tracesSessionId;
this.database = builder.database;
this.routeCardinalityLimit = builder.routeCardinalityLimit;
this.clientRatePerSession = builder.clientRatePerSession;
}
Expand Down Expand Up @@ -74,6 +76,10 @@ public boolean isTracesSessionId() {
return tracesSessionId;
}

public boolean isDatabase() {
return database;
}

public int getRouteCardinalityLimit() {
return routeCardinalityLimit;
}
Expand All @@ -93,6 +99,7 @@ public static final class Builder {
private boolean client = true;
private boolean traces = true;
private boolean tracesSessionId = false;
private boolean database = false;
private int routeCardinalityLimit = 200;
private int clientRatePerSession = 100;

Expand Down Expand Up @@ -139,6 +146,11 @@ public Builder tracesSessionId(boolean tracesSessionId) {
return this;
}

public Builder database(boolean database) {
this.database = database;
return this;
}

public Builder routeCardinalityLimit(int routeCardinalityLimit) {
if (routeCardinalityLimit < 1) {
throw new IllegalArgumentException(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* 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 com.vaadin.flow.component.ComponentUtil;
import com.vaadin.flow.component.UI;

/**
* Exposes the Vaadin view context of the request currently being handled on
* this thread, so instrumentation living outside the Vaadin runtime (for
* example a JDBC {@code DataSource} proxy) can attribute its measurements to
* the view that triggered them.
* <p>
* The route is the template resolved by {@link RouteTagResolver} during
* navigation and is stored as a UI attribute that survives past the navigation
* event (unlike the transient timing state in {@link NavigationMetricsBinder}).
* Because Vaadin binds {@link UI#getCurrent()} to the request-handling thread,
* code running on that thread can read it back here.
*/
public final class VaadinTelemetryContext {

static final String CURRENT_ROUTE_KEY = VaadinTelemetryContext.class
.getName() + ".currentRoute";

private VaadinTelemetryContext() {
}

/**
* Records the route template the current UI last navigated to. Called from
* {@link NavigationMetricsBinder} after navigation completes.
*/
static void setCurrentRoute(UI ui, String route) {
if (ui != null) {
ComponentUtil.setData(ui, CURRENT_ROUTE_KEY, route);
}
}

/**
* Returns the route template of the view bound to the current thread, or
* {@link MeterNames#ROUTE_UNKNOWN} when there is no current UI or no
* navigation has been recorded yet.
*
* @return the current route tag value, never {@code null}
*/
public static String currentRoute() {
UI ui = UI.getCurrent();
if (ui == null) {
return MeterNames.ROUTE_UNKNOWN;
}
Object route = ComponentUtil.getData(ui, CURRENT_ROUTE_KEY);
return route instanceof String r ? r : MeterNames.ROUTE_UNKNOWN;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* 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.spring.boot;

import javax.sql.DataSource;

import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.config.BeanPostProcessor;

/**
* Wraps every {@link DataSource} bean in a {@link RowCountingDataSource} so the
* kit records {@code vaadin.db.fetch.rows} without any application code.
* <p>
* The {@link MeterRegistry} is resolved lazily through an
* {@link ObjectProvider} rather than injected, so this post-processor can be
* created early (before the {@code DataSource} bean) without dragging registry
* creation forward and short-circuiting other post-processors.
*/
class DataSourceFetchMetricsBeanPostProcessor implements BeanPostProcessor {

private final ObjectProvider<MeterRegistry> meterRegistry;
private volatile DatabaseFetchMetrics metrics;

DataSourceFetchMetricsBeanPostProcessor(
ObjectProvider<MeterRegistry> meterRegistry) {
this.meterRegistry = meterRegistry;
}

@Override
public Object postProcessAfterInitialization(Object bean, String beanName)
throws BeansException {
if (bean instanceof DataSource dataSource
&& !(bean instanceof RowCountingDataSource)) {
DatabaseFetchMetrics m = metrics();
if (m != null) {
return new RowCountingDataSource(dataSource, m);
}
}
return bean;
}

private DatabaseFetchMetrics metrics() {
DatabaseFetchMetrics m = metrics;
if (m == null) {
MeterRegistry registry = meterRegistry.getIfAvailable();
if (registry != null) {
m = new DatabaseFetchMetrics(registry);
metrics = m;
}
}
return m;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* 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.spring.boot;

import io.micrometer.core.instrument.DistributionSummary;
import io.micrometer.core.instrument.MeterRegistry;

import com.vaadin.observability.micrometer.MeterNames;
import com.vaadin.observability.micrometer.VaadinTelemetryContext;

/**
* Records the {@link MeterNames#DB_FETCH_ROWS} distribution summary,
* attributing each fetch to the Vaadin route active on the current request
* thread.
*/
final class DatabaseFetchMetrics {

private final MeterRegistry registry;

DatabaseFetchMetrics(MeterRegistry registry) {
this.registry = registry;
}

/**
* Records the number of rows read from a single result set, tagged by the
* route resolved from {@link VaadinTelemetryContext}.
*
* @param rows
* the number of rows iterated; ignored when negative
*/
void recordFetch(long rows) {
if (rows < 0) {
return;
}
DistributionSummary.builder(MeterNames.DB_FETCH_ROWS)
.description("Rows read from a JDBC result set")
.tag(MeterNames.TAG_ROUTE,
VaadinTelemetryContext.currentRoute())
.publishPercentiles(0.95, 0.99).register(registry).record(rows);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@
*/
package com.vaadin.observability.spring.boot;

import javax.sql.DataSource;

import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.observation.ObservationRegistry;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
Expand All @@ -20,6 +23,7 @@
import org.springframework.boot.micrometer.metrics.autoconfigure.CompositeMeterRegistryAutoConfiguration;
import org.springframework.boot.micrometer.metrics.autoconfigure.MetricsAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Role;

import com.vaadin.flow.server.VaadinService;
import com.vaadin.observability.micrometer.MetricsServiceInitListener;
Expand Down Expand Up @@ -66,4 +70,20 @@ MetricsServiceInitListener metricsServiceInitListener(
return new SpringMetricsServiceInitListener(registry,
observationRegistry.getIfAvailable(), settings);
}

/**
* Wraps {@link DataSource} beans to record {@code vaadin.db.fetch.rows}.
* Opt-in via {@code vaadin.observability.database=true} since it reaches
* outside the Vaadin runtime and adds a small per-row cost. Declared as an
* infrastructure-role {@code static} method so the post-processor is
* created early enough to wrap the {@code DataSource}.
*/
@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
@ConditionalOnClass(DataSource.class)
@ConditionalOnProperty(prefix = "vaadin.observability", name = "database", havingValue = "true")
static DataSourceFetchMetricsBeanPostProcessor dataSourceFetchMetricsBeanPostProcessor(
ObjectProvider<MeterRegistry> meterRegistry) {
return new DataSourceFetchMetricsBeanPostProcessor(meterRegistry);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public class ObservabilityProperties {
private boolean client = true;
private boolean traces = true;
private boolean tracesSessionId = false;
private boolean database = false;
private int routeCardinalityLimit = 200;
private int clientRatePerSession = 100;

Expand Down Expand Up @@ -104,6 +105,14 @@ public void setTracesSessionId(boolean tracesSessionId) {
this.tracesSessionId = tracesSessionId;
}

public boolean isDatabase() {
return database;
}

public void setDatabase(boolean database) {
this.database = database;
}

public int getRouteCardinalityLimit() {
return routeCardinalityLimit;
}
Expand Down Expand Up @@ -132,7 +141,7 @@ public ObservabilitySettings toSettings() {
return ObservabilitySettings.builder().sessions(sessions).uis(uis)
.navigation(navigation).requests(requests).errors(errors)
.client(client).traces(traces).tracesSessionId(tracesSessionId)
.routeCardinalityLimit(routeCardinalityLimit)
.database(database).routeCardinalityLimit(routeCardinalityLimit)
.clientRatePerSession(clientRatePerSession).build();
}
}
Loading
Loading