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/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..508e796c --- /dev/null +++ b/api/src/main/java/io/sentrius/sso/controllers/api/TelemetryApiController.java @@ -0,0 +1,216 @@ +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, + @RequestParam(defaultValue = "20") int limit, + @RequestParam(defaultValue = "0") int start + ) { + try { + String jaegerApiUrl = buildJaegerApiUrl(service, operation, lookback, minDuration, maxDuration, tags, limit, start); + 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()); + result.put("limit", limit); + result.put("start", start); + result.put("hasMore", processedTraces.size() >= limit); // Indicate if there might be more data + + 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, int limit, int start) { + StringBuilder url = new StringBuilder(jaegerQueryUrl + "/api/traces?"); + + // Default to sentrius-api if no service is provided to prevent 500 errors + if (service == null || service.isEmpty()) { + service = "sentrius-api"; + } + 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 pagination parameters + url.append("limit=").append(Math.min(limit, 100)).append("&"); // Cap at 100 + url.append("start=").append(start); + + 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..9f4b33bc --- /dev/null +++ b/api/src/main/resources/templates/sso/telemetry.html @@ -0,0 +1,572 @@ + + + + + [[${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/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/api/TelemetryApiControllerTest.java b/api/src/test/java/io/sentrius/sso/controllers/api/TelemetryApiControllerTest.java new file mode 100644 index 00000000..eaceb155 --- /dev/null +++ b/api/src/test/java/io/sentrius/sso/controllers/api/TelemetryApiControllerTest.java @@ -0,0 +1,105 @@ +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, 20, 0 + ); + + 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", 20, 0 + ); + + // 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()); + } + + @Test + void testGetTracesDefaultsToSentriusApiService() { + // Set a mock Jaeger URL + ReflectionTestUtils.setField(telemetryApiController, "jaegerQueryUrl", "http://localhost:16686"); + + // Test with null/empty service parameter - should default to sentrius-api + ResponseEntity response = telemetryApiController.getTraces( + null, null, "1h", null, null, null, 20, 0 + ); + + // Should return 500 since we can't connect to real Jaeger, but the method should handle null service + assertEquals(500, response.getStatusCode().value()); + } + + @Test + void testGetTracesWithPaginationParameters() { + // Set a mock Jaeger URL + ReflectionTestUtils.setField(telemetryApiController, "jaegerQueryUrl", "http://localhost:16686"); + + // Test with pagination parameters + ResponseEntity response = telemetryApiController.getTraces( + "sentrius-api", null, "1h", null, null, null, 50, 100 + ); + + // Should return 500 since we can't connect to real Jaeger, but shouldn't crash with pagination + assertEquals(500, response.getStatusCode().value()); + } +} \ 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 diff --git a/sentrius-chart/templates/configmap.yaml b/sentrius-chart/templates/configmap.yaml index 25e78b50..dc08771f 100644 --- a/sentrius-chart/templates/configmap.yaml +++ b/sentrius-chart/templates/configmap.yaml @@ -348,6 +348,7 @@ data: otel.traces.exporter=otlp otel.metrics.exporter=none otel.exporter.otlp.endpoint=http://sentrius-jaeger:4317 + jaeger.query.url=http://sentrius-jaeger:16686 otel.resource.attributes.service.name=sentrius-api otel.traces.sampler=always_on otel.exporter.otlp.timeout=10s