diff --git a/observability-kit-agent/src/main/java/com/vaadin/extension/Constants.java b/observability-kit-agent/src/main/java/com/vaadin/extension/Constants.java index 76f1b89d..750e4335 100644 --- a/observability-kit-agent/src/main/java/com/vaadin/extension/Constants.java +++ b/observability-kit-agent/src/main/java/com/vaadin/extension/Constants.java @@ -15,6 +15,7 @@ */ public class Constants { public static final String CONFIG_TRACE_LEVEL = "otel.instrumentation.vaadin.trace-level"; + public static final String CONFIG_SPAN_TO_METRICS_ENABLED = "otel.instrumentation.vaadin.span-to-metrics.enabled"; // Vaadin attribute names public static final String SESSION_ID = "vaadin.session.id"; diff --git a/observability-kit-agent/src/main/java/com/vaadin/extension/conf/Configuration.java b/observability-kit-agent/src/main/java/com/vaadin/extension/conf/Configuration.java index 5b680349..93d767ac 100644 --- a/observability-kit-agent/src/main/java/com/vaadin/extension/conf/Configuration.java +++ b/observability-kit-agent/src/main/java/com/vaadin/extension/conf/Configuration.java @@ -9,6 +9,7 @@ */ public class Configuration { public static final TraceLevel TRACE_LEVEL = determineTraceLevel(); + public static final boolean SPAN_TO_METRICS_ENABLED = determineSpanToMetricsEnabled(); private static TraceLevel determineTraceLevel() { String traceLevelString = AgentInstrumentationConfig.get().getString( @@ -20,6 +21,11 @@ private static TraceLevel determineTraceLevel() { } } + private static boolean determineSpanToMetricsEnabled() { + return AgentInstrumentationConfig.get().getBoolean( + Constants.CONFIG_SPAN_TO_METRICS_ENABLED, false); + } + /** * Checks whether a trace level is enabled. Can be used by instrumentations * to check whether some detail should be added to a trace or not. @@ -31,4 +37,14 @@ private static TraceLevel determineTraceLevel() { public static boolean isEnabled(TraceLevel traceLevel) { return TRACE_LEVEL.includes(traceLevel); } + + /** + * Checks whether span-to-metrics recording is enabled. When enabled, + * span duration data will be recorded as metrics. + * + * @return true if span-to-metrics is enabled, false if not + */ + public static boolean isSpanToMetricsEnabled() { + return SPAN_TO_METRICS_ENABLED; + } } diff --git a/observability-kit-agent/src/main/java/com/vaadin/extension/conf/ConfigurationDefaults.java b/observability-kit-agent/src/main/java/com/vaadin/extension/conf/ConfigurationDefaults.java index 2912eaf3..89ce0a72 100644 --- a/observability-kit-agent/src/main/java/com/vaadin/extension/conf/ConfigurationDefaults.java +++ b/observability-kit-agent/src/main/java/com/vaadin/extension/conf/ConfigurationDefaults.java @@ -12,12 +12,13 @@ import static java.util.Collections.emptyMap; import com.vaadin.extension.Constants; - +import com.vaadin.extension.metrics.SpanToMetricProcessor; import com.google.auto.service.AutoService; import io.opentelemetry.instrumentation.api.internal.ConfigPropertiesUtil; import io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizer; import io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider; import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; +import io.opentelemetry.sdk.trace.SpanProcessor; import io.opentelemetry.sdk.trace.export.SpanExporter; import java.io.File; @@ -70,7 +71,8 @@ public int order() { public void customize(AutoConfigurationCustomizer autoConfiguration) { autoConfiguration .addSpanExporterCustomizer(this::setSpanExporter) - .addPropertiesSupplier(this::getDefaultProperties); + .addPropertiesSupplier(this::getDefaultProperties) + .addSpanProcessorCustomizer(this::spanToMetricProcessor); } private SpanExporter setSpanExporter(SpanExporter spanExporter, @@ -80,6 +82,19 @@ private SpanExporter setSpanExporter(SpanExporter spanExporter, return spanExporter; } + SpanProcessor spanToMetricProcessor(SpanProcessor spanProcessor, + ConfigProperties configProperties) { + // Only add SpanToMetricProcessor if explicitly enabled + boolean spanToMetricsEnabled = configProperties.getBoolean( + Constants.CONFIG_SPAN_TO_METRICS_ENABLED, false); + + if (spanToMetricsEnabled) { + return SpanProcessor.composite(spanProcessor, new SpanToMetricProcessor()); + } else { + return spanProcessor; + } + } + private Map getDefaultProperties() { Map properties = new HashMap<>(); final Map defaultconfig = getPropertyFileProperties(); diff --git a/observability-kit-agent/src/main/java/com/vaadin/extension/instrumentation/client/ObjectMapExporter.java b/observability-kit-agent/src/main/java/com/vaadin/extension/instrumentation/client/ObjectMapExporter.java index f19c7dc1..f190c3e7 100644 --- a/observability-kit-agent/src/main/java/com/vaadin/extension/instrumentation/client/ObjectMapExporter.java +++ b/observability-kit-agent/src/main/java/com/vaadin/extension/instrumentation/client/ObjectMapExporter.java @@ -17,7 +17,9 @@ import io.opentelemetry.sdk.trace.data.SpanData; +import com.vaadin.extension.conf.Configuration; import com.vaadin.extension.conf.ConfigurationDefaults; +import com.vaadin.extension.metrics.Metrics; /** * This is a consumer callback that is injected into an ObservabilityHandler @@ -61,6 +63,14 @@ public void accept(String id, Map objectMap) { } } + exportSpans.forEach(span -> { + if (Configuration.isSpanToMetricsEnabled()) { + long durationNanos = span.getEndEpochNanos() - span.getStartEpochNanos(); + Metrics.recordSpanDuration(span.getName(), durationNanos, span.getSpanContext()); + } + }); + ConfigurationDefaults.spanExporter.export(exportSpans); + } } diff --git a/observability-kit-agent/src/main/java/com/vaadin/extension/metrics/Metrics.java b/observability-kit-agent/src/main/java/com/vaadin/extension/metrics/Metrics.java index 9ab74598..cbd9b1f4 100644 --- a/observability-kit-agent/src/main/java/com/vaadin/extension/metrics/Metrics.java +++ b/observability-kit-agent/src/main/java/com/vaadin/extension/metrics/Metrics.java @@ -13,8 +13,11 @@ import com.vaadin.flow.server.VaadinSession; import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.metrics.LongHistogram; import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.api.trace.SpanContext; import java.time.Duration; import java.time.Instant; @@ -30,11 +33,11 @@ public class Metrics { private static final Map sessionStarts = new ConcurrentHashMap<>(); private static LongHistogram sessionDurationMeasurement; - + private static LongHistogram spanDurationHistogram; private static InstantProvider instantProvider = Instant::now; static void setInstantProvider(InstantProvider instantProvider) { - Metrics.instantProvider = instantProvider; + Metrics.instantProvider = instantProvider; } public static void ensureMetricsRegistered() { @@ -62,6 +65,13 @@ public static void ensureMetricsRegistered() { .histogramBuilder("vaadin.session.duration") .setDescription("Duration of sessions").setUnit("seconds") .ofLongs().build(); + + spanDurationHistogram = meter + .histogramBuilder("vaadin.span.duration") + .setDescription("Duration of spans in milliseconds") + .setUnit("ms") + .ofLongs() + .build(); } } @@ -111,4 +121,15 @@ private static String getSessionIdentifier(VaadinSession session) { interface InstantProvider { Instant get(); } -} + + public static void recordSpanDuration(String spanName, long durationNanos, SpanContext spanContext) { + long durationMs = durationNanos / 1000000; + Metrics.ensureMetricsRegistered(); + Attributes attributes = Attributes.of( + AttributeKey.stringKey("span.name"), spanName + ); + spanDurationHistogram.record(durationMs, attributes); + } + + +} \ No newline at end of file diff --git a/observability-kit-agent/src/main/java/com/vaadin/extension/metrics/SpanToMetricProcessor.java b/observability-kit-agent/src/main/java/com/vaadin/extension/metrics/SpanToMetricProcessor.java new file mode 100644 index 00000000..97717301 --- /dev/null +++ b/observability-kit-agent/src/main/java/com/vaadin/extension/metrics/SpanToMetricProcessor.java @@ -0,0 +1,32 @@ +package com.vaadin.extension.metrics; + +import io.opentelemetry.context.Context; +import io.opentelemetry.sdk.trace.ReadWriteSpan; +import io.opentelemetry.sdk.trace.ReadableSpan; +import io.opentelemetry.sdk.trace.SpanProcessor; + +public class SpanToMetricProcessor implements SpanProcessor { + + @Override + public boolean isEndRequired() { + return true; + } + + @Override + public boolean isStartRequired() { + return false; + } + + @Override + public void onEnd(ReadableSpan span) { + long latencyNanos = span.getLatencyNanos(); + Metrics.recordSpanDuration(span.getName(), latencyNanos, span.getSpanContext()); + } + + @Override + public void onStart(Context arg0, ReadWriteSpan arg1) { + + } + + +} diff --git a/observability-kit-agent/src/test/java/com/vaadin/extension/conf/ConfigurationDefaultsTest.java b/observability-kit-agent/src/test/java/com/vaadin/extension/conf/ConfigurationDefaultsTest.java new file mode 100644 index 00000000..a0807b94 --- /dev/null +++ b/observability-kit-agent/src/test/java/com/vaadin/extension/conf/ConfigurationDefaultsTest.java @@ -0,0 +1,64 @@ +package com.vaadin.extension.conf; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.vaadin.extension.Constants; + +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; +import io.opentelemetry.sdk.trace.SpanProcessor; +import org.junit.jupiter.api.Test; + +class ConfigurationDefaultsTest { + + @Test + public void spanToMetricProcessor_whenEnabled_addsSpanToMetricProcessor() { + ConfigurationDefaults configDefaults = new ConfigurationDefaults(); + ConfigProperties configProperties = mock(ConfigProperties.class); + SpanProcessor originalProcessor = mock(SpanProcessor.class); + + // Mock configuration to return true for span-to-metrics enabled + when(configProperties.getBoolean(Constants.CONFIG_SPAN_TO_METRICS_ENABLED, false)) + .thenReturn(true); + + SpanProcessor result = configDefaults.spanToMetricProcessor(originalProcessor, configProperties); + + // Should return a composite processor (different from the original) + assertNotSame(originalProcessor, result, "Should return composite processor when enabled"); + } + + @Test + public void spanToMetricProcessor_whenDisabled_returnsOriginalProcessor() { + ConfigurationDefaults configDefaults = new ConfigurationDefaults(); + ConfigProperties configProperties = mock(ConfigProperties.class); + SpanProcessor originalProcessor = mock(SpanProcessor.class); + + // Mock configuration to return false for span-to-metrics enabled (default) + when(configProperties.getBoolean(Constants.CONFIG_SPAN_TO_METRICS_ENABLED, false)) + .thenReturn(false); + + SpanProcessor result = configDefaults.spanToMetricProcessor(originalProcessor, configProperties); + + // Should return the same original processor + assertSame(originalProcessor, result, "Should return original processor when disabled"); + } + + @Test + public void spanToMetricProcessor_whenNotConfigured_defaultsToDisabled() { + ConfigurationDefaults configDefaults = new ConfigurationDefaults(); + ConfigProperties configProperties = mock(ConfigProperties.class); + SpanProcessor originalProcessor = mock(SpanProcessor.class); + + // Mock configuration to use default value (false) when not explicitly set + when(configProperties.getBoolean(Constants.CONFIG_SPAN_TO_METRICS_ENABLED, false)) + .thenReturn(false); // This simulates the default case + + SpanProcessor result = configDefaults.spanToMetricProcessor(originalProcessor, configProperties); + + // Should return the same original processor (disabled by default) + assertSame(originalProcessor, result, "Should default to disabled when not configured"); + } +} \ No newline at end of file diff --git a/observability-kit-agent/src/test/java/com/vaadin/extension/instrumentation/AbstractInstrumentationTest.java b/observability-kit-agent/src/test/java/com/vaadin/extension/instrumentation/AbstractInstrumentationTest.java index a8cee418..6ee0be0a 100644 --- a/observability-kit-agent/src/test/java/com/vaadin/extension/instrumentation/AbstractInstrumentationTest.java +++ b/observability-kit-agent/src/test/java/com/vaadin/extension/instrumentation/AbstractInstrumentationTest.java @@ -47,7 +47,7 @@ public abstract class AbstractInstrumentationTest { private VaadinSession mockSession; private VaadinService mockService; private Scope sessionScope; - private MockedStatic ConfigurationMock; + protected MockedStatic ConfigurationMock; private TraceLevel configuredTraceLevel; public UI getMockUI() { @@ -106,6 +106,8 @@ public void setupMocks() { TraceLevel level = invocation.getArgument(0); return configuredTraceLevel.includes(level); }); + ConfigurationMock.when(() -> Configuration.isSpanToMetricsEnabled()) + .thenReturn(true); } @AfterEach diff --git a/observability-kit-agent/src/test/java/com/vaadin/extension/instrumentation/client/ObjectMapExporterTest.java b/observability-kit-agent/src/test/java/com/vaadin/extension/instrumentation/client/ObjectMapExporterTest.java index 07e1d2b2..50b838c8 100644 --- a/observability-kit-agent/src/test/java/com/vaadin/extension/instrumentation/client/ObjectMapExporterTest.java +++ b/observability-kit-agent/src/test/java/com/vaadin/extension/instrumentation/client/ObjectMapExporterTest.java @@ -3,6 +3,8 @@ import java.util.Map; import com.fasterxml.jackson.databind.ObjectMapper; +import com.vaadin.extension.conf.Configuration; +import io.opentelemetry.sdk.metrics.data.HistogramPointData; import io.opentelemetry.sdk.trace.data.SpanData; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; @@ -253,4 +255,119 @@ public void accept_emptyJson_exceptionIsThrown() { fail(e); } } + + @Test + public void spanToMetricsRespectedWhenDisabled() { + try { + // First, enable span-to-metrics and create a baseline metric + ConfigurationMock.when(() -> Configuration.isSpanToMetricsEnabled()) + .thenReturn(true); + + String jsonString = """ + { + "resourceSpans": [ + { + "resource": { + "attributes": [ + { + "key": "service.name", + "value": {"stringValue": "test_service"} + } + ] + }, + "scopeSpans": [ + { + "scope": { + "name": "test-scope" + }, + "spans": [ + { + "traceId": "12345678901234567890123456789012", + "spanId": "1234567890123456", + "name": "baseline-span", + "kind": 1, + "startTimeUnixNano": 1674542404352000000, + "endTimeUnixNano": 1674542405301000200, + "attributes": [] + } + ] + } + ] + } + ] + } + """; + + ObjectMapper objectMapper = new ObjectMapper(); + @SuppressWarnings("unchecked") + Map objectMap = objectMapper.readValue(jsonString, Map.class); + + // Process the spans with enabled configuration to create the metric + new ObjectMapExporter().accept("foo", objectMap); + + // Now get initial metrics state (after creating the metric) + HistogramPointData initialSpanMetric = getLastHistogramMetricValue("vaadin.span.duration"); + long initialSpanCount = initialSpanMetric.getCount(); + double initialSpanSum = initialSpanMetric.getSum(); + + // Now disable span-to-metrics + ConfigurationMock.when(() -> Configuration.isSpanToMetricsEnabled()) + .thenReturn(false); + + String jsonString2 = """ + { + "resourceSpans": [ + { + "resource": { + "attributes": [ + { + "key": "service.name", + "value": {"stringValue": "test_service"} + } + ] + }, + "scopeSpans": [ + { + "scope": { + "name": "test-scope" + }, + "spans": [ + { + "traceId": "12345678901234567890123456789013", + "spanId": "1234567890123457", + "name": "test-span-disabled", + "kind": 1, + "startTimeUnixNano": 1674542406352000000, + "endTimeUnixNano": 1674542407301000200, + "attributes": [] + } + ] + } + ] + } + ] + } + """; + + @SuppressWarnings("unchecked") + Map objectMap2 = objectMapper.readValue(jsonString2, Map.class); + + // Process the spans with disabled configuration + new ObjectMapExporter().accept("foo", objectMap2); + + // Verify no new span metrics were recorded + HistogramPointData finalSpanMetric = getLastHistogramMetricValue("vaadin.span.duration"); + assertEquals(initialSpanCount, finalSpanMetric.getCount(), + "Span count should not increase when span-to-metrics is disabled"); + assertEquals(initialSpanSum, finalSpanMetric.getSum(), 0, + "Span sum should not increase when span-to-metrics is disabled"); + + } catch (Exception e) { + fail(e); + } finally { + // Re-enable for other tests + ConfigurationMock.when(() -> Configuration.isSpanToMetricsEnabled()) + .thenReturn(true); + } + } } diff --git a/observability-kit-agent/src/test/java/com/vaadin/extension/metrics/MetricsTest.java b/observability-kit-agent/src/test/java/com/vaadin/extension/metrics/MetricsTest.java index cbca3971..7f0abf0f 100644 --- a/observability-kit-agent/src/test/java/com/vaadin/extension/metrics/MetricsTest.java +++ b/observability-kit-agent/src/test/java/com/vaadin/extension/metrics/MetricsTest.java @@ -1,11 +1,20 @@ package com.vaadin.extension.metrics; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import com.vaadin.extension.conf.Configuration; import com.vaadin.extension.instrumentation.AbstractInstrumentationTest; import com.vaadin.extension.instrumentation.server.VaadinSessionInstrumentation; +import com.vaadin.extension.metrics.SpanToMetricProcessor; import com.vaadin.flow.server.VaadinSession; +import io.opentelemetry.sdk.trace.ReadableSpan; + +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.TraceFlags; +import io.opentelemetry.api.trace.TraceState; import io.opentelemetry.sdk.metrics.data.HistogramPointData; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -83,10 +92,86 @@ public void sessionDuration() { assertEquals(1500, metricValue.getSum(), 0); } + @Test + public void recordSpanDuration() { + SpanContext spanContext = createMockSpanContext(); + String spanName = "test-span"; + long durationNanos = 150_000_000; // 150ms in nanoseconds + + Metrics.recordSpanDuration(spanName, durationNanos, spanContext); + + HistogramPointData metricValue = getLastHistogramMetricValue("vaadin.span.duration"); + assertEquals(150, metricValue.getSum(), 0); // Should be 150ms + assertEquals(1, metricValue.getCount()); + + // Verify span name attribute is recorded + assertTrue(metricValue.getAttributes().asMap().containsKey(io.opentelemetry.api.common.AttributeKey.stringKey("span.name"))); + assertEquals(spanName, metricValue.getAttributes().get(io.opentelemetry.api.common.AttributeKey.stringKey("span.name"))); + } + + @Test + public void spanToMetricsConfigurationRespected() { + // Test that the configuration mock works as expected + + // Disable span-to-metrics + ConfigurationMock.when(() -> Configuration.isSpanToMetricsEnabled()) + .thenReturn(false); + + assertFalse(Configuration.isSpanToMetricsEnabled(), + "Configuration should reflect disabled state"); + + // Enable span-to-metrics + ConfigurationMock.when(() -> Configuration.isSpanToMetricsEnabled()) + .thenReturn(true); + + assertTrue(Configuration.isSpanToMetricsEnabled(), + "Configuration should reflect enabled state"); + + // Reset to enabled for other tests + ConfigurationMock.when(() -> Configuration.isSpanToMetricsEnabled()) + .thenReturn(true); + } + + @Test + public void recordSpanDurationVariousSpanNames() { + SpanContext spanContext = createMockSpanContext(); + + // Test recording spans with different names (including ones that used to trigger document load) + String[] spanNames = { + "regular-operation", + "documentLoad", + "navigate-to-page", + "Navigate-Home" + }; + + // Record spans and verify basic functionality + for (String spanName : spanNames) { + Metrics.recordSpanDuration(spanName, 100_000_000, spanContext); // 100ms in nanoseconds + } + + // Verify basic functionality - that spans are recorded in the histogram + HistogramPointData spanMetric = getLastHistogramMetricValue("vaadin.span.duration"); + assertTrue(spanMetric.getCount() >= 1, "Should have recorded at least 1 span"); + assertTrue(spanMetric.getSum() >= 100, "Should have recorded at least 100ms total"); + + // Verify that span name attribute key exists (value will be from the last recorded span) + assertTrue(spanMetric.getAttributes().asMap().containsKey(io.opentelemetry.api.common.AttributeKey.stringKey("span.name")), + "Span name attribute should be present"); + } + private static VaadinSession mockSession(String sessionId) { VaadinSession session = Mockito.mock(VaadinSession.class); Mockito.when(session.getPushId()).thenReturn(sessionId); return session; } + + private static SpanContext createMockSpanContext() { + return SpanContext.create( + "12345678901234567890123456789012", // traceId (32 hex chars) + "1234567890123456", // spanId (16 hex chars) + TraceFlags.getSampled(), + TraceState.getDefault() + ); + } }