Skip to content

UI observability instrumentation enhancements: data loaders, view lifecycle, fragments #5265#5286

Open
fractal3000 wants to merge 14 commits into
masterfrom
feature/obsevability-enhancements
Open

UI observability instrumentation enhancements: data loaders, view lifecycle, fragments #5265#5286
fractal3000 wants to merge 14 commits into
masterfrom
feature/obsevability-enhancements

Conversation

@fractal3000
Copy link
Copy Markdown
Contributor

@fractal3000 fractal3000 commented May 14, 2026

#5265

Summary

Full UI Observation coverage for Views, Fragments, Actions and DataLoaders.

  • view.id added to every modern observation, with the "N/A" sentinel for the anonymous case.
  • fragment.id added to jmix.ui.actions and jmix.ui.data for 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.
  • Legacy view tag (jmix.ui.data Timer) keeps its previous semantics for fragment-owned loaders — existing dashboards do not break.
  • View close paths via view.close() and StandardDetailView.navigate() are now observed (previously bypassed router listeners).
  • Legacy Timer bridge extracted into @Deprecated(forRemoval=true) LegacyUiTimerSupport.
  • Pre-existing bug fixed: view.id for a loader inside a fragment used to contain the fragment id.
  • 14 commits.

A paired PR in jmix-premium (tabbedmode Views) is required for tabbedmode users.

Public API changes (vs master)

Additions only:

  • UiObservationSupport: new methods observeViewLifecycle, observeFragmentLifecycle, observeDataLoader, createFragmentLifecycleObservation, and the createActionExecutionObservation(Action, Component) overload.
  • LegacyUiTimerSupport — new @Internal @Deprecated(forRemoval=true) bridge.
  • FragmentLifecycle enum.
  • FragmentData.setHostFragment(Fragment)default no-op.
  • AbstractDataComponentsHolder.resolveViewId() / resolveFragmentId() with a default that delegates to getOwnerId().
  • UiProperties.legacyTimerEnabled (@Experimental).
  • DataLoaderMonitoringInfo — new fragmentId field.
  • FragmentLifecycleObservationInfo — new viewId field plus new constructors.

Breaking changes (vs master)

  1. Canonical record constructors of DataLoaderMonitoringInfo and FragmentLifecycleObservationInfo changed arity (new fields). Source-incompatible for external code that constructs these records by hand. Internal usages are updated.
  2. Modern jmix.ui.data view.id tag 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 legacy jmix.ui.data Timer view tag keeps the old value via a back-compat lookup — existing dashboards keep matching.
  3. Generated loaders now emit observation events with loader.id="<generated>" plus a high-cardinality full_loader_id (previously they were dropped entirely as NOOP). Fresh spans/metrics will appear in Jaeger/Prometheus.
  4. ResourceRoleModelDetailView create-policy actions now use a stable id createPolicy_<ProviderViewClass> instead of a random per-instance id.

QA checklist

  • /actuator/prometheus exposes jmix_ui_views_*, jmix_ui_fragments_*, jmix_ui_actions_*, jmix_ui_data_*.
  • Opening / closing a View → spans with view.id / view.class. Closing via view.close() also produces a span.
  • Action inside a fragment → span has fragment.id. Action outside any fragment → no fragment.id.
  • Loader inside a fragment → modern view.id = enclosing view id, fragment.id = fragment id; legacy view tag = fragment id (back-compat).
  • Generated loader → loader.id="<generated>" (single bucket in Prometheus), full_loader_id = original id (visible in the trace).
  • View without an id → view.id="N/A", drill-down via view.class.
  • :flowui:test green.
  • jmix.ui.ui-observation-enabled=false → zero overhead, modern metrics are not emitted.
  • jmix.ui.legacy-timer-enabled=true → legacy jmix.ui.data Timer behaves as before.

Diagnostic queries

# View classes without an id
topk(20, sum by (view_class) (rate(jmix_ui_views_seconds_count{view_id="N/A"}[5m])))

# Anonymous data loads grouped by View
sum by (view_id) (rate(jmix_ui_data_seconds_count{loader_id="<generated>"}[5m]))

fractal3000 and others added 14 commits May 14, 2026 13:45
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>


Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* impact on call sites in {@code UiObservationSupport}.
*/
@Internal
@Deprecated(since = "2.9", forRemoval = true)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2.9 will never be released
This class will be deprecated since 3.0

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry. Fixed

*/
@Internal
@Deprecated(since = "2.9", forRemoval = true)
@SuppressWarnings("removal")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This warning shouldn't be suppressed, I guess

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

* 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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. The property's name currently doesn't reflect its purpose.
    Enabling a 'legacy timer' doesn't clearly refer to timer metrics. A timer can refer to a visual component or a facet. The property needs to be renamed to better reflect its purpose.

Important

  1. 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.

Comment on lines +118 to +120
protected String resolveViewId() {
return getOwnerId();
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By default, you shouldn't return ownerId because not all inheritor types are views

Comment on lines +104 to +105
loader.setMonitoringInfoProvider(dl ->
new DataLoaderMonitoringInfo(resolveViewId(), id, resolveFragmentId()));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should implement such logic in an abstract class. We don't know what our implementations will be.

Comment on lines +160 to +163
Observation observation = createDataLoaderObservation(loader, phase);
return legacyUiTimerSupport.recordDataLoaderTimer(loader, phase,
() -> observation.observe(action));
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First we create an observation, and then we might not use anything at all.
Why we can't check ui property here?

Comment on lines +218 to +225
if (!isObservationAvailable()) {
return Observation.NOOP;
}

String loaderId = info.loaderId();
if (StringUtils.isBlank(loaderId)) {
return Observation.NOOP;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why should we pass the target component if it is calculated for components that have it?

Comment on lines +195 to +196
action.run();
return null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Looks not good, to many overloads. Why we don't use native .observe() method without returning the value?
  2. Moreover, we now have two options for interaction: creating an observation using create...Observation and performing an observation using observe.... 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));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

UI observability instrumentation enhancements: data loaders, view lifecycle, fragments

2 participants