From 14f902b0b15ce8486c3adb4b2b8b60a97a3e85a6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 Aug 2025 00:45:19 +0000 Subject: [PATCH 1/5] Initial plan From 2377a88e98252fb55aaf905b35705b81bf4937e3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 Aug 2025 01:04:22 +0000 Subject: [PATCH 2/5] Add Jaeger OpenTelemetry UI feature - core implementation Co-authored-by: phrocker <1781585+phrocker@users.noreply.github.com> --- .../api/TelemetryApiController.java | 208 +++++++++ .../controllers/view/TelemetryController.java | 29 ++ api/src/main/resources/application.properties | 3 + .../templates/fragments/sidebar.html | 13 +- .../resources/templates/sso/telemetry.html | 404 ++++++++++++++++++ .../api/TelemetryApiControllerTest.java | 77 ++++ 6 files changed, 730 insertions(+), 4 deletions(-) create mode 100644 api/src/main/java/io/sentrius/sso/controllers/api/TelemetryApiController.java create mode 100644 api/src/main/java/io/sentrius/sso/controllers/view/TelemetryController.java create mode 100644 api/src/main/resources/templates/sso/telemetry.html create mode 100644 api/src/test/java/io/sentrius/sso/controllers/api/TelemetryApiControllerTest.java diff --git a/api/src/main/java/io/sentrius/sso/controllers/api/TelemetryApiController.java b/api/src/main/java/io/sentrius/sso/controllers/api/TelemetryApiController.java new file mode 100644 index 00000000..907931a3 --- /dev/null +++ b/api/src/main/java/io/sentrius/sso/controllers/api/TelemetryApiController.java @@ -0,0 +1,208 @@ +package io.sentrius.sso.controllers.api; + +import io.sentrius.sso.core.config.SystemOptions; +import io.sentrius.sso.core.controllers.BaseController; +import io.sentrius.sso.core.services.ErrorOutputService; +import io.sentrius.sso.core.services.UserService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.client.RestTemplate; + +import java.util.*; + +@Slf4j +@RestController +@RequestMapping("/api/v1/telemetry") +public class TelemetryApiController extends BaseController { + + private final RestTemplate restTemplate = new RestTemplate(); + + @Value("${jaeger.query.url:http://localhost:16686}") + private String jaegerQueryUrl; + + protected TelemetryApiController( + UserService userService, + SystemOptions systemOptions, + ErrorOutputService errorOutputService + ) { + super(userService, systemOptions, errorOutputService); + } + + @GetMapping("/traces") + public ResponseEntity getTraces( + @RequestParam(required = false) String service, + @RequestParam(required = false) String operation, + @RequestParam(defaultValue = "1h") String lookback, + @RequestParam(required = false) Long minDuration, + @RequestParam(required = false) Long maxDuration, + @RequestParam(required = false) String tags + ) { + try { + String jaegerApiUrl = buildJaegerApiUrl(service, operation, lookback, minDuration, maxDuration, tags); + log.info("Querying Jaeger at: {}", jaegerApiUrl); + + HttpHeaders headers = new HttpHeaders(); + headers.set("Accept", "application/json"); + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange( + jaegerApiUrl, + HttpMethod.GET, + entity, + Map.class + ); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + Map jaegerResponse = response.getBody(); + List> processedTraces = processJaegerResponse(jaegerResponse); + + Map result = new HashMap<>(); + result.put("traces", processedTraces); + result.put("status", "success"); + result.put("count", processedTraces.size()); + + return ResponseEntity.ok(result); + } else { + return ResponseEntity.status(response.getStatusCode()) + .body(Map.of("error", "Failed to query Jaeger", "status", "error")); + } + + } catch (Exception e) { + log.error("Error querying Jaeger traces", e); + return ResponseEntity.internalServerError() + .body(Map.of("error", "Internal server error: " + e.getMessage(), "status", "error")); + } + } + + @GetMapping("/services") + public ResponseEntity getServices() { + try { + String servicesUrl = jaegerQueryUrl + "/api/services"; + log.info("Fetching services from Jaeger at: {}", servicesUrl); + + HttpHeaders headers = new HttpHeaders(); + headers.set("Accept", "application/json"); + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange( + servicesUrl, + HttpMethod.GET, + entity, + Map.class + ); + + return ResponseEntity.ok(response.getBody()); + + } catch (Exception e) { + log.error("Error fetching services from Jaeger", e); + return ResponseEntity.internalServerError() + .body(Map.of("error", "Failed to fetch services: " + e.getMessage(), "status", "error")); + } + } + + @GetMapping("/trace/{traceId}") + public ResponseEntity getTrace(@PathVariable String traceId) { + try { + String traceUrl = jaegerQueryUrl + "/api/traces/" + traceId; + log.info("Fetching trace from Jaeger at: {}", traceUrl); + + HttpHeaders headers = new HttpHeaders(); + headers.set("Accept", "application/json"); + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange( + traceUrl, + HttpMethod.GET, + entity, + Map.class + ); + + return ResponseEntity.ok(response.getBody()); + + } catch (Exception e) { + log.error("Error fetching trace from Jaeger", e); + return ResponseEntity.internalServerError() + .body(Map.of("error", "Failed to fetch trace: " + e.getMessage(), "status", "error")); + } + } + + private String buildJaegerApiUrl(String service, String operation, String lookback, + Long minDuration, Long maxDuration, String tags) { + StringBuilder url = new StringBuilder(jaegerQueryUrl + "/api/traces?"); + + if (service != null && !service.isEmpty()) { + url.append("service=").append(service).append("&"); + } + + if (operation != null && !operation.isEmpty()) { + url.append("operation=").append(operation).append("&"); + } + + url.append("lookback=").append(lookback).append("&"); + + if (minDuration != null) { + url.append("minDuration=").append(minDuration).append("us&"); + } + + if (maxDuration != null) { + url.append("maxDuration=").append(maxDuration).append("us&"); + } + + if (tags != null && !tags.isEmpty()) { + url.append("tags=").append(tags).append("&"); + } + + // Add limit to prevent too many results + url.append("limit=100"); + + return url.toString(); + } + + private List> processJaegerResponse(Map jaegerResponse) { + List> processedTraces = new ArrayList<>(); + + try { + Object dataObj = jaegerResponse.get("data"); + if (dataObj instanceof List) { + List> traces = (List>) dataObj; + + for (Map trace : traces) { + Map processedTrace = new HashMap<>(); + processedTrace.put("traceID", trace.get("traceID")); + + // Calculate duration and other metrics + Object spansObj = trace.get("spans"); + if (spansObj instanceof List) { + List> spans = (List>) spansObj; + processedTrace.put("spans", spans); + processedTrace.put("spanCount", spans.size()); + + // Find root span for start time and total duration + Optional> rootSpan = spans.stream() + .filter(span -> { + Object refs = span.get("references"); + return refs == null || (refs instanceof List && ((List) refs).isEmpty()); + }) + .findFirst(); + + if (rootSpan.isPresent()) { + processedTrace.put("startTime", rootSpan.get().get("startTime")); + processedTrace.put("duration", rootSpan.get().get("duration")); + } + } + + processedTraces.add(processedTrace); + } + } + } catch (Exception e) { + log.warn("Error processing Jaeger response", e); + } + + return processedTraces; + } +} \ No newline at end of file diff --git a/api/src/main/java/io/sentrius/sso/controllers/view/TelemetryController.java b/api/src/main/java/io/sentrius/sso/controllers/view/TelemetryController.java new file mode 100644 index 00000000..64e5ed92 --- /dev/null +++ b/api/src/main/java/io/sentrius/sso/controllers/view/TelemetryController.java @@ -0,0 +1,29 @@ +package io.sentrius.sso.controllers.view; + +import io.sentrius.sso.core.config.SystemOptions; +import io.sentrius.sso.core.controllers.BaseController; +import io.sentrius.sso.core.services.ErrorOutputService; +import io.sentrius.sso.core.services.UserService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@Slf4j +@Controller +@RequestMapping("/sso") +public class TelemetryController extends BaseController { + + protected TelemetryController( + UserService userService, + SystemOptions systemOptions, + ErrorOutputService errorOutputService + ) { + super(userService, systemOptions, errorOutputService); + } + + @GetMapping("/v1/telemetry") + public String telemetry() { + return "sso/telemetry"; + } +} \ No newline at end of file diff --git a/api/src/main/resources/application.properties b/api/src/main/resources/application.properties index 27d52ade..63d525dd 100644 --- a/api/src/main/resources/application.properties +++ b/api/src/main/resources/application.properties @@ -89,6 +89,9 @@ otel.resource.attributes.service.name=sentrius-api otel.traces.sampler=always_on otel.exporter.otlp.timeout=10s +# Jaeger Query API URL +jaeger.query.url=${JAEGER_QUERY_URL:http://localhost:16686} + sentrius.agent.register.bootstrap.allow=true sentrius.agent.bootstrap.policy=default-policy.yaml # Optional: set the identity lifetime diff --git a/api/src/main/resources/templates/fragments/sidebar.html b/api/src/main/resources/templates/fragments/sidebar.html index 48ea2ceb..6238bafb 100755 --- a/api/src/main/resources/templates/fragments/sidebar.html +++ b/api/src/main/resources/templates/fragments/sidebar.html @@ -55,10 +55,15 @@ Manage Agent/Users -
  • - - Trust Policies - +
  • + + Trust Policies + +
  • +
  • + + Telemetry +

  • diff --git a/api/src/main/resources/templates/sso/telemetry.html b/api/src/main/resources/templates/sso/telemetry.html new file mode 100644 index 00000000..be3161d7 --- /dev/null +++ b/api/src/main/resources/templates/sso/telemetry.html @@ -0,0 +1,404 @@ + + + + + [[${systemOptions.systemLogoName}]] - Telemetry + + + + + +
    +
    + +
    + + +
    +
    +

    OpenTelemetry Traces

    + + +
    +
    +
    Query Jaeger Traces
    +
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + + + + + + + + +
    +
    +
    +
    + + + + \ No newline at end of file diff --git a/api/src/test/java/io/sentrius/sso/controllers/api/TelemetryApiControllerTest.java b/api/src/test/java/io/sentrius/sso/controllers/api/TelemetryApiControllerTest.java new file mode 100644 index 00000000..771c7cc4 --- /dev/null +++ b/api/src/test/java/io/sentrius/sso/controllers/api/TelemetryApiControllerTest.java @@ -0,0 +1,77 @@ +package io.sentrius.sso.controllers.api; + +import io.sentrius.sso.core.config.SystemOptions; +import io.sentrius.sso.core.services.ErrorOutputService; +import io.sentrius.sso.core.services.UserService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(MockitoExtension.class) +class TelemetryApiControllerTest { + + @Mock + private UserService userService; + + @Mock + private SystemOptions systemOptions; + + @Mock + private ErrorOutputService errorOutputService; + + @InjectMocks + private TelemetryApiController telemetryApiController; + + @Test + void testGetTracesHandlesInvalidJaegerUrl() { + // Set an invalid Jaeger URL to test error handling + ReflectionTestUtils.setField(telemetryApiController, "jaegerQueryUrl", "invalid-url"); + + ResponseEntity response = telemetryApiController.getTraces( + "test-service", null, "1h", null, null, null + ); + + assertEquals(500, response.getStatusCode().value()); + assertNotNull(response.getBody()); + } + + @Test + void testGetTracesWithValidParameters() { + // Set a mock Jaeger URL + ReflectionTestUtils.setField(telemetryApiController, "jaegerQueryUrl", "http://localhost:16686"); + + // This test will fail to connect to Jaeger, but should not throw exception + ResponseEntity response = telemetryApiController.getTraces( + "sentrius-api", "test-operation", "1h", 1000L, 10000L, "error=true" + ); + + // Should return 500 since we can't connect to real Jaeger, but shouldn't crash + assertEquals(500, response.getStatusCode().value()); + } + + @Test + void testGetServicesWithInvalidUrl() { + ReflectionTestUtils.setField(telemetryApiController, "jaegerQueryUrl", "invalid-url"); + + ResponseEntity response = telemetryApiController.getServices(); + + assertEquals(500, response.getStatusCode().value()); + assertNotNull(response.getBody()); + } + + @Test + void testGetTraceByIdWithInvalidUrl() { + ReflectionTestUtils.setField(telemetryApiController, "jaegerQueryUrl", "invalid-url"); + + ResponseEntity response = telemetryApiController.getTrace("test-trace-id"); + + assertEquals(500, response.getStatusCode().value()); + assertNotNull(response.getBody()); + } +} \ No newline at end of file From 27329917767a4c196a78f222082c2c05fc8c1d18 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 Aug 2025 01:11:54 +0000 Subject: [PATCH 3/5] Complete Jaeger OpenTelemetry UI feature with mock data and tests Co-authored-by: phrocker <1781585+phrocker@users.noreply.github.com> --- .../api/MockJaegerApiController.java | 101 ++++++++++++++++++ .../resources/templates/sso/telemetry.html | 44 +++++++- .../api/MockJaegerApiControllerTest.java | 60 +++++++++++ .../view/TelemetryControllerTest.java | 34 ++++++ 4 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 api/src/main/java/io/sentrius/sso/controllers/api/MockJaegerApiController.java create mode 100644 api/src/test/java/io/sentrius/sso/controllers/api/MockJaegerApiControllerTest.java create mode 100644 api/src/test/java/io/sentrius/sso/controllers/view/TelemetryControllerTest.java diff --git a/api/src/main/java/io/sentrius/sso/controllers/api/MockJaegerApiController.java b/api/src/main/java/io/sentrius/sso/controllers/api/MockJaegerApiController.java new file mode 100644 index 00000000..d481451b --- /dev/null +++ b/api/src/main/java/io/sentrius/sso/controllers/api/MockJaegerApiController.java @@ -0,0 +1,101 @@ +package io.sentrius.sso.controllers.api; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Instant; +import java.util.*; + +/** + * Mock Jaeger API for testing telemetry UI when actual Jaeger is not available. + * This controller provides sample trace data for demonstration purposes. + */ +@RestController +@RequestMapping("/mock/jaeger/api") +public class MockJaegerApiController { + + @GetMapping("/services") + public ResponseEntity getMockServices() { + Map response = new HashMap<>(); + List services = Arrays.asList( + "sentrius-api", + "sentrius-dataplane", + "sentrius-agent-proxy", + "sentrius-integration-proxy" + ); + response.put("data", services); + return ResponseEntity.ok(response); + } + + @GetMapping("/traces") + public ResponseEntity getMockTraces() { + Map response = new HashMap<>(); + List> traces = new ArrayList<>(); + + // Create sample trace 1 + Map trace1 = createSampleTrace( + "1234567890abcdef", + "sentrius-api", + Arrays.asList("HTTP GET /sso/v1/dashboard", "Database Query", "Cache Lookup"), + 150000 // 150ms + ); + + // Create sample trace 2 + Map trace2 = createSampleTrace( + "fedcba0987654321", + "sentrius-api", + Arrays.asList("HTTP POST /api/v1/users", "User Validation", "Database Insert", "Send Notification"), + 320000 // 320ms + ); + + traces.add(trace1); + traces.add(trace2); + + response.put("data", traces); + return ResponseEntity.ok(response); + } + + private Map createSampleTrace(String traceId, String serviceName, List operations, long totalDuration) { + Map trace = new HashMap<>(); + trace.put("traceID", traceId); + + List> spans = new ArrayList<>(); + long startTime = Instant.now().toEpochMilli() * 1000; // Convert to microseconds + long currentTime = startTime; + + for (int i = 0; i < operations.size(); i++) { + Map span = new HashMap<>(); + span.put("spanID", String.format("%016x", i + 1)); + span.put("operationName", operations.get(i)); + span.put("startTime", currentTime); + + long duration = totalDuration / operations.size(); + span.put("duration", duration); + + // Create process info + Map process = new HashMap<>(); + process.put("serviceName", serviceName); + span.put("process", process); + + // Add references for child spans + if (i > 0) { + List> references = new ArrayList<>(); + Map ref = new HashMap<>(); + ref.put("refType", "CHILD_OF"); + ref.put("spanID", String.format("%016x", i)); // Reference parent + references.add(ref); + span.put("references", references); + } else { + span.put("references", new ArrayList<>()); + } + + spans.add(span); + currentTime += duration; + } + + trace.put("spans", spans); + return trace; + } +} \ No newline at end of file diff --git a/api/src/main/resources/templates/sso/telemetry.html b/api/src/main/resources/templates/sso/telemetry.html index be3161d7..9cff887f 100644 --- a/api/src/main/resources/templates/sso/telemetry.html +++ b/api/src/main/resources/templates/sso/telemetry.html @@ -81,6 +81,7 @@
    Query Jaeger Traces
    + @@ -368,6 +369,28 @@
    Call Graph Visualization
    errorDiv.style.display = 'block'; } +async function loadMockData() { + document.getElementById('loading').style.display = 'block'; + document.getElementById('results-section').style.display = 'none'; + document.getElementById('error-display').style.display = 'none'; + + try { + const response = await fetch('/mock/jaeger/api/traces'); + if (response.ok) { + const data = await response.json(); + currentTraces = data.data || []; + displayResults({traces: currentTraces, status: 'success', count: currentTraces.length}); + } else { + showError('Failed to load mock data'); + } + } catch (error) { + console.error('Error loading mock data:', error); + showError('Error loading mock data: ' + error.message); + } finally { + document.getElementById('loading').style.display = 'none'; + } +} + // Load services on page load document.addEventListener('DOMContentLoaded', function() { loadServices(); @@ -396,7 +419,26 @@
    Call Graph Visualization
    } } } catch (error) { - console.warn('Could not load services from Jaeger:', error); + console.warn('Could not load services from Jaeger, trying mock data:', error); + // Try to load mock services + try { + const mockResponse = await fetch('/mock/jaeger/api/services'); + if (mockResponse.ok) { + const mockData = await mockResponse.json(); + const serviceSelect = document.getElementById('service-name'); + + if (mockData.data && Array.isArray(mockData.data)) { + mockData.data.forEach(service => { + const option = document.createElement('option'); + option.value = service; + option.textContent = service + ' (mock)'; + serviceSelect.appendChild(option); + }); + } + } + } catch (mockError) { + console.warn('Could not load mock services either:', mockError); + } } } diff --git a/api/src/test/java/io/sentrius/sso/controllers/api/MockJaegerApiControllerTest.java b/api/src/test/java/io/sentrius/sso/controllers/api/MockJaegerApiControllerTest.java new file mode 100644 index 00000000..09d47413 --- /dev/null +++ b/api/src/test/java/io/sentrius/sso/controllers/api/MockJaegerApiControllerTest.java @@ -0,0 +1,60 @@ +package io.sentrius.sso.controllers.api; + +import org.junit.jupiter.api.Test; +import org.springframework.http.ResponseEntity; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class MockJaegerApiControllerTest { + + private MockJaegerApiController mockController = new MockJaegerApiController(); + + @Test + void testGetMockServices() { + ResponseEntity response = mockController.getMockServices(); + + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + + Map body = (Map) response.getBody(); + assertTrue(body.containsKey("data")); + + List services = (List) body.get("data"); + assertEquals(4, services.size()); + assertTrue(services.contains("sentrius-api")); + assertTrue(services.contains("sentrius-dataplane")); + } + + @Test + void testGetMockTraces() { + ResponseEntity response = mockController.getMockTraces(); + + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + + Map body = (Map) response.getBody(); + assertTrue(body.containsKey("data")); + + List> traces = (List>) body.get("data"); + assertEquals(2, traces.size()); + + // Verify trace structure + Map trace = traces.get(0); + assertTrue(trace.containsKey("traceID")); + assertTrue(trace.containsKey("spans")); + + List> spans = (List>) trace.get("spans"); + assertTrue(spans.size() > 0); + + // Verify span structure + Map span = spans.get(0); + assertTrue(span.containsKey("spanID")); + assertTrue(span.containsKey("operationName")); + assertTrue(span.containsKey("startTime")); + assertTrue(span.containsKey("duration")); + assertTrue(span.containsKey("process")); + } +} \ No newline at end of file diff --git a/api/src/test/java/io/sentrius/sso/controllers/view/TelemetryControllerTest.java b/api/src/test/java/io/sentrius/sso/controllers/view/TelemetryControllerTest.java new file mode 100644 index 00000000..c40ff4dc --- /dev/null +++ b/api/src/test/java/io/sentrius/sso/controllers/view/TelemetryControllerTest.java @@ -0,0 +1,34 @@ +package io.sentrius.sso.controllers.view; + +import io.sentrius.sso.core.config.SystemOptions; +import io.sentrius.sso.core.services.ErrorOutputService; +import io.sentrius.sso.core.services.UserService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(MockitoExtension.class) +class TelemetryControllerTest { + + @Mock + private UserService userService; + + @Mock + private SystemOptions systemOptions; + + @Mock + private ErrorOutputService errorOutputService; + + @InjectMocks + private TelemetryController telemetryController; + + @Test + void testTelemetryPageMapping() { + String result = telemetryController.telemetry(); + assertEquals("sso/telemetry", result); + } +} \ No newline at end of file From 2c94894bd231b06c25faafff03f1c54a265667ef Mon Sep 17 00:00:00 2001 From: Marc Parisi Date: Mon, 18 Aug 2025 06:15:12 -0400 Subject: [PATCH 4/5] Fix failure to display --- api/src/main/resources/templates/sso/telemetry.html | 4 ++-- sentrius-chart/templates/configmap.yaml | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/api/src/main/resources/templates/sso/telemetry.html b/api/src/main/resources/templates/sso/telemetry.html index 9cff887f..22a208ad 100644 --- a/api/src/main/resources/templates/sso/telemetry.html +++ b/api/src/main/resources/templates/sso/telemetry.html @@ -3,8 +3,8 @@ [[${systemOptions.systemLogoName}]] - Telemetry - - + +
    -
    +
    -
    +
    @@ -43,8 +54,8 @@
    Query Jaeger Traces
    @@ -79,6 +90,21 @@
    Query Jaeger Traces
    +
    +
    + + +
    +
    + + +
    +
    @@ -101,11 +127,24 @@
    Query Jaeger Traces