Skip to content

Commit a985354

Browse files
committed
feat: for spans record event and component
When running with tracing enabled eligible spans will contain event name and component class name if found for element.
1 parent b057a03 commit a985354

3 files changed

Lines changed: 224 additions & 4 deletions

File tree

observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/RpcMetricsBinder.java

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,18 @@
88
*/
99
package com.vaadin.observability.micrometer;
1010

11+
import java.util.Optional;
12+
1113
import io.micrometer.core.instrument.MeterRegistry;
1214
import io.micrometer.core.instrument.Timer;
1315
import io.micrometer.observation.Observation;
1416
import io.micrometer.observation.ObservationRegistry;
1517

18+
import com.vaadin.flow.component.Component;
19+
import com.vaadin.flow.component.UI;
20+
import com.vaadin.flow.dom.Element;
21+
import com.vaadin.flow.internal.StateNode;
22+
import com.vaadin.flow.internal.StateTree;
1623
import com.vaadin.flow.server.communication.RpcInvocationEvent;
1724
import com.vaadin.flow.server.communication.RpcInvocationListener;
1825
import com.vaadin.observability.micrometer.trace.ObservationNames;
@@ -33,9 +40,16 @@
3340
* unavailable), the binder falls back to recording the Timer directly.</li>
3441
* </ul>
3542
* <p>
36-
* Tags: {@code type} (RPC invocation type) and {@code outcome}
37-
* ({@code success}/{@code error}). The invocation name and node ID are
38-
* deliberately omitted because they are high-cardinality.
43+
* Timer tags (low cardinality): {@code type} (RPC invocation type) and
44+
* {@code outcome} ({@code success}/{@code error}). The invocation name and node
45+
* ID are deliberately omitted from the Timer tags because they are
46+
* high-cardinality.
47+
* <p>
48+
* When tracing is enabled, the span additionally carries the invocation name
49+
* ({@link ObservationNames#KEY_EVENT_NAME}) and the targeted component class
50+
* ({@link ObservationNames#KEY_COMPONENT}) as high-cardinality key-values.
51+
* These attach to the span only and never become Timer tags, so they enrich
52+
* traces without affecting metric cardinality.
3953
*/
4054
final class RpcMetricsBinder implements RpcInvocationListener {
4155

@@ -79,14 +93,60 @@ public void invocationStarted(RpcInvocationEvent event) {
7993
.createNotStarted(MeterNames.RPC_DURATION,
8094
observationRegistry)
8195
.contextualName(ObservationNames.RPC + "." + type)
82-
.lowCardinalityKeyValue(MeterNames.TAG_TYPE, type).start();
96+
.lowCardinalityKeyValue(MeterNames.TAG_TYPE, type);
97+
98+
// Span-only enrichment: the invocation name (e.g. the DOM event
99+
// name) and the targeted component class. Added as high-cardinality
100+
// key-values so they never leak into the Timer tags.
101+
String name = event.getName();
102+
if (name != null) {
103+
obs.highCardinalityKeyValue(ObservationNames.KEY_EVENT_NAME,
104+
name);
105+
}
106+
resolveComponentType(event)
107+
.ifPresent(component -> obs.highCardinalityKeyValue(
108+
ObservationNames.KEY_COMPONENT, component));
109+
110+
obs.start();
83111
observation.set(obs);
84112
observationScope.set(obs.openScope());
85113
} else {
86114
sample.set(Timer.start(registry));
87115
}
88116
}
89117

118+
/**
119+
* Resolves the class name of the {@link Component} the invocation targets,
120+
* by looking up the target {@code StateNode} in the UI's state tree and
121+
* walking up to the nearest enclosing component. Returns
122+
* {@link Optional#empty()} if the invocation does not target a node, the
123+
* node is no longer attached, or no component can be resolved.
124+
*/
125+
private static Optional<String> resolveComponentType(
126+
RpcInvocationEvent event) {
127+
int nodeId = event.getNodeId();
128+
if (nodeId < 0) {
129+
return Optional.empty();
130+
}
131+
UI ui = event.getUI();
132+
if (ui == null) {
133+
return Optional.empty();
134+
}
135+
try {
136+
StateTree tree = ui.getInternals().getStateTree();
137+
StateNode node = tree.getNodeById(nodeId);
138+
if (node == null) {
139+
return Optional.empty();
140+
}
141+
return Element.get(node).getComponent()
142+
.map(component -> component.getClass().getName());
143+
} catch (RuntimeException e) {
144+
// Resolution is best-effort enrichment; never let it break the
145+
// invocation or the surrounding span.
146+
return Optional.empty();
147+
}
148+
}
149+
90150
@Override
91151
public void invocationFailed(RpcInvocationEvent event, Throwable error) {
92152
errored.set(Boolean.TRUE);

observability-kit-micrometer/src/main/java/com/vaadin/observability/micrometer/trace/ObservationNames.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,21 @@ public final class ObservationNames {
6060
/** Observation/span name for a server-side RPC invocation. */
6161
public static final String RPC = "vaadin.rpc";
6262

63+
/**
64+
* High-cardinality span attribute: the human-readable invocation name (DOM
65+
* event name, invoked method name, navigation location, ...). Span-only; it
66+
* is never added as a Timer tag because of its cardinality.
67+
*/
68+
public static final String KEY_EVENT_NAME = "vaadin.rpc.event_name";
69+
70+
/**
71+
* High-cardinality span attribute: the class name of the Vaadin
72+
* {@code Component} that the invocation targets, when it can be resolved
73+
* from the target node. Span-only; it is never added as a Timer tag because
74+
* of its cardinality.
75+
*/
76+
public static final String KEY_COMPONENT = "vaadin.rpc.component";
77+
6378
private ObservationNames() {
6479
}
6580
}

observability-kit-micrometer/src/test/java/com/vaadin/observability/micrometer/RpcMetricsBinderTest.java

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@
2525
import org.junit.jupiter.api.Test;
2626
import org.mockito.Mockito;
2727

28+
import com.vaadin.flow.component.Component;
29+
import com.vaadin.flow.component.UI;
30+
import com.vaadin.flow.dom.Element;
31+
import com.vaadin.flow.dom.ElementFactory;
2832
import com.vaadin.flow.server.communication.RpcInvocationEvent;
2933
import com.vaadin.observability.micrometer.trace.ObservationNames;
3034

@@ -35,6 +39,7 @@ private static final class RecordingHandler
3539

3640
final List<String> names = new ArrayList<>();
3741
final List<Map<String, String>> tags = new ArrayList<>();
42+
final List<Map<String, String>> highCardinalityTags = new ArrayList<>();
3843
final AtomicBoolean errored = new AtomicBoolean();
3944
Observation.Context lastContext;
4045

@@ -47,6 +52,11 @@ public void onStop(Observation.Context ctx) {
4752
snap.put(kv.getKey(), kv.getValue());
4853
}
4954
tags.add(snap);
55+
Map<String, String> highSnap = new HashMap<>();
56+
for (KeyValue kv : ctx.getHighCardinalityKeyValues()) {
57+
highSnap.put(kv.getKey(), kv.getValue());
58+
}
59+
highCardinalityTags.add(highSnap);
5060
if (ctx.getError() != null) {
5161
errored.set(true);
5262
}
@@ -234,4 +244,139 @@ void errorStateDoesNotBleedIntoSubsequentInvocation() {
234244
Assertions.assertEquals(1L, successTimer.count(),
235245
"error state must not bleed into next invocation");
236246
}
247+
248+
@Test
249+
void observationPathAddsEventNameAsHighCardinalitySpanAttribute() {
250+
SimpleMeterRegistry simpleRegistry = new SimpleMeterRegistry();
251+
ObservationRegistry observationRegistry = ObservationRegistry.create();
252+
RecordingHandler recorder = new RecordingHandler();
253+
observationRegistry.observationConfig()
254+
.observationHandler(
255+
new DefaultMeterObservationHandler(simpleRegistry))
256+
.observationHandler(recorder);
257+
258+
RpcMetricsBinder binder = new RpcMetricsBinder(simpleRegistry,
259+
observationRegistry,
260+
ObservabilitySettings.builder().traces(true).build());
261+
262+
RpcInvocationEvent event = Mockito.mock(RpcInvocationEvent.class);
263+
Mockito.when(event.getType()).thenReturn("event");
264+
Mockito.when(event.getName()).thenReturn("click");
265+
266+
binder.invocationStarted(event);
267+
binder.invocationEnded(event);
268+
269+
Assertions.assertEquals("click",
270+
recorder.highCardinalityTags.get(0)
271+
.get(ObservationNames.KEY_EVENT_NAME),
272+
"span should carry the invocation name as a high-cardinality attribute");
273+
274+
// The event name must not leak into the Timer tags.
275+
Assertions.assertFalse(
276+
recorder.tags.get(0)
277+
.containsKey(ObservationNames.KEY_EVENT_NAME),
278+
"event name must not be a low-cardinality Timer tag");
279+
Timer timer = simpleRegistry.find(MeterNames.RPC_DURATION)
280+
.tag(MeterNames.TAG_TYPE, "event")
281+
.tag(MeterNames.TAG_OUTCOME, MeterNames.OUTCOME_SUCCESS)
282+
.timer();
283+
Assertions.assertNotNull(timer,
284+
"Timer tags should remain type + outcome only");
285+
Assertions.assertEquals(1L, timer.count());
286+
}
287+
288+
@Test
289+
void observationPathOmitsEventNameWhenNull() {
290+
SimpleMeterRegistry simpleRegistry = new SimpleMeterRegistry();
291+
ObservationRegistry observationRegistry = ObservationRegistry.create();
292+
RecordingHandler recorder = new RecordingHandler();
293+
observationRegistry.observationConfig()
294+
.observationHandler(
295+
new DefaultMeterObservationHandler(simpleRegistry))
296+
.observationHandler(recorder);
297+
298+
RpcMetricsBinder binder = new RpcMetricsBinder(simpleRegistry,
299+
observationRegistry,
300+
ObservabilitySettings.builder().traces(true).build());
301+
302+
RpcInvocationEvent event = Mockito.mock(RpcInvocationEvent.class);
303+
Mockito.when(event.getType()).thenReturn("event");
304+
Mockito.when(event.getName()).thenReturn(null);
305+
306+
binder.invocationStarted(event);
307+
binder.invocationEnded(event);
308+
309+
Assertions.assertFalse(
310+
recorder.highCardinalityTags.get(0)
311+
.containsKey(ObservationNames.KEY_EVENT_NAME),
312+
"no event name attribute should be added when getName() is null");
313+
}
314+
315+
@Test
316+
void observationPathAddsComponentClassWhenNodeResolves() {
317+
SimpleMeterRegistry simpleRegistry = new SimpleMeterRegistry();
318+
ObservationRegistry observationRegistry = ObservationRegistry.create();
319+
RecordingHandler recorder = new RecordingHandler();
320+
observationRegistry.observationConfig()
321+
.observationHandler(
322+
new DefaultMeterObservationHandler(simpleRegistry))
323+
.observationHandler(recorder);
324+
325+
RpcMetricsBinder binder = new RpcMetricsBinder(simpleRegistry,
326+
observationRegistry,
327+
ObservabilitySettings.builder().traces(true).build());
328+
329+
// Build a real UI with an attached component so the binder can resolve
330+
// the component class from the target node id.
331+
UI ui = new UI();
332+
Element element = ElementFactory.createDiv();
333+
Component component = new Component(element) {
334+
};
335+
ui.getElement().appendChild(element);
336+
int nodeId = element.getNode().getId();
337+
338+
RpcInvocationEvent event = Mockito.mock(RpcInvocationEvent.class);
339+
Mockito.when(event.getType()).thenReturn("event");
340+
Mockito.when(event.getUI()).thenReturn(ui);
341+
Mockito.when(event.getNodeId()).thenReturn(nodeId);
342+
343+
binder.invocationStarted(event);
344+
binder.invocationEnded(event);
345+
346+
Assertions.assertEquals(component.getClass().getName(),
347+
recorder.highCardinalityTags.get(0)
348+
.get(ObservationNames.KEY_COMPONENT),
349+
"span should carry the targeted component class as a high-cardinality attribute");
350+
Assertions.assertFalse(
351+
recorder.tags.get(0)
352+
.containsKey(ObservationNames.KEY_COMPONENT),
353+
"component class must not be a low-cardinality Timer tag");
354+
}
355+
356+
@Test
357+
void observationPathOmitsComponentWhenNodeIdNegative() {
358+
SimpleMeterRegistry simpleRegistry = new SimpleMeterRegistry();
359+
ObservationRegistry observationRegistry = ObservationRegistry.create();
360+
RecordingHandler recorder = new RecordingHandler();
361+
observationRegistry.observationConfig()
362+
.observationHandler(
363+
new DefaultMeterObservationHandler(simpleRegistry))
364+
.observationHandler(recorder);
365+
366+
RpcMetricsBinder binder = new RpcMetricsBinder(simpleRegistry,
367+
observationRegistry,
368+
ObservabilitySettings.builder().traces(true).build());
369+
370+
RpcInvocationEvent event = Mockito.mock(RpcInvocationEvent.class);
371+
Mockito.when(event.getType()).thenReturn("event");
372+
Mockito.when(event.getNodeId()).thenReturn(-1);
373+
374+
binder.invocationStarted(event);
375+
binder.invocationEnded(event);
376+
377+
Assertions.assertFalse(
378+
recorder.highCardinalityTags.get(0)
379+
.containsKey(ObservationNames.KEY_COMPONENT),
380+
"no component attribute should be added when the invocation targets no node");
381+
}
237382
}

0 commit comments

Comments
 (0)