UI observability instrumentation enhancements: data loaders, view lifecycle, fragments #5265#5286
UI observability instrumentation enhancements: data loaders, view lifecycle, fragments #5265#5286fractal3000 wants to merge 14 commits into
Conversation
Pulls the legacy `Timer`-based instrumentation out of `UiObservationSupport` into a new `@Deprecated(forRemoval = true)` `LegacyUiTimerSupport` bean. `UiObservationSupport` now delegates the legacy bracketing to it via two thin call-sites in `observeDataLoader` / `observeViewLifecycle`. `UiMonitoring` itself is marked deprecated since it has no consumers outside the bridge. Tests split: legacy Timer behaviour is covered by the new `LegacyUiTimerSupportTest`; `UiObservationSupportTest` mocks the bridge and focuses on its own contract. Removing the legacy path in a future release becomes a localised change: delete the bean, the property, and one branch in each helper — without touching call sites in loaders, views, or fragments. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`View.beforeLeave` already wraps `BeforeCloseEvent` and `AfterCloseEvent` with `observeViewLifecycle` (added in #5058), but Vaadin Router only triggers it for router-driven navigation. Programmatic close paths fire the same events without Observation: - `View.close(CloseAction)` — used by tabbedmode tab-close, "Save & Close" buttons, and any user code calling `view.close(...)`. - `StandardDetailView.navigate(...)` — fires `AfterCloseEvent` after a postponed navigation resumes (dirty-detail-view dialog flow). Both are wrapped now via `observeViewLifecycle`. `closeActionPerformed` and `BeforeLeaveEvent.isPostponed()` keep the router and programmatic paths mutually exclusive, so the same close operation produces exactly one Observation. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace random per-instance action.id with a stable id derived from the provider's create-policy view class, and pass the trigger component to createActionExecutionObservation so view.id is attributed correctly. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Remove a duplicate test that exercised the same single-arg call as the TargetAction-with-component-target case. Add given/when/then labels to tests whose motivation (null-safety, priority rule, fallback path, cardinality protection, exception identity, registry recording) is not self-evident from the method name. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The same fragment class is often reused across multiple views, but the fragment observation did not carry the enclosing view id — so dashboards could not distinguish "fragment X loading in OrderDetail" from the same fragment in OrderArchive. Extend FragmentLifecycleObservationInfo with a nullable viewId, resolved via UiComponentUtils.findView from the Fragment instance (READY phase) or from the FragmentOwner parent (CREATE phase, before the fragment exists). The view.id tag is written conditionally, mirroring fragment.id behaviour. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When the same fragment class is reused (or instantiated multiple times) in one View, the existing action observation tags collapse — same action.id, target.id and view.id. Resolve the enclosing Fragment via UiComponentUtils.findFragment from the same viewSource that is already used for view.id, and add fragment.id conditionally when the fragment has an explicit id. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…5265 Two data loaders with identical loader.id in different fragments of the same view used to collapse into one Prometheus time-series. Extend DataLoaderMonitoringInfo with a nullable fragmentId, switch AbstractDataComponentsHolder to a lazy MonitoringInfoProvider that resolves viewId and fragmentId at each monitoring event instead of at loader-registration time. This also corrects a pre-existing semantic issue in fragment-owned loaders: previously the fragment id was stored in the viewId field of DataLoaderMonitoringInfo and exposed under the view.id tag — now view.id consistently carries the enclosing view id while fragment.id is its own tag. The legacy getOwnerId() hook is preserved as the default backing for resolveViewId(), so external subclasses that override only getOwnerId() continue to populate the view.id tag without migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…5265 The previous commit corrected viewId resolution for loaders inside fragments so that view.id in modern Observation carries the enclosing view id. As a side effect, the legacy jmix.ui.data Timer's `view` tag would also start carrying the enclosing view id, silently breaking any Grafana/Prometheus dashboard that filters by `view=<fragment-id>` — which used to be the legacy behaviour. Route fragmentId into the legacy `view` tag when present, so legacy dashboards keep matching the same values they always did. Modern Observation tags (view.id / fragment.id) are unaffected — they expose the new, semantically correct schema. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… as high-cardinality #5265 Auto-generated loader ids (prefix `generated_`) were previously dropped to NOOP to avoid Prometheus cardinality explosion. As a result, dynamic loaders had zero observability — no metric, no span. Map them to the fixed sentinel `loader.id="<generated>"` in the low-cardinality tag schema so they aggregate into one Prometheus time-series, and expose the original id verbatim as the high-cardinality `full_loader_id` attribute on the span for trace-level identification. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previously the view.id tag was set to "N/A" only in jmix.ui.data but omitted altogether in jmix.ui.views, jmix.ui.fragments and jmix.ui.actions when the value could not be resolved. Apply the sentinel uniformly so that: - Grafana variable dropdowns surface the anonymous bucket alongside named views, prompting developers to investigate missing ids. - PromQL queries filtering by view.id have a consistent value to match. - view.class / fragment.class (always present) act as the drill-down axis to identify which classes lack ids. Rename DATA_LOADER_EMPTY_VIEW_ID -> MISSING_VIEW_ID since the constant now covers all observation types. fragment.id and target.id keep their omit-when-absent behaviour (anonymous fragments / non-TargetAction cases are common and should not pollute aggregations). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
| * impact on call sites in {@code UiObservationSupport}. | ||
| */ | ||
| @Internal | ||
| @Deprecated(since = "2.9", forRemoval = true) |
There was a problem hiding this comment.
2.9 will never be released
This class will be deprecated since 3.0
| */ | ||
| @Internal | ||
| @Deprecated(since = "2.9", forRemoval = true) | ||
| @SuppressWarnings("removal") |
There was a problem hiding this comment.
This warning shouldn't be suppressed, I guess
| * Observation path with a different tag schema; this flag is intended for back-compat with | ||
| * dashboards built on the legacy tag set and is expected to be turned off as dashboards migrate. | ||
| */ | ||
| boolean legacyTimerEnabled; |
There was a problem hiding this comment.
- The property's name currently doesn't reflect its purpose.
Enabling a 'legacy timer' doesn't clearly refer to timer metrics. Atimercan refer to a visual component or a facet. The property needs to be renamed to better reflect its purpose.
Important
- We really need to remove this functionality in the future. What if I don't want to build an observability environment, but just want to use the basic metrics from the micrometer? This seems like a different approach to metrics, not necessarily replaceable by observability.
| protected String resolveViewId() { | ||
| return getOwnerId(); | ||
| } |
There was a problem hiding this comment.
By default, you shouldn't return ownerId because not all inheritor types are views
| loader.setMonitoringInfoProvider(dl -> | ||
| new DataLoaderMonitoringInfo(resolveViewId(), id, resolveFragmentId())); |
There was a problem hiding this comment.
I don't think we should implement such logic in an abstract class. We don't know what our implementations will be.
| Observation observation = createDataLoaderObservation(loader, phase); | ||
| return legacyUiTimerSupport.recordDataLoaderTimer(loader, phase, | ||
| () -> observation.observe(action)); | ||
| } |
There was a problem hiding this comment.
First we create an observation, and then we might not use anything at all.
Why we can't check ui property here?
| if (!isObservationAvailable()) { | ||
| return Observation.NOOP; | ||
| } | ||
|
|
||
| String loaderId = info.loaderId(); | ||
| if (StringUtils.isBlank(loaderId)) { | ||
| return Observation.NOOP; | ||
| } |
There was a problem hiding this comment.
How many if conditions returning NOOP are needed to squash them into one? :)
| return createActionExecutionObservation(action, null); | ||
| } | ||
|
|
||
| public Observation createActionExecutionObservation(Action action, @Nullable Component triggerComponent) { |
There was a problem hiding this comment.
Why should we pass the target component if it is calculated for components that have it?
| action.run(); | ||
| return null; |
There was a problem hiding this comment.
- Looks not good, to many overloads. Why we don't use native
.observe()method without returning the value? - Moreover, we now have two options for interaction: creating an observation using
create...Observationand performing an observation usingobserve.... This seems inconsistent, and it's very easy to get confused about what's happening where.
| fireViewInitEvent(view); | ||
|
|
||
| stopViewTimerSample(initSample, meterRegistry, ViewLifeCycle.INIT, viewId); | ||
| uiObservationSupport.observeViewLifecycle(view, ViewLifecycle.INIT, () -> fireViewInitEvent(view)); |
There was a problem hiding this comment.
This is where we start the observation, but if we dig deeper...
-> fireViewInitEvent -> ViewControllerUtils.fireEvent * and here we create the observation again. I'm sure this isn't the only place where this happens.
Relates to not consistent API of UiObservationSupport
#5265
Summary
Full UI Observation coverage for Views, Fragments, Actions and DataLoaders.
view.idadded to every modern observation, with the"N/A"sentinel for the anonymous case.fragment.idadded tojmix.ui.actionsandjmix.ui.datafor actions/loaders living inside a fragment.full_loader_id(high-cardinality) for generated loaders:loader.id="<generated>"aggregates, the original id is preserved on the span for trace search.viewtag (jmix.ui.dataTimer) keeps its previous semantics for fragment-owned loaders — existing dashboards do not break.view.close()andStandardDetailView.navigate()are now observed (previously bypassed router listeners).@Deprecated(forRemoval=true)LegacyUiTimerSupport.view.idfor a loader inside a fragment used to contain the fragment id.A paired PR in
jmix-premium(tabbedmode Views) is required for tabbedmode users.Public API changes (vs master)
Additions only:
UiObservationSupport: new methodsobserveViewLifecycle,observeFragmentLifecycle,observeDataLoader,createFragmentLifecycleObservation, and thecreateActionExecutionObservation(Action, Component)overload.LegacyUiTimerSupport— new@Internal @Deprecated(forRemoval=true)bridge.FragmentLifecycleenum.FragmentData.setHostFragment(Fragment)—defaultno-op.AbstractDataComponentsHolder.resolveViewId()/resolveFragmentId()with a default that delegates togetOwnerId().UiProperties.legacyTimerEnabled(@Experimental).DataLoaderMonitoringInfo— newfragmentIdfield.FragmentLifecycleObservationInfo— newviewIdfield plus new constructors.Breaking changes (vs master)
DataLoaderMonitoringInfoandFragmentLifecycleObservationInfochanged arity (new fields). Source-incompatible for external code that constructs these records by hand. Internal usages are updated.jmix.ui.dataview.idtag for a fragment-owned loader now carries the enclosing view id (it used to carry the fragment id because of a pre-existing bug). The legacyjmix.ui.dataTimerviewtag keeps the old value via a back-compat lookup — existing dashboards keep matching.loader.id="<generated>"plus a high-cardinalityfull_loader_id(previously they were dropped entirely as NOOP). Fresh spans/metrics will appear in Jaeger/Prometheus.ResourceRoleModelDetailViewcreate-policy actions now use a stable idcreatePolicy_<ProviderViewClass>instead of a random per-instance id.QA checklist
/actuator/prometheusexposesjmix_ui_views_*,jmix_ui_fragments_*,jmix_ui_actions_*,jmix_ui_data_*.view.id/view.class. Closing viaview.close()also produces a span.fragment.id. Action outside any fragment → nofragment.id.view.id= enclosing view id,fragment.id= fragment id; legacyviewtag = fragment id (back-compat).loader.id="<generated>"(single bucket in Prometheus),full_loader_id= original id (visible in the trace).view.id="N/A", drill-down viaview.class.:flowui:testgreen.jmix.ui.ui-observation-enabled=false→ zero overhead, modern metrics are not emitted.jmix.ui.legacy-timer-enabled=true→ legacyjmix.ui.dataTimer behaves as before.Diagnostic queries