From be4bf590cff6a7fc8051c8c6a873e7d0d04899cf Mon Sep 17 00:00:00 2001 From: Mikael Grankvist Date: Fri, 12 Jun 2026 11:41:28 +0300 Subject: [PATCH 1/2] feat: add copilot window for stats Add a copilot window to look at collected stats. To see window in any mode but play click on the bar-graph observability button. --- observability-kit-micrometer/pom.xml | 10 + .../MetricsServiceInitListener.java | 14 ++ .../ObservabilityDevToolsClient.java | 59 +++++ .../micrometer/ObservabilityKit.java | 29 +++ .../ObservabilityDevToolsHandler.java | 131 ++++++++++ .../frontend/VaadinObservabilityDevTools.js | 233 ++++++++++++++++++ ...adin.base.devserver.DevToolsMessageHandler | 1 + 7 files changed, 477 insertions(+) create mode 100644 observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/ObservabilityDevToolsClient.java create mode 100644 observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/devtools/ObservabilityDevToolsHandler.java create mode 100644 observability-kit-micrometer/src/main/resources/META-INF/frontend/VaadinObservabilityDevTools.js create mode 100644 observability-kit-micrometer/src/main/resources/META-INF/services/com.vaadin.base.devserver.DevToolsMessageHandler diff --git a/observability-kit-micrometer/pom.xml b/observability-kit-micrometer/pom.xml index 68e09f6..45af2a5 100644 --- a/observability-kit-micrometer/pom.xml +++ b/observability-kit-micrometer/pom.xml @@ -21,6 +21,16 @@ com.vaadin flow-server + + + com.vaadin + vaadin-dev-server + true + io.micrometer micrometer-core diff --git a/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/MetricsServiceInitListener.java b/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/MetricsServiceInitListener.java index 0a3696a..e6bfb7f 100644 --- a/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/MetricsServiceInitListener.java +++ b/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/MetricsServiceInitListener.java @@ -14,6 +14,7 @@ import io.micrometer.core.instrument.observation.DefaultMeterObservationHandler; import io.micrometer.observation.ObservationRegistry; +import com.vaadin.flow.function.DeploymentConfiguration; import com.vaadin.flow.server.ServiceInitEvent; import com.vaadin.flow.server.VaadinRequest; import com.vaadin.flow.server.VaadinService; @@ -155,7 +156,20 @@ public void serviceInit(ServiceInitEvent event) { ObservationRegistry or = observationRegistry != null ? observationRegistry : ObservabilityKit.getObservationRegistry(); + // Record the bound registry so the dev-mode Copilot metrics panel can + // read the live meters regardless of deployment type. + ObservabilityKit.setActiveMeterRegistry(r); bind(event, r, or, s); + DeploymentConfiguration configuration = event.getSource() + .getDeploymentConfiguration(); + boolean productionMode = configuration != null + ? configuration.isProductionMode() + : true; + if (!productionMode) { + event.getSource() + .addUIInitListener(uiEvent -> ObservabilityDevToolsClient + .inject(uiEvent.getUI())); + } } void bind(ServiceInitEvent event, MeterRegistry registry, diff --git a/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/ObservabilityDevToolsClient.java b/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/ObservabilityDevToolsClient.java new file mode 100644 index 0000000..e624293 --- /dev/null +++ b/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/ObservabilityDevToolsClient.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 java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import org.slf4j.LoggerFactory; + +import com.vaadin.flow.component.ComponentUtil; +import com.vaadin.flow.component.UI; +import com.vaadin.flow.internal.StringUtil; + +/** + * Loads the in-browser Vaadin Copilot metrics panel. The panel registers itself + * with Copilot's plugin API and pulls metric snapshots from the server over the + * dev-tools websocket (see {@code ObservabilityDevToolsHandler}). + *

+ * Injected once per UI and only in development mode; in production Copilot and + * the dev-tools connection do not exist, so this is never called. + */ +final class ObservabilityDevToolsClient { + + private static final String INIT_KEY = "vaadinObservabilityDevToolsInitialized"; + private static final String CLIENT_RESOURCE = "META-INF/frontend/VaadinObservabilityDevTools.js"; + + private ObservabilityDevToolsClient() { + } + + static void inject(UI ui) { + if (ui == null || ComponentUtil.getData(ui, INIT_KEY) != null) { + return; + } + ComponentUtil.setData(ui, INIT_KEY, Boolean.TRUE); + ClassLoader loader = ObservabilityDevToolsClient.class.getClassLoader(); + try (InputStream in = loader.getResourceAsStream(CLIENT_RESOURCE)) { + if (in == null) { + LoggerFactory.getLogger(ObservabilityDevToolsClient.class).warn( + "observability-kit dev-tools resource not found: {}", + CLIENT_RESOURCE); + return; + } + String js = StringUtil.removeComments( + new String(in.readAllBytes(), StandardCharsets.UTF_8), + true); + ui.getPage().executeJs(js); + } catch (IOException e) { + LoggerFactory.getLogger(ObservabilityDevToolsClient.class).warn( + "Could not load observability-kit dev-tools panel code", e); + } + } +} diff --git a/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/ObservabilityKit.java b/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/ObservabilityKit.java index 2e8c634..068b0d9 100644 --- a/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/ObservabilityKit.java +++ b/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/ObservabilityKit.java @@ -27,6 +27,15 @@ public final class ObservabilityKit { private static final AtomicReference OBSERVATION_REGISTRY = new AtomicReference<>(); private static final AtomicReference SETTINGS = new AtomicReference<>(); + /** + * The registry instrumentation was actually bound to, recorded at + * {@code serviceInit} time. Unlike {@link #METER_REGISTRY} (only populated + * by {@link #install} in standalone deployments) this is set for every + * deployment type, including Spring where the registry arrives via DI. Used + * by the dev-mode Copilot metrics panel to read the live meters. + */ + private static final AtomicReference ACTIVE_METER_REGISTRY = new AtomicReference<>(); + private ObservabilityKit() { } @@ -55,6 +64,25 @@ static MeterRegistry getMeterRegistry() { return METER_REGISTRY.get(); } + /** + * Records the registry instrumentation was bound to. Called from + * {@code MetricsServiceInitListener} for all deployment types. + */ + static void setActiveMeterRegistry(MeterRegistry registry) { + ACTIVE_METER_REGISTRY.set(registry); + } + + /** + * The registry instrumentation is currently publishing to, or {@code null} + * if instrumentation has not been bound. Read by the dev-mode Copilot + * metrics panel. + * + * @return the active meter registry, or {@code null} + */ + public static MeterRegistry getActiveMeterRegistry() { + return ACTIVE_METER_REGISTRY.get(); + } + static ObservationRegistry getObservationRegistry() { return OBSERVATION_REGISTRY.get(); } @@ -68,5 +96,6 @@ static void reset() { METER_REGISTRY.set(null); OBSERVATION_REGISTRY.set(null); SETTINGS.set(null); + ACTIVE_METER_REGISTRY.set(null); } } diff --git a/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/devtools/ObservabilityDevToolsHandler.java b/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/devtools/ObservabilityDevToolsHandler.java new file mode 100644 index 0000000..30951a0 --- /dev/null +++ b/observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/devtools/ObservabilityDevToolsHandler.java @@ -0,0 +1,131 @@ +/** + * 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.devtools; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.DistributionSummary; +import io.micrometer.core.instrument.FunctionCounter; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.Measurement; +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Timer; +import tools.jackson.databind.JsonNode; + +import com.vaadin.base.devserver.DevToolsInterface; +import com.vaadin.base.devserver.DevToolsMessageHandler; +import com.vaadin.observability.micrometer.ObservabilityKit; + +/** + * Dev-mode bridge between the live Micrometer {@link MeterRegistry} and the + * Vaadin Copilot metrics panel. + *

+ * Discovered via the Java {@link java.util.ServiceLoader} by Flow's dev-tools + * server (see {@code META-INF/services}). On request from the panel it + * snapshots every {@code vaadin.*} meter and sends it to the browser over the + * shared dev-tools websocket. This is a developer-only convenience view; it has + * no effect in production where the dev-tools connection does not exist. + */ +public class ObservabilityDevToolsHandler implements DevToolsMessageHandler { + + static final String COMMAND_REFRESH = "observability-kit-refresh"; + static final String COMMAND_METRICS = "observability-kit-metrics"; + + /** Only meters under this prefix are exposed to the panel. */ + private static final String METER_PREFIX = "vaadin."; + + @Override + public void handleConnect(DevToolsInterface devToolsInterface) { + // Push an initial snapshot; the panel also pulls on demand. + sendSnapshot(devToolsInterface); + } + + @Override + public boolean handleMessage(String command, JsonNode data, + DevToolsInterface devToolsInterface) { + if (COMMAND_REFRESH.equals(command)) { + sendSnapshot(devToolsInterface); + return true; + } + return false; + } + + private void sendSnapshot(DevToolsInterface devToolsInterface) { + Map payload = new LinkedHashMap<>(); + payload.put("timestamp", System.currentTimeMillis()); + payload.put("meters", snapshot()); + devToolsInterface.send(COMMAND_METRICS, payload); + } + + private List> snapshot() { + List> meters = new ArrayList<>(); + MeterRegistry registry = ObservabilityKit.getActiveMeterRegistry(); + if (registry == null) { + return meters; + } + for (Meter meter : registry.getMeters()) { + Meter.Id id = meter.getId(); + if (!id.getName().startsWith(METER_PREFIX)) { + continue; + } + Map entry = new LinkedHashMap<>(); + entry.put("name", id.getName()); + entry.put("type", id.getType().name()); + + Map tags = new LinkedHashMap<>(); + for (Tag tag : id.getTags()) { + tags.put(tag.getKey(), tag.getValue()); + } + entry.put("tags", tags); + + // Emit derived, interpretable values per meter type rather than raw + // statistics. For timers the cumulative mean is the stable, useful + // figure (TOTAL_TIME is an ever-growing sum and the SimpleMeter + // registry's MAX decays to 0 between polls). + if (meter instanceof Timer timer) { + entry.put("count", timer.count()); + entry.put("mean", timer.mean(TimeUnit.MILLISECONDS)); + entry.put("max", timer.max(TimeUnit.MILLISECONDS)); + entry.put("unit", "ms"); + } else if (meter instanceof Counter counter) { + entry.put("count", (long) counter.count()); + } else if (meter instanceof FunctionCounter counter) { + entry.put("count", (long) counter.count()); + } else if (meter instanceof Gauge gauge) { + entry.put("value", gauge.value()); + } else if (meter instanceof DistributionSummary summary) { + entry.put("count", summary.count()); + entry.put("mean", summary.mean()); + entry.put("max", summary.max()); + if (id.getBaseUnit() != null) { + entry.put("unit", id.getBaseUnit()); + } + } else { + // Unknown meter type: fall back to raw measurements. + List> measurements = new ArrayList<>(); + for (Measurement measurement : meter.measure()) { + Map m = new LinkedHashMap<>(); + m.put("statistic", measurement.getStatistic().name()); + m.put("value", measurement.getValue()); + measurements.add(m); + } + entry.put("measurements", measurements); + } + meters.add(entry); + } + return meters; + } +} diff --git a/observability-kit-micrometer/src/main/resources/META-INF/frontend/VaadinObservabilityDevTools.js b/observability-kit-micrometer/src/main/resources/META-INF/frontend/VaadinObservabilityDevTools.js new file mode 100644 index 0000000..766492e --- /dev/null +++ b/observability-kit-micrometer/src/main/resources/META-INF/frontend/VaadinObservabilityDevTools.js @@ -0,0 +1,233 @@ +// Copyright 2000-2026 Vaadin Ltd. +// Licensed under the Vaadin Commercial License and Service Terms. +// +// Dev-mode Vaadin Copilot panel for observability-kit. Injected per UI by +// ObservabilityDevToolsClient via Page.executeJs (development mode only). +// Registers a Copilot plugin that renders the live vaadin.* Micrometer meters +// snapshotted by ObservabilityDevToolsHandler on the server. The IIFE is +// idempotent so repeated injection does not re-register the plugin. +(function () { + if (window.__vaadinObservabilityDevToolsInstalled) { + return; + } + window.__vaadinObservabilityDevToolsInstalled = true; + + var PANEL_TAG = 'observability-kit-metrics-panel'; + var COMMAND_REFRESH = 'observability-kit-refresh'; + var COMMAND_METRICS = 'observability-kit-metrics'; + var REFRESH_INTERVAL_MS = 3000; + + // CopilotInterface captured at plugin init; used by the panel to talk to the + // server over the dev-tools websocket. + var copilot = null; + // Last snapshot received from the server, shared so a freshly opened panel + // can render immediately before its first refresh round-trips. + var latest = null; + + function num(value, decimals) { + if (typeof value !== 'number' || !isFinite(value)) { + return String(value); + } + if (Number.isInteger(value)) { + return String(value); + } + return value.toFixed(decimals == null ? 1 : decimals); + } + + function formatTags(tags) { + var keys = Object.keys(tags || {}); + if (keys.length === 0) { + return ''; + } + return keys + .map(function (k) { + return k + '=' + tags[k]; + }) + .join(', '); + } + + // Renders a meter's value cell from the type-aware fields sent by the server. + function formatMeterValue(meter) { + var unit = meter.unit ? ' ' + meter.unit : ''; + // Timer / DistributionSummary: cumulative mean is the stable figure; count + // gives weight; max is shown only when non-zero (it decays to 0 between + // polls in SimpleMeterRegistry). + if (typeof meter.mean === 'number') { + var parts = ['mean ' + num(meter.mean) + unit]; + if (typeof meter.max === 'number' && meter.max > 0) { + parts.push('max ' + num(meter.max) + unit); + } + if (typeof meter.count === 'number') { + parts.push('n=' + meter.count); + } + return parts.join(' ยท '); + } + if (typeof meter.value === 'number') { + return num(meter.value, 3); + } + if (typeof meter.count === 'number') { + return String(meter.count); + } + // Unknown meter type fallback. + return (meter.measurements || []) + .map(function (m) { + return m.statistic + ': ' + num(m.value, 3); + }) + .join(', '); + } + + class ObservabilityMetricsPanel extends HTMLElement { + connectedCallback() { + this.style.display = 'block'; + this.style.height = '100%'; + this.style.overflow = 'auto'; + this.render(); + this.requestRefresh(); + this._timer = setInterval(() => this.requestRefresh(), REFRESH_INTERVAL_MS); + } + + disconnectedCallback() { + if (this._timer) { + clearInterval(this._timer); + this._timer = null; + } + } + + requestRefresh() { + if (copilot) { + copilot.send(COMMAND_REFRESH, {}); + } + } + + // Copilot's panel manager calls this on its panel content element (the + // method is provided by its internal BasePanel). We position the panel + // explicitly, so there is nothing to recompute - just satisfy the contract. + requestLayoutUpdate() { + return Promise.resolve(); + } + + // Called by Copilot for every server message; we claim the metrics command. + handleMessage(message) { + if (message && message.command === COMMAND_METRICS) { + latest = message.data; + this.render(); + return true; + } + return false; + } + + render() { + if (!latest || !latest.meters || latest.meters.length === 0) { + this.innerHTML = + '

' + + 'No Vaadin meters yet. Interact with the application to generate metrics.' + + '
'; + return; + } + + var meters = latest.meters.slice().sort(function (a, b) { + return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; + }); + + var rows = meters + .map(function (meter) { + var tagText = formatTags(meter.tags); + var nameCell = + meter.name + + (tagText + ? '
' + tagText + '
' + : ''); + return ( + '' + + '' + + nameCell + + '' + + '' + + formatMeterValue(meter) + + '' + + '' + ); + }) + .join(''); + + var when = latest.timestamp ? new Date(latest.timestamp).toLocaleTimeString() : ''; + + this.innerHTML = + '
' + + '
' + + meters.length + + ' meter(s) · updated ' + + when + + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + rows + + '' + + '
MeterValue
' + + '
'; + } + } + + try { + if (!customElements.get(PANEL_TAG)) { + customElements.define(PANEL_TAG, ObservabilityMetricsPanel); + } + } catch (e) { + // Defining failed (e.g. unsupported in this context): give up quietly. + return; + } + + var plugin = { + init: function (copilotInterface) { + copilot = copilotInterface; + copilotInterface.addPanel({ + header: 'Observability', + tag: PANEL_TAG, + // Plain HTMLElements don't self-position the way Copilot's BasePanel + // does, and the panel manager skips viewport adjustment when no + // position is set - so it would open off-screen. Give it an explicit + // on-screen position and size. + position: { + top: 80, + left: 80, + width: 540, + height: 440 + }, + toolbarOptions: { + iconKey: 'barChart', + // The toolbar only renders an icon for panels mapped to an active + // mode; 'common' alone gives no entry point. 'play' hides the panel + // container, so expose the icon in the remaining modes. + allowedModesWithOrder: { + edit: 100, + inspect: 100, + test: 100 + } + } + }); + } + }; + + // Copilot resets window.Vaadin.copilot.plugins to [] once during bootstrap, + // so pushing eagerly races that reset and gets wiped. Wait until Copilot has + // bootstrapped (_uiState is created in the same synchronous block right after + // the reset) and only then push. At that point either initializePlugins() has + // already overridden push (so our push inits immediately) or our entry sits in + // the array until it runs - both register the panel. + var attempts = 0; + var maxAttempts = 600; // ~60s at 100ms + var timer = setInterval(function () { + attempts++; + var cp = window.Vaadin && window.Vaadin.copilot; + if (cp && cp._uiState && Array.isArray(cp.plugins)) { + clearInterval(timer); + cp.plugins.push(plugin); + } else if (attempts >= maxAttempts) { + clearInterval(timer); + } + }, 100); +})(); diff --git a/observability-kit-micrometer/src/main/resources/META-INF/services/com.vaadin.base.devserver.DevToolsMessageHandler b/observability-kit-micrometer/src/main/resources/META-INF/services/com.vaadin.base.devserver.DevToolsMessageHandler new file mode 100644 index 0000000..6fd7cec --- /dev/null +++ b/observability-kit-micrometer/src/main/resources/META-INF/services/com.vaadin.base.devserver.DevToolsMessageHandler @@ -0,0 +1 @@ +com.vaadin.observability.micrometer.devtools.ObservabilityDevToolsHandler From 5810c25aac83c4e8cf64cc5e48fa1959fb180603 Mon Sep 17 00:00:00 2001 From: Mikael Grankvist Date: Fri, 12 Jun 2026 13:55:19 +0300 Subject: [PATCH 2/2] Add trendlines --- .../frontend/VaadinObservabilityDevTools.js | 90 ++++++++++++++++++- 1 file changed, 87 insertions(+), 3 deletions(-) diff --git a/observability-kit-micrometer/src/main/resources/META-INF/frontend/VaadinObservabilityDevTools.js b/observability-kit-micrometer/src/main/resources/META-INF/frontend/VaadinObservabilityDevTools.js index 766492e..ea44f7a 100644 --- a/observability-kit-micrometer/src/main/resources/META-INF/frontend/VaadinObservabilityDevTools.js +++ b/observability-kit-micrometer/src/main/resources/META-INF/frontend/VaadinObservabilityDevTools.js @@ -23,6 +23,10 @@ // Last snapshot received from the server, shared so a freshly opened panel // can render immediately before its first refresh round-trips. var latest = null; + // Per-meter ring buffer of recent trend values, keyed by name+tags. Survives + // panel close/reopen (module scope) so the sparkline keeps its history. + var history = {}; + var HISTORY_MAX = 20; function num(value, decimals) { if (typeof value !== 'number' || !isFinite(value)) { @@ -46,6 +50,81 @@ .join(', '); } + // Stable identity for a meter across polls (name + its tag values). + function meterKey(meter) { + return meter.name + '|' + formatTags(meter.tags); + } + + // The single scalar plotted in the sparkline for this meter. + function trendValue(meter) { + if (typeof meter.mean === 'number') { + return meter.mean; + } + if (typeof meter.value === 'number') { + return meter.value; + } + if (typeof meter.count === 'number') { + return meter.count; + } + if (meter.measurements && meter.measurements.length) { + return meter.measurements[0].value; + } + return null; + } + + // Append this poll's trend value to each meter's ring buffer. + function recordHistory(meters) { + (meters || []).forEach(function (meter) { + var v = trendValue(meter); + if (typeof v !== 'number' || !isFinite(v)) { + return; + } + var key = meterKey(meter); + var buf = history[key] || (history[key] = []); + buf.push(v); + if (buf.length > HISTORY_MAX) { + buf.shift(); + } + }); + } + + // Inline SVG sparkline for a series of values. + function sparkline(values) { + if (!values || values.length < 2) { + return ''; + } + var w = 84; + var h = 18; + var pad = 2; + var min = Math.min.apply(null, values); + var max = Math.max.apply(null, values); + var range = max - min || 1; + var n = values.length; + var pts = values + .map(function (v, i) { + var x = pad + (i / (n - 1)) * (w - 2 * pad); + var y = h - pad - ((v - min) / range) * (h - 2 * pad); + return x.toFixed(1) + ',' + y.toFixed(1); + }) + .join(' '); + return ( + '' + + '' + + '' + ); + } + // Renders a meter's value cell from the type-aware fields sent by the server. function formatMeterValue(meter) { var unit = meter.unit ? ' ' + meter.unit : ''; @@ -110,6 +189,7 @@ handleMessage(message) { if (message && message.command === COMMAND_METRICS) { latest = message.data; + recordHistory(latest.meters); this.render(); return true; } @@ -139,12 +219,15 @@ : ''); return ( '' + - '' + + '' + nameCell + '' + '' + formatMeterValue(meter) + '' + + '' + + sparkline(history[meterKey(meter)]) + + '' + '' ); }) @@ -163,6 +246,7 @@ '' + 'Meter' + 'Value' + + 'Trend' + '' + '' + rows + @@ -194,8 +278,8 @@ position: { top: 80, left: 80, - width: 540, - height: 440 + width: 720, + height: 460 }, toolbarOptions: { iconKey: 'barChart',