diff --git a/README.md b/README.md index 73ce7af..92b3158 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,8 @@ 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 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.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 +220,46 @@ 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`). | +| `vaadin.db.query` | Timer | Duration of a JDBC query, tagged by `route`. Produced alongside the query span when database monitoring and tracing are both on. | + +## 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). + +### Locating slow or large queries in a trace + +When tracing is also enabled (`vaadin.observability.traces=true`, the default), +each query additionally opens a `vaadin.db.query` span. Because it starts on the +request-handling thread inside the Vaadin request span, it **nests under that +request/RPC span automatically** — so in Jaeger (or any backend fed by your +Micrometer tracing bridge) you can open a slow interaction and see the individual +queries it ran, each carrying the `route` and a `db.rows` attribute. The same +observation also yields a `vaadin.db.query` duration timer, i.e. DB time per +view. + +The span does not include the SQL text by default. Set +`vaadin.observability.database-statement=true` to attach the parameterized +statement as `db.statement` — useful for pinpointing the offending query, but +opt-in because SQL is higher cardinality and can be sensitive. ## 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..c621b98 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,8 @@ public final class ObservabilitySettings { private final boolean client; private final boolean traces; private final boolean tracesSessionId; + private final boolean database; + private final boolean databaseStatement; private final int routeCardinalityLimit; private final int clientRatePerSession; @@ -34,6 +36,8 @@ private ObservabilitySettings(Builder builder) { this.client = builder.client; this.traces = builder.traces; this.tracesSessionId = builder.tracesSessionId; + this.database = builder.database; + this.databaseStatement = builder.databaseStatement; this.routeCardinalityLimit = builder.routeCardinalityLimit; this.clientRatePerSession = builder.clientRatePerSession; } @@ -74,6 +78,14 @@ public boolean isTracesSessionId() { return tracesSessionId; } + public boolean isDatabase() { + return database; + } + + public boolean isDatabaseStatement() { + return databaseStatement; + } + public int getRouteCardinalityLimit() { return routeCardinalityLimit; } @@ -93,6 +105,8 @@ public static final class Builder { private boolean client = true; private boolean traces = true; private boolean tracesSessionId = false; + private boolean database = false; + private boolean databaseStatement = false; private int routeCardinalityLimit = 200; private int clientRatePerSession = 100; @@ -139,6 +153,16 @@ public Builder tracesSessionId(boolean tracesSessionId) { return this; } + public Builder database(boolean database) { + this.database = database; + return this; + } + + public Builder databaseStatement(boolean databaseStatement) { + this.databaseStatement = databaseStatement; + 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-micrometer/src/main/java/com/vaadin/observability/micrometer/trace/ObservationNames.java b/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/trace/ObservationNames.java index ab91ca6..9c2be86 100644 --- a/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/trace/ObservationNames.java +++ b/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/trace/ObservationNames.java @@ -60,6 +60,19 @@ public final class ObservationNames { /** Observation/span name for a server-side RPC invocation. */ public static final String RPC = "vaadin.rpc"; + /** Observation/span name for a single JDBC query execution. */ + public static final String DB_QUERY = "vaadin.db.query"; + + /** Span attribute: number of rows read from the query's result set. */ + public static final String KEY_DB_ROWS = "db.rows"; + + /** + * Span attribute: the (parameterized) SQL statement. Only attached when + * statement capture is explicitly enabled, as it is higher cardinality and + * may be sensitive. + */ + public static final String KEY_DB_STATEMENT = "db.statement"; + private ObservationNames() { } } 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..c50f2f2 --- /dev/null +++ b/observability-kit-starter/src/main/java/com/vaadin/observability/spring/boot/DataSourceFetchMetricsBeanPostProcessor.java @@ -0,0 +1,95 @@ +/** + * 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 io.micrometer.observation.ObservationRegistry; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.config.BeanPostProcessor; + +import com.vaadin.observability.micrometer.ObservabilitySettings; + +/** + * Wraps every {@link DataSource} bean in a {@link RowCountingDataSource} so the + * kit records {@code vaadin.db.fetch.rows} — and, when tracing is enabled, + * emits a {@code vaadin.db.query} span per query — without any application + * code. + *

+ * The {@link MeterRegistry} and {@link ObservationRegistry} are resolved lazily + * through {@link ObjectProvider}s 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 final ObjectProvider observationRegistry; + private final ObjectProvider settings; + private volatile DatabaseFetchMetrics metrics; + private volatile DatabaseQuerySpans spans; + + DataSourceFetchMetricsBeanPostProcessor( + ObjectProvider meterRegistry, + ObjectProvider observationRegistry, + ObjectProvider settings) { + this.meterRegistry = meterRegistry; + this.observationRegistry = observationRegistry; + this.settings = settings; + } + + @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, spans()); + } + } + 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; + } + + /** + * Builds the query-span emitter, or returns {@code null} when tracing is + * disabled or no {@link ObservationRegistry} is available — in which case + * only the row-count metric is recorded. + */ + private DatabaseQuerySpans spans() { + ObservabilitySettings s = settings.getIfAvailable(); + if (s == null || !s.isTraces()) { + return null; + } + DatabaseQuerySpans existing = spans; + if (existing == null) { + ObservationRegistry registry = observationRegistry.getIfAvailable(); + if (registry != null) { + existing = new DatabaseQuerySpans(registry, + s.isDatabaseStatement()); + spans = existing; + } + } + return existing; + } +} 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/DatabaseQuerySpans.java b/observability-kit-starter/src/main/java/com/vaadin/observability/spring/boot/DatabaseQuerySpans.java new file mode 100644 index 0000000..763e7e9 --- /dev/null +++ b/observability-kit-starter/src/main/java/com/vaadin/observability/spring/boot/DatabaseQuerySpans.java @@ -0,0 +1,95 @@ +/** + * 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 java.util.concurrent.atomic.AtomicBoolean; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; + +import com.vaadin.observability.micrometer.VaadinTelemetryContext; +import com.vaadin.observability.micrometer.trace.ObservationNames; + +/** + * Emits a {@link ObservationNames#DB_QUERY} observation around each JDBC query. + * Started when a query executes and stopped when its result set closes, the + * observation produces a span (exported to the configured tracing backend, e.g. + * Jaeger) nested under the current Vaadin request/RPC span, plus — through the + * Observation API's meter handler — a {@code vaadin.db.query} duration timer + * tagged by route. + *

+ * The route is read from {@link VaadinTelemetryContext}; the row count is added + * when the span stops. The SQL text is attached only when statement capture is + * enabled, since it is higher cardinality and may be sensitive. + */ +final class DatabaseQuerySpans { + + private final ObservationRegistry observationRegistry; + private final boolean captureStatement; + + DatabaseQuerySpans(ObservationRegistry observationRegistry, + boolean captureStatement) { + this.observationRegistry = observationRegistry; + this.captureStatement = captureStatement; + } + + /** + * Starts a query span, tagging the current route and (when enabled) the + * SQL. The caller must {@link QuerySpan#stop(long) stop} it when the result + * set closes. + * + * @param sql + * the SQL being executed, may be {@code null} + * @return the in-flight span handle + */ + QuerySpan start(String sql) { + Observation observation = Observation + .createNotStarted(ObservationNames.DB_QUERY, + observationRegistry) + .contextualName(ObservationNames.DB_QUERY) + .lowCardinalityKeyValue(ObservationNames.KEY_ROUTE, + VaadinTelemetryContext.currentRoute()); + if (captureStatement && sql != null) { + observation.highCardinalityKeyValue( + ObservationNames.KEY_DB_STATEMENT, sql); + } + return new QuerySpan(observation.start()); + } + + /** + * Handle for an in-flight query span. {@link #stop(long)} is idempotent so + * the result-set close path and the statement-close leak guard can both + * call it without double-stopping. + */ + static final class QuerySpan { + private final Observation observation; + private final AtomicBoolean stopped = new AtomicBoolean(); + + private QuerySpan(Observation observation) { + this.observation = observation; + } + + /** + * Stops the span, recording the row count when known. + * + * @param rows + * rows read, or a negative value when unknown (e.g. the + * result set was never closed) + */ + void stop(long rows) { + if (stopped.compareAndSet(false, true)) { + if (rows >= 0) { + observation.highCardinalityKeyValue( + ObservationNames.KEY_DB_ROWS, Long.toString(rows)); + } + observation.stop(); + } + } + } +} 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..4d94a67 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,23 @@ 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, + ObjectProvider observationRegistry, + ObjectProvider settings) { + return new DataSourceFetchMetricsBeanPostProcessor(meterRegistry, + observationRegistry, settings); + } } 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..df9ced5 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,8 @@ public class ObservabilityProperties { private boolean client = true; private boolean traces = true; private boolean tracesSessionId = false; + private boolean database = false; + private boolean databaseStatement = false; private int routeCardinalityLimit = 200; private int clientRatePerSession = 100; @@ -104,6 +106,22 @@ public void setTracesSessionId(boolean tracesSessionId) { this.tracesSessionId = tracesSessionId; } + public boolean isDatabase() { + return database; + } + + public void setDatabase(boolean database) { + this.database = database; + } + + public boolean isDatabaseStatement() { + return databaseStatement; + } + + public void setDatabaseStatement(boolean databaseStatement) { + this.databaseStatement = databaseStatement; + } + public int getRouteCardinalityLimit() { return routeCardinalityLimit; } @@ -132,6 +150,7 @@ public ObservabilitySettings toSettings() { return ObservabilitySettings.builder().sessions(sessions).uis(uis) .navigation(navigation).requests(requests).errors(errors) .client(client).traces(traces).tracesSessionId(tracesSessionId) + .database(database).databaseStatement(databaseStatement) .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..cc47e5f --- /dev/null +++ b/observability-kit-starter/src/main/java/com/vaadin/observability/spring/boot/RowCountingDataSource.java @@ -0,0 +1,275 @@ +/** + * 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; + private final DatabaseQuerySpans spans; + + RowCountingDataSource(DataSource delegate, DatabaseFetchMetrics metrics, + DatabaseQuerySpans spans) { + this.delegate = delegate; + this.metrics = metrics; + this.spans = spans; + } + + @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, String sql) { + 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, sql)); + } + + private ResultSet wrapResultSet(ResultSet resultSet, + DatabaseQuerySpans.QuerySpan span) { + if (resultSet == null) { + return null; + } + return (ResultSet) Proxy.newProxyInstance( + ResultSet.class.getClassLoader(), + new Class[] { ResultSet.class }, + new ResultSetHandler(resultSet, span)); + } + + /** + * 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) { + // prepareStatement/prepareCall carry the SQL up front; plain + // createStatement does not (its SQL arrives at executeQuery). + String sql = method.getName().startsWith("prepare") + && args != null && args.length > 0 + && args[0] instanceof String s ? s : null; + return wrapStatement(statement, sql); + } + return result; + } + } + + private final class StatementHandler implements InvocationHandler { + private final Statement statement; + /** SQL from prepareStatement/prepareCall, null for plain statements. */ + private final String preparedSql; + /** Span for the most recent query, kept for the close() leak guard. */ + private DatabaseQuerySpans.QuerySpan pending; + + StatementHandler(Statement statement, String preparedSql) { + this.statement = statement; + this.preparedSql = preparedSql; + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) + throws Throwable { + String name = method.getName(); + // Start the span before executing so it brackets the DB round trip; + // it is stopped when the returned result set closes. + DatabaseQuerySpans.QuerySpan span = spans != null + && name.equals("executeQuery") ? spans.start(sqlFor(args)) + : null; + Object result; + try { + result = RowCountingDataSource.invoke(statement, method, args); + } catch (Throwable t) { + if (span != null) { + span.stop(-1); + } + throw t; + } + if (result instanceof ResultSet resultSet) { + if (span != null) { + pending = span; + } + return wrapResultSet(resultSet, span); + } + if (span != null) { + // executeQuery returned no result set (unusual) — don't leak. + span.stop(-1); + } + if (name.equals("close") && pending != null) { + // Result set never closed: close the span so it isn't orphaned. + pending.stop(-1); + pending = null; + } + return result; + } + + private String sqlFor(Object[] args) { + if (args != null && args.length > 0 + && args[0] instanceof String s) { + return s; + } + return preparedSql; + } + } + + private final class ResultSetHandler implements InvocationHandler { + private final ResultSet resultSet; + private final DatabaseQuerySpans.QuerySpan span; + private long rows; + private boolean recorded; + + ResultSetHandler(ResultSet resultSet, + DatabaseQuerySpans.QuerySpan span) { + this.resultSet = resultSet; + this.span = span; + } + + @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); + if (span != null) { + span.stop(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..11d8f97 --- /dev/null +++ b/observability-kit-starter/src/test/java/com/vaadin/observability/spring/boot/RowCountingDataSourceTest.java @@ -0,0 +1,150 @@ +/** + * 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 java.util.ArrayList; +import java.util.List; + +import io.micrometer.core.instrument.DistributionSummary; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationHandler; +import io.micrometer.observation.ObservationRegistry; +import org.junit.jupiter.api.Test; + +import com.vaadin.observability.micrometer.MeterNames; +import com.vaadin.observability.micrometer.trace.ObservationNames; + +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), null); + 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), null); + 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); + } + + @Test + void query_emitsSpanWithRowCountAndStatement() 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, true, false); + + ObservationRegistry observationRegistry = ObservationRegistry.create(); + List stopped = new ArrayList<>(); + observationRegistry.observationConfig() + .observationHandler(new ObservationHandler<>() { + @Override + public boolean supportsContext(Observation.Context c) { + return true; + } + + @Override + public void onStop(Observation.Context c) { + stopped.add(c); + } + }); + + DataSource ds = new RowCountingDataSource(delegate, + new DatabaseFetchMetrics(registry), + new DatabaseQuerySpans(observationRegistry, true)); + try (Connection c = ds.getConnection(); + PreparedStatement ps = c.prepareStatement("SELECT * FROM x"); + ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + // drain + } + } + + assertThat(stopped).singleElement().satisfies(context -> { + assertThat(context.getName()).isEqualTo(ObservationNames.DB_QUERY); + assertThat(context + .getHighCardinalityKeyValue(ObservationNames.KEY_DB_ROWS) + .getValue()).isEqualTo("2"); + assertThat(context.getHighCardinalityKeyValue( + ObservationNames.KEY_DB_STATEMENT).getValue()) + .isEqualTo("SELECT * FROM x"); + assertThat(context + .getLowCardinalityKeyValue(ObservationNames.KEY_ROUTE) + .getValue()).isEqualTo(MeterNames.ROUTE_UNKNOWN); + }); + } +} 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..f94be22 --- /dev/null +++ b/observability-kit-tests/observability-kit-tests-starter/src/test/java/com/vaadin/observability/tests/starter/DatabaseFetchMetricsIT.java @@ -0,0 +1,110 @@ +/** + * 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); + // Each query also opens a vaadin.db.query observation (the span shown + // in + // Jaeger when a tracing bridge is present); even without a bridge the + // Observation API produces a route-tagged duration timer, so its count + // must reflect the two queries we issued from the db view. + assertThat(labeledValue(body, "vaadin_db_query_seconds_count", "db")) + .as("vaadin_db_query_seconds_count{route=\"db\"}") + .isGreaterThanOrEqualTo(2.0); + } + + 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; + } +}