From ed13f5df50e1e2ec0c3120a777a43a8324ddbd47 Mon Sep 17 00:00:00 2001 From: Mikael Grankvist Date: Mon, 15 Jun 2026 15:17:37 +0300 Subject: [PATCH] feat: Collect database fetch rows as metrics Have an opt in feature to collect metrics for database request. --- README.md | 24 ++ .../observability/micrometer/MeterNames.java | 7 + .../micrometer/NavigationMetricsBinder.java | 6 + .../micrometer/ObservabilitySettings.java | 12 + .../micrometer/VaadinTelemetryContext.java | 59 +++++ ...taSourceFetchMetricsBeanPostProcessor.java | 61 +++++ .../spring/boot/DatabaseFetchMetrics.java | 47 ++++ .../boot/ObservabilityAutoConfiguration.java | 20 ++ .../spring/boot/ObservabilityProperties.java | 11 +- .../spring/boot/RowCountingDataSource.java | 222 ++++++++++++++++++ .../ObservabilityAutoConfigurationTest.java | 38 +++ .../boot/RowCountingDataSourceTest.java | 93 ++++++++ .../observability-kit-tests-starter/pom.xml | 18 ++ .../tests/starter/Application.java | 12 +- .../tests/starter/DatabaseView.java | 55 +++++ .../tests/starter/DbDemoConfig.java | 41 ++++ .../tests/starter/NumbersInitializer.java | 50 ++++ .../resources/application-db-demo.properties | 3 + .../tests/starter/DatabaseFetchMetricsIT.java | 102 ++++++++ 19 files changed, 879 insertions(+), 2 deletions(-) create mode 100644 observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/VaadinTelemetryContext.java create mode 100644 observability-kit-starter/src/main/java/com/vaadin/observability/spring/boot/DataSourceFetchMetricsBeanPostProcessor.java create mode 100644 observability-kit-starter/src/main/java/com/vaadin/observability/spring/boot/DatabaseFetchMetrics.java create mode 100644 observability-kit-starter/src/main/java/com/vaadin/observability/spring/boot/RowCountingDataSource.java create mode 100644 observability-kit-starter/src/test/java/com/vaadin/observability/spring/boot/RowCountingDataSourceTest.java create mode 100644 observability-kit-tests/observability-kit-tests-starter/src/main/java/com/vaadin/observability/tests/starter/DatabaseView.java create mode 100644 observability-kit-tests/observability-kit-tests-starter/src/main/java/com/vaadin/observability/tests/starter/DbDemoConfig.java create mode 100644 observability-kit-tests/observability-kit-tests-starter/src/main/java/com/vaadin/observability/tests/starter/NumbersInitializer.java create mode 100644 observability-kit-tests/observability-kit-tests-starter/src/main/resources/application-db-demo.properties create mode 100644 observability-kit-tests/observability-kit-tests-starter/src/test/java/com/vaadin/observability/tests/starter/DatabaseFetchMetricsIT.java diff --git a/README.md b/README.md index 73ce7af..2e59357 100644 --- a/README.md +++ b/README.md @@ -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`. | @@ -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 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 6e42a85..da5f19c 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 @@ -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"; diff --git a/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/NavigationMetricsBinder.java b/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/NavigationMetricsBinder.java index fa2d0c1..5f55765 100644 --- a/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/NavigationMetricsBinder.java +++ b/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/NavigationMetricsBinder.java @@ -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); 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 85fe093..5aa7166 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 @@ -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; @@ -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; } @@ -74,6 +76,10 @@ public boolean isTracesSessionId() { return tracesSessionId; } + public boolean isDatabase() { + return database; + } + public int getRouteCardinalityLimit() { return routeCardinalityLimit; } @@ -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; @@ -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( diff --git a/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/VaadinTelemetryContext.java b/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/VaadinTelemetryContext.java new file mode 100644 index 0000000..f5c53ad --- /dev/null +++ b/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/VaadinTelemetryContext.java @@ -0,0 +1,59 @@ +/** + * 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 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. + *

+ * 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; + } +} diff --git a/observability-kit-starter/src/main/java/com/vaadin/observability/spring/boot/DataSourceFetchMetricsBeanPostProcessor.java b/observability-kit-starter/src/main/java/com/vaadin/observability/spring/boot/DataSourceFetchMetricsBeanPostProcessor.java new file mode 100644 index 0000000..f28d517 --- /dev/null +++ b/observability-kit-starter/src/main/java/com/vaadin/observability/spring/boot/DataSourceFetchMetricsBeanPostProcessor.java @@ -0,0 +1,61 @@ +/** + * 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.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. + *

+ * 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; + private volatile DatabaseFetchMetrics metrics; + + DataSourceFetchMetricsBeanPostProcessor( + ObjectProvider 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; + } +} diff --git a/observability-kit-starter/src/main/java/com/vaadin/observability/spring/boot/DatabaseFetchMetrics.java b/observability-kit-starter/src/main/java/com/vaadin/observability/spring/boot/DatabaseFetchMetrics.java new file mode 100644 index 0000000..91d03cb --- /dev/null +++ b/observability-kit-starter/src/main/java/com/vaadin/observability/spring/boot/DatabaseFetchMetrics.java @@ -0,0 +1,47 @@ +/** + * 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.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); + } +} 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 fb450ef..805f364 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 @@ -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; @@ -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; @@ -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) { + return new DataSourceFetchMetricsBeanPostProcessor(meterRegistry); + } } diff --git a/observability-kit-starter/src/main/java/com/vaadin/observability/spring/boot/ObservabilityProperties.java b/observability-kit-starter/src/main/java/com/vaadin/observability/spring/boot/ObservabilityProperties.java index 7b556d2..a6c659a 100644 --- a/observability-kit-starter/src/main/java/com/vaadin/observability/spring/boot/ObservabilityProperties.java +++ b/observability-kit-starter/src/main/java/com/vaadin/observability/spring/boot/ObservabilityProperties.java @@ -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; @@ -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; } @@ -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(); } } diff --git a/observability-kit-starter/src/main/java/com/vaadin/observability/spring/boot/RowCountingDataSource.java b/observability-kit-starter/src/main/java/com/vaadin/observability/spring/boot/RowCountingDataSource.java new file mode 100644 index 0000000..f1b6ec3 --- /dev/null +++ b/observability-kit-starter/src/main/java/com/vaadin/observability/spring/boot/RowCountingDataSource.java @@ -0,0 +1,222 @@ +/** + * 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.boot; + +import javax.sql.DataSource; + +import java.io.PrintWriter; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.sql.CallableStatement; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; +import java.sql.Statement; +import java.util.logging.Logger; + +/** + * A {@link DataSource} wrapper that counts the rows read from every + * {@link ResultSet} and reports each count to {@link DatabaseFetchMetrics}. + *

+ * Connections, statements and result sets are wrapped with JDK dynamic proxies + * that delegate every call straight through, intercepting only the few methods + * that produce a {@link ResultSet} (to wrap it) and the result set's own + * {@code next()}/{@code close()} (to count rows and emit the metric). No + * third-party JDBC-proxy library is involved. + *

+ * Counting is best-effort: a result set whose {@code close()} is never called + * (an unusual driver/usage) is simply not recorded, and row scrolling via + * {@code absolute()}/{@code relative()} is not counted. This keeps the hot path + * to a single increment per row. + */ +final class RowCountingDataSource implements DataSource { + + private final DataSource delegate; + private final DatabaseFetchMetrics metrics; + + RowCountingDataSource(DataSource delegate, DatabaseFetchMetrics metrics) { + this.delegate = delegate; + this.metrics = metrics; + } + + @Override + public Connection getConnection() throws SQLException { + return wrapConnection(delegate.getConnection()); + } + + @Override + public Connection getConnection(String username, String password) + throws SQLException { + return wrapConnection(delegate.getConnection(username, password)); + } + + private Connection wrapConnection(Connection connection) { + if (connection == null) { + return null; + } + return (Connection) Proxy.newProxyInstance( + Connection.class.getClassLoader(), + new Class[] { Connection.class }, + new ConnectionHandler(connection)); + } + + private Statement wrapStatement(Statement statement) { + if (statement == null) { + return null; + } + Class iface = statement instanceof CallableStatement + ? CallableStatement.class + : statement instanceof PreparedStatement + ? PreparedStatement.class + : Statement.class; + return (Statement) Proxy.newProxyInstance( + Statement.class.getClassLoader(), new Class[] { iface }, + new StatementHandler(statement)); + } + + private ResultSet wrapResultSet(ResultSet resultSet) { + if (resultSet == null) { + return null; + } + return (ResultSet) Proxy.newProxyInstance( + ResultSet.class.getClassLoader(), + new Class[] { ResultSet.class }, + new ResultSetHandler(resultSet)); + } + + /** + * Invokes {@code method} on {@code target}, unwrapping reflection errors. + */ + private static Object invoke(Object target, Method method, Object[] args) + throws Throwable { + try { + return method.invoke(target, args); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + } + + private final class ConnectionHandler implements InvocationHandler { + private final Connection connection; + + ConnectionHandler(Connection connection) { + this.connection = connection; + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) + throws Throwable { + Object result = RowCountingDataSource.invoke(connection, method, + args); + if (result instanceof Statement statement) { + return wrapStatement(statement); + } + return result; + } + } + + private final class StatementHandler implements InvocationHandler { + private final Statement statement; + + StatementHandler(Statement statement) { + this.statement = statement; + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) + throws Throwable { + Object result = RowCountingDataSource.invoke(statement, method, + args); + if (result instanceof ResultSet resultSet) { + return wrapResultSet(resultSet); + } + return result; + } + } + + private final class ResultSetHandler implements InvocationHandler { + private final ResultSet resultSet; + private long rows; + private boolean recorded; + + ResultSetHandler(ResultSet resultSet) { + this.resultSet = resultSet; + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) + throws Throwable { + Object result = RowCountingDataSource.invoke(resultSet, method, + args); + switch (method.getName()) { + case "next" -> { + if (Boolean.TRUE.equals(result)) { + rows++; + } + } + case "close" -> record(); + default -> { + // pass-through + } + } + return result; + } + + private void record() { + if (!recorded) { + recorded = true; + metrics.recordFetch(rows); + } + } + } + + // --- Plain delegation for the rest of the DataSource contract ----------- + + @Override + public PrintWriter getLogWriter() throws SQLException { + return delegate.getLogWriter(); + } + + @Override + public void setLogWriter(PrintWriter out) throws SQLException { + delegate.setLogWriter(out); + } + + @Override + public void setLoginTimeout(int seconds) throws SQLException { + delegate.setLoginTimeout(seconds); + } + + @Override + public int getLoginTimeout() throws SQLException { + return delegate.getLoginTimeout(); + } + + @Override + public Logger getParentLogger() throws SQLFeatureNotSupportedException { + return delegate.getParentLogger(); + } + + @Override + public T unwrap(Class iface) throws SQLException { + if (iface.isInstance(this)) { + return iface.cast(this); + } + return delegate.unwrap(iface); + } + + @Override + public boolean isWrapperFor(Class iface) throws SQLException { + return iface.isInstance(this) || delegate.isWrapperFor(iface); + } +} diff --git a/observability-kit-starter/src/test/java/com/vaadin/observability/spring/boot/ObservabilityAutoConfigurationTest.java b/observability-kit-starter/src/test/java/com/vaadin/observability/spring/boot/ObservabilityAutoConfigurationTest.java index 53a6792..511adca 100644 --- a/observability-kit-starter/src/test/java/com/vaadin/observability/spring/boot/ObservabilityAutoConfigurationTest.java +++ b/observability-kit-starter/src/test/java/com/vaadin/observability/spring/boot/ObservabilityAutoConfigurationTest.java @@ -8,6 +8,8 @@ */ package com.vaadin.observability.spring.boot; +import javax.sql.DataSource; + import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.Test; @@ -20,6 +22,7 @@ import com.vaadin.observability.micrometer.ObservabilitySettings; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; /** * Unit tests for {@link ObservabilityAutoConfiguration} using @@ -138,6 +141,41 @@ void userSuppliedSettings_autoConfigBacksOff() { }); } + /** + * Database monitoring is opt-in: with the default property a DataSource + * bean is left untouched and the post-processor is not registered. + */ + @Test + void databaseMonitoringDisabledByDefault_dataSourceNotWrapped() { + contextRunner + .withBean(SimpleMeterRegistry.class, SimpleMeterRegistry::new) + .withBean(DataSource.class, () -> mock(DataSource.class)) + .run(context -> { + assertThat(context).doesNotHaveBean( + DataSourceFetchMetricsBeanPostProcessor.class); + assertThat(context.getBean(DataSource.class)) + .isNotInstanceOf(RowCountingDataSource.class); + }); + } + + /** + * With {@code vaadin.observability.database=true} the post-processor is + * registered and wraps the application's DataSource bean. + */ + @Test + void databaseMonitoringEnabled_dataSourceWrapped() { + contextRunner + .withBean(SimpleMeterRegistry.class, SimpleMeterRegistry::new) + .withBean(DataSource.class, () -> mock(DataSource.class)) + .withPropertyValues("vaadin.observability.database=true") + .run(context -> { + assertThat(context).hasSingleBean( + DataSourceFetchMetricsBeanPostProcessor.class); + assertThat(context.getBean(DataSource.class)) + .isInstanceOf(RowCountingDataSource.class); + }); + } + /** * User-supplied MetricsServiceInitListener bean: our auto-configured * listener should back off (@ConditionalOnMissingBean), and the custom bean diff --git a/observability-kit-starter/src/test/java/com/vaadin/observability/spring/boot/RowCountingDataSourceTest.java b/observability-kit-starter/src/test/java/com/vaadin/observability/spring/boot/RowCountingDataSourceTest.java new file mode 100644 index 0000000..d665575 --- /dev/null +++ b/observability-kit-starter/src/test/java/com/vaadin/observability/spring/boot/RowCountingDataSourceTest.java @@ -0,0 +1,93 @@ +/** + * 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.boot; + +import javax.sql.DataSource; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.Statement; + +import io.micrometer.core.instrument.DistributionSummary; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; + +import com.vaadin.observability.micrometer.MeterNames; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Verifies {@link RowCountingDataSource} counts result-set rows and records the + * {@code vaadin.db.fetch.rows} summary on close. + */ +class RowCountingDataSourceTest { + + private final SimpleMeterRegistry registry = new SimpleMeterRegistry(); + + @Test + void iteratingResultSet_recordsRowCount() throws Exception { + DataSource delegate = mock(DataSource.class); + Connection connection = mock(Connection.class); + Statement statement = mock(Statement.class); + ResultSet resultSet = mock(ResultSet.class); + when(delegate.getConnection()).thenReturn(connection); + when(connection.createStatement()).thenReturn(statement); + when(statement.executeQuery(anyString())).thenReturn(resultSet); + when(resultSet.next()).thenReturn(true, true, true, false); + + DataSource ds = new RowCountingDataSource(delegate, + new DatabaseFetchMetrics(registry)); + try (Connection c = ds.getConnection(); + Statement s = c.createStatement(); + ResultSet rs = s.executeQuery("select 1")) { + while (rs.next()) { + // drain + } + } + + DistributionSummary summary = registry.find(MeterNames.DB_FETCH_ROWS) + .summary(); + assertThat(summary).isNotNull(); + assertThat(summary.count()).isEqualTo(1); + assertThat(summary.totalAmount()).isEqualTo(3.0); + // No active UI in a unit test, so the fetch is attributed to _unknown. + assertThat(summary.getId().getTag(MeterNames.TAG_ROUTE)) + .isEqualTo(MeterNames.ROUTE_UNKNOWN); + } + + @Test + void preparedStatementProxy_isCastable() throws Exception { + DataSource delegate = mock(DataSource.class); + Connection connection = mock(Connection.class); + PreparedStatement prepared = mock(PreparedStatement.class); + ResultSet resultSet = mock(ResultSet.class); + when(delegate.getConnection()).thenReturn(connection); + when(connection.prepareStatement(anyString())).thenReturn(prepared); + when(prepared.executeQuery()).thenReturn(resultSet); + when(resultSet.next()).thenReturn(true, false); + + DataSource ds = new RowCountingDataSource(delegate, + new DatabaseFetchMetrics(registry)); + try (Connection c = ds.getConnection(); + PreparedStatement ps = c.prepareStatement("select 1"); + ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + // drain + } + } + + assertThat( + registry.find(MeterNames.DB_FETCH_ROWS).summary().totalAmount()) + .isEqualTo(1.0); + } +} diff --git a/observability-kit-tests/observability-kit-tests-starter/pom.xml b/observability-kit-tests/observability-kit-tests-starter/pom.xml index 3d00266..eaca6e8 100644 --- a/observability-kit-tests/observability-kit-tests-starter/pom.xml +++ b/observability-kit-tests/observability-kit-tests-starter/pom.xml @@ -64,6 +64,16 @@ micrometer-registry-prometheus + + + org.springframework.boot + spring-boot-starter-jdbc + + + com.h2database + h2 + + com.vaadin @@ -135,6 +145,14 @@ start + + + + db-demo + + post-integration-test diff --git a/observability-kit-tests/observability-kit-tests-starter/src/main/java/com/vaadin/observability/tests/starter/Application.java b/observability-kit-tests/observability-kit-tests-starter/src/main/java/com/vaadin/observability/tests/starter/Application.java index 1b213e1..775d239 100644 --- a/observability-kit-tests/observability-kit-tests-starter/src/main/java/com/vaadin/observability/tests/starter/Application.java +++ b/observability-kit-tests/observability-kit-tests-starter/src/main/java/com/vaadin/observability/tests/starter/Application.java @@ -10,8 +10,18 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; -@SpringBootApplication +/** + * The JDBC/H2 stack that backs {@link DatabaseView} is deliberately confined to + * the {@code db-demo} Spring profile (see {@code DbDemoConfig}); Boot's + * {@link DataSourceAutoConfiguration} is excluded so that, outside that + * profile, no {@code DataSource} exists at all. This keeps the GraalVM native + * image lean — it carries none of the DB demo, and the kit's DataSource proxy + * is never engaged there — while the JVM integration tests activate the profile + * to exercise {@code vaadin.db.fetch.rows} end to end. + */ +@SpringBootApplication(exclude = DataSourceAutoConfiguration.class) public class Application { public static void main(String[] args) { diff --git a/observability-kit-tests/observability-kit-tests-starter/src/main/java/com/vaadin/observability/tests/starter/DatabaseView.java b/observability-kit-tests/observability-kit-tests-starter/src/main/java/com/vaadin/observability/tests/starter/DatabaseView.java new file mode 100644 index 0000000..443f066 --- /dev/null +++ b/observability-kit-tests/observability-kit-tests-starter/src/main/java/com/vaadin/observability/tests/starter/DatabaseView.java @@ -0,0 +1,55 @@ +/** + * Copyright (C) 2000-2026 Vaadin Ltd + * + * This program is available under Vaadin Commercial License and Service Terms. + * + * See for the full + * license. + */ +package com.vaadin.observability.tests.starter; + +import org.springframework.context.annotation.Profile; +import org.springframework.jdbc.core.JdbcTemplate; + +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.NativeButton; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.router.Route; + +/** + * View that issues real JDBC queries through the (proxied) {@code DataSource} + * so the {@code vaadin.db.fetch.rows} summary is recorded under route + * {@code db}. A small fetch returns {@value #SMALL} rows and a large fetch + * returns every seeded row. Part of the {@code db-demo} profile (see + * {@link DbDemoConfig}); it is not instantiated outside that profile because + * its {@link JdbcTemplate} dependency only exists there. + */ +@Route("db") +@Profile("db-demo") +public class DatabaseView extends Div { + + static final int SMALL = 3; + + private final transient JdbcTemplate jdbc; + private final Span result = new Span(); + + public DatabaseView(JdbcTemplate jdbc) { + this.jdbc = jdbc; + result.setId("fetch-result"); + + NativeButton small = new NativeButton("Small fetch", e -> fetch( + "SELECT id FROM numbers ORDER BY id LIMIT " + SMALL)); + small.setId("small-fetch"); + + NativeButton large = new NativeButton("Large fetch", + e -> fetch("SELECT id FROM numbers")); + large.setId("large-fetch"); + + add(small, large, result); + } + + private void fetch(String sql) { + int rows = jdbc.queryForList(sql, Integer.class).size(); + result.setText("rows: " + rows); + } +} diff --git a/observability-kit-tests/observability-kit-tests-starter/src/main/java/com/vaadin/observability/tests/starter/DbDemoConfig.java b/observability-kit-tests/observability-kit-tests-starter/src/main/java/com/vaadin/observability/tests/starter/DbDemoConfig.java new file mode 100644 index 0000000..b7ae870 --- /dev/null +++ b/observability-kit-tests/observability-kit-tests-starter/src/main/java/com/vaadin/observability/tests/starter/DbDemoConfig.java @@ -0,0 +1,41 @@ +/** + * Copyright (C) 2000-2026 Vaadin Ltd + * + * This program is available under Vaadin Commercial License and Service Terms. + * + * See for the full + * license. + */ +package com.vaadin.observability.tests.starter; + +import javax.sql.DataSource; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; + +/** + * Provides the embedded H2 {@link DataSource} and {@link JdbcTemplate} that + * back {@link DatabaseView} and {@link NumbersInitializer}. Active only under + * the {@code db-demo} profile, which the JVM integration tests enable and the + * native image build leaves off — so the native image carries no JDBC stack and + * the kit's DataSource proxy is never created there. + */ +@Configuration +@Profile("db-demo") +public class DbDemoConfig { + + @Bean + DataSource dataSource() { + return new EmbeddedDatabaseBuilder().setType(EmbeddedDatabaseType.H2) + .setName("numbers").build(); + } + + @Bean + JdbcTemplate jdbcTemplate(DataSource dataSource) { + return new JdbcTemplate(dataSource); + } +} diff --git a/observability-kit-tests/observability-kit-tests-starter/src/main/java/com/vaadin/observability/tests/starter/NumbersInitializer.java b/observability-kit-tests/observability-kit-tests-starter/src/main/java/com/vaadin/observability/tests/starter/NumbersInitializer.java new file mode 100644 index 0000000..ee0d2d8 --- /dev/null +++ b/observability-kit-tests/observability-kit-tests-starter/src/main/java/com/vaadin/observability/tests/starter/NumbersInitializer.java @@ -0,0 +1,50 @@ +/** + * Copyright (C) 2000-2026 Vaadin Ltd + * + * This program is available under Vaadin Commercial License and Service Terms. + * + * See for the full + * license. + */ +package com.vaadin.observability.tests.starter; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Seeds the embedded H2 database with {@value #TOTAL} rows so + * {@link DatabaseView} can issue both a small and a large fetch against real + * JDBC, driving the {@code vaadin.db.fetch.rows} summary. Part of the + * {@code db-demo} profile (see {@link DbDemoConfig}). + */ +@Component +@Profile("db-demo") +public class NumbersInitializer implements CommandLineRunner { + + static final int TOTAL = 1000; + + private final JdbcTemplate jdbc; + + public NumbersInitializer(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + @Override + public void run(String... args) { + jdbc.execute("CREATE TABLE IF NOT EXISTS numbers (id INT PRIMARY KEY)"); + Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM numbers", + Integer.class); + if (count != null && count == 0) { + List rows = new ArrayList<>(TOTAL); + for (int i = 1; i <= TOTAL; i++) { + rows.add(new Object[] { i }); + } + jdbc.batchUpdate("INSERT INTO numbers (id) VALUES (?)", rows); + } + } +} diff --git a/observability-kit-tests/observability-kit-tests-starter/src/main/resources/application-db-demo.properties b/observability-kit-tests/observability-kit-tests-starter/src/main/resources/application-db-demo.properties new file mode 100644 index 0000000..9bbda92 --- /dev/null +++ b/observability-kit-tests/observability-kit-tests-starter/src/main/resources/application-db-demo.properties @@ -0,0 +1,3 @@ +# Active only under the db-demo profile (JVM integration tests). Enables the +# kit's JDBC result-set size monitoring so vaadin.db.fetch.rows is recorded. +vaadin.observability.database=true diff --git a/observability-kit-tests/observability-kit-tests-starter/src/test/java/com/vaadin/observability/tests/starter/DatabaseFetchMetricsIT.java b/observability-kit-tests/observability-kit-tests-starter/src/test/java/com/vaadin/observability/tests/starter/DatabaseFetchMetricsIT.java new file mode 100644 index 0000000..cb3c285 --- /dev/null +++ b/observability-kit-tests/observability-kit-tests-starter/src/test/java/com/vaadin/observability/tests/starter/DatabaseFetchMetricsIT.java @@ -0,0 +1,102 @@ +/** + * Copyright (C) 2000-2026 Vaadin Ltd + * + * This program is available under Vaadin Commercial License and Service Terms. + * + * See for the full + * license. + */ +package com.vaadin.observability.tests.starter; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +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; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Drives the {@link DatabaseView} in Chrome, triggering a small and a large + * JDBC fetch, and asserts the {@code observability-kit-starter}'s DataSource + * proxy recorded {@code vaadin.db.fetch.rows} into the Prometheus registry, + * tagged with the {@code db} route that issued the queries. + */ +public class DatabaseFetchMetricsIT extends AbstractIT { + + @Override + protected String getTestPath() { + return "/db"; + } + + @BrowserTest + public void smallAndLargeFetchRecordedPerRoute() throws IOException { + $(NativeButtonElement.class).id("small-fetch").click(); + waitUntilResult("rows: " + DatabaseView.SMALL); + + $(NativeButtonElement.class).id("large-fetch").click(); + waitUntilResult("rows: " + NumbersInitializer.TOTAL); + + String body = fetchPrometheus(); + + assertThat(body).withFailMessage( + "expected a vaadin_db_fetch_rows sample in the Prometheus scrape") + .contains("vaadin_db_fetch_rows"); + // Both fetches were issued from the "db" view, so the summary must be + // tagged with that route and have observed at least the two fetches. + assertThat(labeledValue(body, "vaadin_db_fetch_rows_count", "db")) + .as("vaadin_db_fetch_rows_count{route=\"db\"}") + .isGreaterThanOrEqualTo(2.0); + // The large fetch read every seeded row, so the recorded maximum must + // reach the full table size. + assertThat(labeledValue(body, "vaadin_db_fetch_rows_max", "db")) + .as("vaadin_db_fetch_rows_max{route=\"db\"}") + .isGreaterThanOrEqualTo(NumbersInitializer.TOTAL); + } + + private void waitUntilResult(String expected) { + waitUntil(driver -> expected + .equals($(SpanElement.class).id("fetch-result").getText())); + } + + private String fetchPrometheus() throws IOException { + HttpURLConnection conn = (HttpURLConnection) URI + .create(getRootURL() + "/actuator/prometheus").toURL() + .openConnection(); + conn.setRequestMethod("GET"); + assertThat(conn.getResponseCode()).isEqualTo(200); + StringBuilder out = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader( + conn.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + out.append(line).append('\n'); + } + } + return out.toString(); + } + + /** + * Returns the value of the first Prometheus sample of {@code meterName} + * whose label set contains {@code route=""}, or {@code -1.0} if none + * is found. + */ + private static double labeledValue(String prometheusBody, String meterName, + String route) { + Pattern pattern = Pattern.compile( + "^" + Pattern.quote(meterName) + "\\{[^}]*route=\"" + + Pattern.quote(route) + "\"[^}]*\\}\\s+" + + "([0-9]+(?:\\.[0-9]+)?(?:[eE][-+]?[0-9]+)?)", + Pattern.MULTILINE); + Matcher m = pattern.matcher(prometheusBody); + return m.find() ? Double.parseDouble(m.group(1)) : -1.0; + } +}