diff --git a/argus-apm/build.gradle.kts b/argus-apm/build.gradle.kts new file mode 100644 index 0000000..e86f65d --- /dev/null +++ b/argus-apm/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + `java-library` +} + +dependencies { + testImplementation("org.junit.jupiter:junit-jupiter:${property("junitVersion")}") +} + +tasks.withType { + options.release.set(17) +} diff --git a/argus-apm/src/main/java/io/argus/apm/ApmFacade.java b/argus-apm/src/main/java/io/argus/apm/ApmFacade.java new file mode 100644 index 0000000..9d72f15 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/ApmFacade.java @@ -0,0 +1,34 @@ +package io.argus.apm; + +import io.argus.apm.dto.ApmBackendLinksRequest; +import io.argus.apm.dto.ApmBackendLinksResponse; +import io.argus.apm.dto.ApmEndpointListResponse; +import io.argus.apm.dto.ApmIncidentListResponse; +import io.argus.apm.dto.ApmServiceDetailResponse; +import io.argus.apm.dto.ApmServiceInventoryResponse; +import io.argus.apm.dto.ApmTraceResponse; +import io.argus.apm.model.ApmPrincipal; +import io.argus.apm.model.ApmScope; +import io.argus.apm.model.ApmServiceId; + +/** + * Public APM product facade. + * + *

Implementations authorize through {@link ApmPrincipal} and {@link ApmScope} + * before loading data from metrics, trace, log, profile, or internal Argus + * caches. This interface intentionally exposes APM product DTOs only; raw + * aggregator v1alpha1 route and model types stay internal. + */ +public interface ApmFacade { + ApmServiceInventoryResponse listServices(ApmPrincipal principal, ApmScope scope); + + ApmServiceDetailResponse getService(ApmPrincipal principal, ApmScope scope, ApmServiceId service); + + ApmEndpointListResponse listEndpoints(ApmPrincipal principal, ApmScope scope, ApmServiceId service); + + ApmTraceResponse getTrace(ApmPrincipal principal, ApmScope scope, String traceId); + + ApmIncidentListResponse listIncidents(ApmPrincipal principal, ApmScope scope); + + ApmBackendLinksResponse getBackendLinks(ApmPrincipal principal, ApmBackendLinksRequest request); +} diff --git a/argus-apm/src/main/java/io/argus/apm/ApmFacadeRoutes.java b/argus-apm/src/main/java/io/argus/apm/ApmFacadeRoutes.java new file mode 100644 index 0000000..7659d5c --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/ApmFacadeRoutes.java @@ -0,0 +1,53 @@ +package io.argus.apm; + +import java.util.Set; + +/** + * Contract route names for the public APM facade. + */ +public final class ApmFacadeRoutes { + public static final String SERVICES = "/apm/services"; + public static final String SERVICE = "/apm/services/{service}"; + public static final String SERVICE_ENDPOINTS = "/apm/services/{service}/endpoints"; + public static final String TRACE = "/apm/traces/{traceId}"; + public static final String INCIDENTS = "/apm/incidents"; + public static final String BACKEND_LINKS = "/apm/backend-links"; + + private static final Set PUBLIC_ROUTES = Set.of( + SERVICES, + SERVICE, + SERVICE_ENDPOINTS, + TRACE, + INCIDENTS, + BACKEND_LINKS + ); + + private static final Set FORBIDDEN_AGGREGATOR_PREFIXES = Set.of( + "/fleet", + "/api/pods", + "/profile" + ); + + private ApmFacadeRoutes() { + } + + public static Set publicRoutes() { + return PUBLIC_ROUTES; + } + + public static boolean isPublicApmRoute(String path) { + return path != null && path.startsWith("/apm/"); + } + + public static boolean isForbiddenAggregatorRoute(String path) { + if (path == null) { + return false; + } + for (String prefix : FORBIDDEN_AGGREGATOR_PREFIXES) { + if (path.equals(prefix) || path.startsWith(prefix + "/")) { + return true; + } + } + return false; + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/correlation/ApmCorrelationReason.java b/argus-apm/src/main/java/io/argus/apm/correlation/ApmCorrelationReason.java new file mode 100644 index 0000000..60bc9e4 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/correlation/ApmCorrelationReason.java @@ -0,0 +1,7 @@ +package io.argus.apm.correlation; + +public enum ApmCorrelationReason { + TRACE_AND_SPAN_ID, + TRACE_ID, + SERVICE_AND_TIME_OVERLAP +} diff --git a/argus-apm/src/main/java/io/argus/apm/correlation/ApmFindingCorrelation.java b/argus-apm/src/main/java/io/argus/apm/correlation/ApmFindingCorrelation.java new file mode 100644 index 0000000..53bce37 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/correlation/ApmFindingCorrelation.java @@ -0,0 +1,17 @@ +package io.argus.apm.correlation; + +import io.argus.apm.model.ApmSpanSummary; +import io.argus.apm.model.JvmFinding; + +import java.util.Objects; + +public record ApmFindingCorrelation( + JvmFinding finding, + ApmSpanSummary span, + ApmCorrelationReason reason +) { + public ApmFindingCorrelation { + Objects.requireNonNull(finding, "finding"); + Objects.requireNonNull(reason, "reason"); + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/correlation/ApmTraceCorrelationResult.java b/argus-apm/src/main/java/io/argus/apm/correlation/ApmTraceCorrelationResult.java new file mode 100644 index 0000000..934261b --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/correlation/ApmTraceCorrelationResult.java @@ -0,0 +1,19 @@ +package io.argus.apm.correlation; + +import io.argus.apm.model.ApmTraceContext; +import io.argus.apm.model.JvmFinding; + +import java.util.List; +import java.util.Objects; + +public record ApmTraceCorrelationResult( + ApmTraceContext trace, + List matches, + List unmatchedFindings +) { + public ApmTraceCorrelationResult { + Objects.requireNonNull(trace, "trace"); + matches = List.copyOf(Objects.requireNonNull(matches, "matches")); + unmatchedFindings = List.copyOf(Objects.requireNonNull(unmatchedFindings, "unmatchedFindings")); + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/correlation/ApmTraceFindingCorrelator.java b/argus-apm/src/main/java/io/argus/apm/correlation/ApmTraceFindingCorrelator.java new file mode 100644 index 0000000..470ef7a --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/correlation/ApmTraceFindingCorrelator.java @@ -0,0 +1,79 @@ +package io.argus.apm.correlation; + +import io.argus.apm.model.ApmSpanSummary; +import io.argus.apm.model.ApmTraceContext; +import io.argus.apm.model.JvmFinding; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Correlates Argus JVM findings with OTel trace/span summaries. + */ +public final class ApmTraceFindingCorrelator { + private final Duration timingWindow; + + public ApmTraceFindingCorrelator() { + this(Duration.ofSeconds(1)); + } + + public ApmTraceFindingCorrelator(Duration timingWindow) { + this.timingWindow = Objects.requireNonNull(timingWindow, "timingWindow"); + if (timingWindow.isNegative()) { + throw new IllegalArgumentException("timingWindow must not be negative"); + } + } + + public ApmTraceCorrelationResult correlate(ApmTraceContext trace, List candidateFindings) { + Objects.requireNonNull(trace, "trace"); + Objects.requireNonNull(candidateFindings, "candidateFindings"); + List matches = new ArrayList<>(); + List unmatched = new ArrayList<>(); + + for (JvmFinding finding : candidateFindings) { + Objects.requireNonNull(finding, "candidate finding"); + ApmFindingCorrelation match = match(trace, finding); + if (match == null) { + unmatched.add(finding); + } else { + matches.add(match); + } + } + return new ApmTraceCorrelationResult(trace, matches, unmatched); + } + + private ApmFindingCorrelation match(ApmTraceContext trace, JvmFinding finding) { + if (!isBlank(finding.traceId()) && !finding.traceId().equals(trace.traceId())) { + return null; + } + if (!isBlank(finding.spanId())) { + for (ApmSpanSummary span : trace.spans()) { + if (finding.spanId().equals(span.spanId())) { + return new ApmFindingCorrelation(finding, span, ApmCorrelationReason.TRACE_AND_SPAN_ID); + } + } + } + if (!isBlank(finding.traceId()) && finding.traceId().equals(trace.traceId())) { + return new ApmFindingCorrelation(finding, null, ApmCorrelationReason.TRACE_ID); + } + for (ApmSpanSummary span : trace.spans()) { + if (finding.service().equals(span.service()) && overlaps(finding.observedAt(), span)) { + return new ApmFindingCorrelation(finding, span, ApmCorrelationReason.SERVICE_AND_TIME_OVERLAP); + } + } + return null; + } + + private boolean overlaps(Instant observedAt, ApmSpanSummary span) { + Instant from = span.startTime().minus(timingWindow); + Instant to = span.endTime().plus(timingWindow); + return !observedAt.isBefore(from) && !observedAt.isAfter(to); + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/demo/ApmDemoCatalog.java b/argus-apm/src/main/java/io/argus/apm/demo/ApmDemoCatalog.java new file mode 100644 index 0000000..d098cc4 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/demo/ApmDemoCatalog.java @@ -0,0 +1,204 @@ +package io.argus.apm.demo; + +import io.argus.apm.link.ApmBackendLinkContext; +import io.argus.apm.link.ApmBackendLinkRouter; +import io.argus.apm.model.ApmBackendLink; +import io.argus.apm.model.ApmDeployment; +import io.argus.apm.model.ApmEndpointSummary; +import io.argus.apm.model.ApmEntityIdentity; +import io.argus.apm.model.ApmFindingKind; +import io.argus.apm.model.ApmHealth; +import io.argus.apm.model.ApmIncident; +import io.argus.apm.model.ApmIncidentStatus; +import io.argus.apm.model.ApmInstance; +import io.argus.apm.model.ApmInstanceStatus; +import io.argus.apm.model.ApmMetadataSource; +import io.argus.apm.model.ApmOwner; +import io.argus.apm.model.ApmProfileReference; +import io.argus.apm.model.ApmScope; +import io.argus.apm.model.ApmServiceDetail; +import io.argus.apm.model.ApmServiceId; +import io.argus.apm.model.ApmServiceSummary; +import io.argus.apm.model.ApmSeverity; +import io.argus.apm.model.ApmSignalStats; +import io.argus.apm.model.ApmSpanSummary; +import io.argus.apm.model.ApmTimeRange; +import io.argus.apm.model.ApmTraceContext; +import io.argus.apm.model.JvmFinding; +import io.argus.apm.model.RunbookLink; + +import java.net.URI; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +public final class ApmDemoCatalog { + private static final Instant BASE_TIME = Instant.parse("2026-06-09T00:00:00Z"); + private static final ApmBackendLinkRouter LINK_ROUTER = ApmBackendLinkRouter.mvp( + URI.create("https://grafana.local"), + URI.create("https://tempo.local"), + URI.create("https://loki.local"), + URI.create("https://pyroscope.local") + ); + + private ApmDemoCatalog() { + } + + public static ApmDemoTopology defaultTopology() { + ApmScope scope = ApmScope.environment("tenant-a", "payments", "prod") + .withTimeRange(new ApmTimeRange(BASE_TIME.minusSeconds(3600), BASE_TIME)); + ApmServiceId checkout = new ApmServiceId("shop", "checkout"); + ApmServiceId session = new ApmServiceId("identity", "session"); + + List scenarios = List.of( + scenario(scope, ApmDemoScenarioType.GC_LATENCY, checkout, "checkout-7f4c", "pod-checkout-a", + "POST", "/checkout", "GC latency checkout regression", ApmFindingKind.GC_PAUSE, + ApmSeverity.WARNING, ApmHealth.DEGRADED, "G1 evacuation pause overlaps checkout spans.", + "trace-gc-latency", "span-gc-latency", 180, BASE_TIME.minusSeconds(210)), + scenario(scope, ApmDemoScenarioType.LOCK_CONTENTION, checkout, "checkout-7f4c", "pod-checkout-b", + "GET", "/checkout/{id}", "Checkout lock contention hotspot", ApmFindingKind.LOCK_CONTENTION, + ApmSeverity.CRITICAL, ApmHealth.UNHEALTHY, "PaymentClient cache lock blocks carrier threads.", + "trace-lock-contention", "span-lock-contention", 240, BASE_TIME.minusSeconds(170)), + scenario(scope, ApmDemoScenarioType.VIRTUAL_THREAD_PINNING, session, "session-65dd", "pod-session-a", + "POST", "/session", "Session virtual-thread pinning", ApmFindingKind.VIRTUAL_THREAD_PINNING, + ApmSeverity.WARNING, ApmHealth.DEGRADED, "Synchronized token refresh pins request carriers.", + "trace-vthread-pinning", "span-vthread-pinning", 220, BASE_TIME.minusSeconds(130)), + scenario(scope, ApmDemoScenarioType.BAD_RELEASE_REGRESSION, session, "session-65dd", "pod-session-b", + "POST", "/session", "Session bad release regression", ApmFindingKind.DEPLOYMENT_REGRESSION, + ApmSeverity.CRITICAL, ApmHealth.UNHEALTHY, "Deployment 2026.06.09 shifted p95 latency and error rate.", + "trace-bad-release", "span-bad-release", 420, BASE_TIME.minusSeconds(90)) + ); + + return new ApmDemoTopology(scope, List.of( + service(scope, checkout, "checkout-7f4c", "payments-platform", ApmHealth.UNHEALTHY, + scenarios.stream().filter(s -> s.service().equals(checkout)).toList()), + service(scope, session, "session-65dd", "identity", ApmHealth.UNHEALTHY, + scenarios.stream().filter(s -> s.service().equals(session)).toList()) + ), scenarios); + } + + private static ApmDemoScenario scenario(ApmScope scope, ApmDemoScenarioType type, ApmServiceId service, + String deploymentId, String instanceId, String method, + String endpointRoute, String title, ApmFindingKind kind, + ApmSeverity severity, ApmHealth health, String detail, + String traceId, String spanId, long latencyP95, + Instant observedAt) { + ApmScope scenarioScope = scope.withService(service) + .withDeployment(deploymentId) + .withInstance(instanceId) + .withEndpointRoute(endpointRoute); + List links = LINK_ROUTER.linksFor(new ApmBackendLinkContext( + scenarioScope, service, deploymentId, instanceId, endpointRoute, traceId, spanId)); + JvmFinding finding = new JvmFinding( + type.name().toLowerCase(java.util.Locale.ROOT), + kind, + severity, + title, + detail, + service, + instanceId, + traceId, + spanId, + observedAt, + links + ); + ApmTraceContext trace = new ApmTraceContext( + traceId, + service, + spanId, + observedAt.minusMillis(latencyP95), + latencyP95, + health, + List.of(new ApmSpanSummary(spanId, "", service, method + " " + endpointRoute, + endpointRoute, observedAt.minusMillis(latencyP95), latencyP95)), + List.of(finding), + List.of(), + links + ); + ApmIncident incident = new ApmIncident( + "incident-" + type.name().toLowerCase(java.util.Locale.ROOT).replace('_', '-'), + ApmIncidentStatus.OPEN, + severity, + title, + service, + observedAt.minusSeconds(60), + observedAt, + List.of(finding), + links + ); + String localPath = "/?service=" + encode(service.displayName()) + "&scenario=" + type.name().toLowerCase(java.util.Locale.ROOT); + return new ApmDemoScenario(type, title, service, method, endpointRoute, trace, incident, + List.of(finding), links, localPath, links.get(0).uri().toString()); + } + + private static ApmServiceDetail service(ApmScope scope, ApmServiceId service, String deploymentId, + String owner, ApmHealth health, + List scenarios) { + ApmEntityIdentity identity = new ApmEntityIdentity( + "otel:" + service.displayName(), + ApmMetadataSource.OPEN_TELEMETRY_RESOURCE, + Map.of("service.name", service.name(), "service.namespace", service.namespace()), + List.of() + ); + List findings = scenarios.stream().flatMap(s -> s.findings().stream()).toList(); + List links = scenarios.isEmpty() ? List.of() : scenarios.get(0).backendLinks(); + ApmServiceSummary summary = new ApmServiceSummary( + service, + service.displayName(), + health, + new ApmOwner(owner, owner + "@example.com", "pagerduty/" + owner), + new RunbookLink("APM demo runbook", URI.create("https://runbooks.example/argus-apm-demo")), + new ApmSignalStats(120, 2.0, 40, maxLatency(scenarios), maxLatency(scenarios) + 120, maxGc(scenarios), 0.72, 0.61), + identity, + findings, + links + ); + ApmDeployment deployment = new ApmDeployment( + deploymentId, + service, + "2026.06.09", + scope.environment(), + BASE_TIME.minusSeconds(7200), + BASE_TIME, + health, + identity + ); + ApmInstance instance = new ApmInstance( + deploymentId + "-demo", + service, + deploymentId, + service.namespace(), + deploymentId + "-demo", + "node-demo", + health == ApmHealth.UNHEALTHY ? ApmInstanceStatus.DEGRADED : ApmInstanceStatus.UP, + BASE_TIME, + identity + ); + List endpoints = scenarios.stream() + .map(s -> new ApmEndpointSummary(service, s.method(), s.endpointRoute(), identity, + ApmSignalStats.empty(), s.findings(), s.backendLinks())) + .toList(); + List profiles = scenarios.stream() + .map(s -> new ApmProfileReference("profile-" + s.type().name().toLowerCase(java.util.Locale.ROOT), + service, deploymentId + "-demo", "cpu", BASE_TIME.minusSeconds(120), BASE_TIME, + s.backendLinks().get(s.backendLinks().size() - 1))) + .toList(); + return new ApmServiceDetail(summary, List.of(deployment), List.of(instance), endpoints, profiles); + } + + private static double maxLatency(List scenarios) { + return scenarios.stream().mapToLong(s -> s.trace().durationMillis()).max().orElse(0); + } + + private static double maxGc(List scenarios) { + return scenarios.stream() + .filter(s -> s.type() == ApmDemoScenarioType.GC_LATENCY) + .mapToDouble(s -> 96.0) + .findFirst() + .orElse(32.0); + } + + private static String encode(String value) { + return java.net.URLEncoder.encode(value, java.nio.charset.StandardCharsets.UTF_8); + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/demo/ApmDemoScenario.java b/argus-apm/src/main/java/io/argus/apm/demo/ApmDemoScenario.java new file mode 100644 index 0000000..8244226 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/demo/ApmDemoScenario.java @@ -0,0 +1,46 @@ +package io.argus.apm.demo; + +import io.argus.apm.model.ApmBackendLink; +import io.argus.apm.model.ApmIncident; +import io.argus.apm.model.ApmServiceId; +import io.argus.apm.model.ApmTraceContext; +import io.argus.apm.model.JvmFinding; + +import java.util.List; +import java.util.Objects; + +public record ApmDemoScenario( + ApmDemoScenarioType type, + String title, + ApmServiceId service, + String method, + String endpointRoute, + ApmTraceContext trace, + ApmIncident incident, + List findings, + List backendLinks, + String localDashboardPath, + String grafanaPath +) { + public ApmDemoScenario { + Objects.requireNonNull(type, "type"); + title = requireText(title, "title"); + Objects.requireNonNull(service, "service"); + method = requireText(method, "method").toUpperCase(java.util.Locale.ROOT); + endpointRoute = requireText(endpointRoute, "endpointRoute"); + Objects.requireNonNull(trace, "trace"); + Objects.requireNonNull(incident, "incident"); + findings = List.copyOf(Objects.requireNonNull(findings, "findings")); + backendLinks = List.copyOf(Objects.requireNonNull(backendLinks, "backendLinks")); + localDashboardPath = requireText(localDashboardPath, "localDashboardPath"); + grafanaPath = requireText(grafanaPath, "grafanaPath"); + } + + private static String requireText(String value, String name) { + Objects.requireNonNull(value, name); + if (value.isBlank()) { + throw new IllegalArgumentException(name + " must not be blank"); + } + return value; + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/demo/ApmDemoScenarioType.java b/argus-apm/src/main/java/io/argus/apm/demo/ApmDemoScenarioType.java new file mode 100644 index 0000000..2d6f595 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/demo/ApmDemoScenarioType.java @@ -0,0 +1,8 @@ +package io.argus.apm.demo; + +public enum ApmDemoScenarioType { + GC_LATENCY, + LOCK_CONTENTION, + VIRTUAL_THREAD_PINNING, + BAD_RELEASE_REGRESSION +} diff --git a/argus-apm/src/main/java/io/argus/apm/demo/ApmDemoTopology.java b/argus-apm/src/main/java/io/argus/apm/demo/ApmDemoTopology.java new file mode 100644 index 0000000..74acf6d --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/demo/ApmDemoTopology.java @@ -0,0 +1,29 @@ +package io.argus.apm.demo; + +import io.argus.apm.model.ApmIncident; +import io.argus.apm.model.ApmScope; +import io.argus.apm.model.ApmServiceDetail; +import io.argus.apm.model.ApmTraceContext; + +import java.util.List; +import java.util.Objects; + +public record ApmDemoTopology( + ApmScope scope, + List services, + List scenarios +) { + public ApmDemoTopology { + Objects.requireNonNull(scope, "scope"); + services = List.copyOf(Objects.requireNonNull(services, "services")); + scenarios = List.copyOf(Objects.requireNonNull(scenarios, "scenarios")); + } + + public List incidents() { + return scenarios.stream().map(ApmDemoScenario::incident).toList(); + } + + public List traces() { + return scenarios.stream().map(ApmDemoScenario::trace).toList(); + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/dto/ApmBackendLinksRequest.java b/argus-apm/src/main/java/io/argus/apm/dto/ApmBackendLinksRequest.java new file mode 100644 index 0000000..a44e376 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/dto/ApmBackendLinksRequest.java @@ -0,0 +1,22 @@ +package io.argus.apm.dto; + +import io.argus.apm.model.ApmBackendSignal; +import io.argus.apm.model.ApmScope; +import io.argus.apm.model.ApmServiceId; + +import java.util.Objects; + +public record ApmBackendLinksRequest( + ApmScope scope, + ApmBackendSignal signal, + ApmServiceId service, + String instanceId, + String endpointRoute, + String traceId, + String spanId +) { + public ApmBackendLinksRequest { + Objects.requireNonNull(scope, "scope"); + Objects.requireNonNull(signal, "signal"); + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/dto/ApmBackendLinksResponse.java b/argus-apm/src/main/java/io/argus/apm/dto/ApmBackendLinksResponse.java new file mode 100644 index 0000000..da3e03a --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/dto/ApmBackendLinksResponse.java @@ -0,0 +1,14 @@ +package io.argus.apm.dto; + +import io.argus.apm.model.ApmBackendLink; +import io.argus.apm.model.ApmScope; + +import java.util.List; +import java.util.Objects; + +public record ApmBackendLinksResponse(ApmScope scope, List links) { + public ApmBackendLinksResponse { + Objects.requireNonNull(scope, "scope"); + links = List.copyOf(Objects.requireNonNull(links, "links")); + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/dto/ApmEndpointListResponse.java b/argus-apm/src/main/java/io/argus/apm/dto/ApmEndpointListResponse.java new file mode 100644 index 0000000..377fd4c --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/dto/ApmEndpointListResponse.java @@ -0,0 +1,20 @@ +package io.argus.apm.dto; + +import io.argus.apm.model.ApmEndpointSummary; +import io.argus.apm.model.ApmScope; +import io.argus.apm.model.ApmServiceId; + +import java.util.List; +import java.util.Objects; + +public record ApmEndpointListResponse( + ApmScope scope, + ApmServiceId service, + List endpoints +) { + public ApmEndpointListResponse { + Objects.requireNonNull(scope, "scope"); + Objects.requireNonNull(service, "service"); + endpoints = List.copyOf(Objects.requireNonNull(endpoints, "endpoints")); + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/dto/ApmIncidentListResponse.java b/argus-apm/src/main/java/io/argus/apm/dto/ApmIncidentListResponse.java new file mode 100644 index 0000000..7c2fa32 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/dto/ApmIncidentListResponse.java @@ -0,0 +1,14 @@ +package io.argus.apm.dto; + +import io.argus.apm.model.ApmIncident; +import io.argus.apm.model.ApmScope; + +import java.util.List; +import java.util.Objects; + +public record ApmIncidentListResponse(ApmScope scope, List incidents) { + public ApmIncidentListResponse { + Objects.requireNonNull(scope, "scope"); + incidents = List.copyOf(Objects.requireNonNull(incidents, "incidents")); + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/dto/ApmServiceDetailResponse.java b/argus-apm/src/main/java/io/argus/apm/dto/ApmServiceDetailResponse.java new file mode 100644 index 0000000..8056eb4 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/dto/ApmServiceDetailResponse.java @@ -0,0 +1,13 @@ +package io.argus.apm.dto; + +import io.argus.apm.model.ApmScope; +import io.argus.apm.model.ApmServiceDetail; + +import java.util.Objects; + +public record ApmServiceDetailResponse(ApmScope scope, ApmServiceDetail service) { + public ApmServiceDetailResponse { + Objects.requireNonNull(scope, "scope"); + Objects.requireNonNull(service, "service"); + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/dto/ApmServiceInventoryResponse.java b/argus-apm/src/main/java/io/argus/apm/dto/ApmServiceInventoryResponse.java new file mode 100644 index 0000000..c252e57 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/dto/ApmServiceInventoryResponse.java @@ -0,0 +1,14 @@ +package io.argus.apm.dto; + +import io.argus.apm.model.ApmScope; +import io.argus.apm.model.ApmServiceSummary; + +import java.util.List; +import java.util.Objects; + +public record ApmServiceInventoryResponse(ApmScope scope, List services) { + public ApmServiceInventoryResponse { + Objects.requireNonNull(scope, "scope"); + services = List.copyOf(Objects.requireNonNull(services, "services")); + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/dto/ApmTraceResponse.java b/argus-apm/src/main/java/io/argus/apm/dto/ApmTraceResponse.java new file mode 100644 index 0000000..1dc0639 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/dto/ApmTraceResponse.java @@ -0,0 +1,13 @@ +package io.argus.apm.dto; + +import io.argus.apm.model.ApmScope; +import io.argus.apm.model.ApmTraceContext; + +import java.util.Objects; + +public record ApmTraceResponse(ApmScope scope, ApmTraceContext trace) { + public ApmTraceResponse { + Objects.requireNonNull(scope, "scope"); + Objects.requireNonNull(trace, "trace"); + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/guard/ApmEndpointCardinalityGuard.java b/argus-apm/src/main/java/io/argus/apm/guard/ApmEndpointCardinalityGuard.java new file mode 100644 index 0000000..c63dc50 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/guard/ApmEndpointCardinalityGuard.java @@ -0,0 +1,56 @@ +package io.argus.apm.guard; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; + +public final class ApmEndpointCardinalityGuard { + private static final int MAX_ROUTE_LENGTH = 160; + private static final int MAX_SEGMENTS = 12; + private static final Pattern UUID = Pattern.compile("^[0-9a-fA-F]{8}-[0-9a-fA-F-]{27,}$"); + private static final Pattern LONG_NUMBER = Pattern.compile("^[0-9]{4,}$"); + private static final Pattern LONG_HEX = Pattern.compile("^[0-9a-fA-F]{10,}$"); + + private ApmEndpointCardinalityGuard() { + } + + public static ApmRouteNormalization normalize(String method, String rawPath) { + if (method == null || method.isBlank()) { + throw new IllegalArgumentException("method must not be blank"); + } + if (rawPath == null || rawPath.isBlank() || !rawPath.startsWith("/")) { + throw new IllegalArgumentException("rawPath must start with /"); + } + String path = rawPath.split("[?#]", 2)[0]; + if (path.length() > MAX_ROUTE_LENGTH) { + throw new IllegalArgumentException("route exceeds max length " + MAX_ROUTE_LENGTH); + } + String[] segments = path.substring(1).split("/"); + if (segments.length > MAX_SEGMENTS) { + throw new IllegalArgumentException("route exceeds max segment count " + MAX_SEGMENTS); + } + List reasons = new ArrayList<>(); + List normalized = new ArrayList<>(); + for (String segment : segments) { + if (segment.isBlank()) { + continue; + } + if (isHighCardinality(segment)) { + normalized.add("{id}"); + reasons.add("high-cardinality segment"); + } else { + normalized.add(segment.toLowerCase(Locale.ROOT)); + } + } + String route = "/" + String.join("/", normalized); + return new ApmRouteNormalization(method, route, !reasons.isEmpty() || !route.equals(path), reasons); + } + + private static boolean isHighCardinality(String segment) { + return UUID.matcher(segment).matches() + || LONG_NUMBER.matcher(segment).matches() + || LONG_HEX.matcher(segment).matches() + || segment.contains("@"); + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/guard/ApmGuardrailDecision.java b/argus-apm/src/main/java/io/argus/apm/guard/ApmGuardrailDecision.java new file mode 100644 index 0000000..84ec345 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/guard/ApmGuardrailDecision.java @@ -0,0 +1,16 @@ +package io.argus.apm.guard; + +public record ApmGuardrailDecision(boolean allowed, String code, String detail) { + public ApmGuardrailDecision { + code = code == null ? "" : code; + detail = detail == null ? "" : detail; + } + + public static ApmGuardrailDecision allow() { + return new ApmGuardrailDecision(true, "allowed", "allowed"); + } + + public static ApmGuardrailDecision deny(String code, String detail) { + return new ApmGuardrailDecision(false, code, detail); + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/guard/ApmOverheadBudget.java b/argus-apm/src/main/java/io/argus/apm/guard/ApmOverheadBudget.java new file mode 100644 index 0000000..eef4152 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/guard/ApmOverheadBudget.java @@ -0,0 +1,42 @@ +package io.argus.apm.guard; + +public record ApmOverheadBudget( + int maxServices, + int maxEndpointsPerService, + int maxFindingsPerTrace, + int maxBackendLinksPerRequest +) { + public ApmOverheadBudget { + requirePositive(maxServices, "maxServices"); + requirePositive(maxEndpointsPerService, "maxEndpointsPerService"); + requirePositive(maxFindingsPerTrace, "maxFindingsPerTrace"); + requirePositive(maxBackendLinksPerRequest, "maxBackendLinksPerRequest"); + } + + public static ApmOverheadBudget defaults() { + return new ApmOverheadBudget(500, 200, 50, 12); + } + + public ApmGuardrailDecision validate(int services, int endpointsPerService, + int findingsPerTrace, int backendLinksPerRequest) { + if (services > maxServices) { + return ApmGuardrailDecision.deny("services_limit", "service count exceeds APM facade budget"); + } + if (endpointsPerService > maxEndpointsPerService) { + return ApmGuardrailDecision.deny("endpoints_limit", "endpoint cardinality exceeds service budget"); + } + if (findingsPerTrace > maxFindingsPerTrace) { + return ApmGuardrailDecision.deny("findings_limit", "trace finding fanout exceeds budget"); + } + if (backendLinksPerRequest > maxBackendLinksPerRequest) { + return ApmGuardrailDecision.deny("backend_links_limit", "backend link fanout exceeds budget"); + } + return ApmGuardrailDecision.allow(); + } + + private static void requirePositive(int value, String name) { + if (value <= 0) { + throw new IllegalArgumentException(name + " must be positive"); + } + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/guard/ApmRouteNormalization.java b/argus-apm/src/main/java/io/argus/apm/guard/ApmRouteNormalization.java new file mode 100644 index 0000000..35641a9 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/guard/ApmRouteNormalization.java @@ -0,0 +1,25 @@ +package io.argus.apm.guard; + +import java.util.List; +import java.util.Objects; + +public record ApmRouteNormalization( + String method, + String route, + boolean normalized, + List reasons +) { + public ApmRouteNormalization { + method = requireText(method, "method").toUpperCase(java.util.Locale.ROOT); + route = requireText(route, "route"); + reasons = List.copyOf(Objects.requireNonNull(reasons, "reasons")); + } + + private static String requireText(String value, String name) { + Objects.requireNonNull(value, name); + if (value.isBlank()) { + throw new IllegalArgumentException(name + " must not be blank"); + } + return value; + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/link/ApmBackendLinkContext.java b/argus-apm/src/main/java/io/argus/apm/link/ApmBackendLinkContext.java new file mode 100644 index 0000000..db1b88a --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/link/ApmBackendLinkContext.java @@ -0,0 +1,47 @@ +package io.argus.apm.link; + +import io.argus.apm.dto.ApmBackendLinksRequest; +import io.argus.apm.model.ApmScope; +import io.argus.apm.model.ApmServiceId; + +import java.util.Objects; + +public record ApmBackendLinkContext( + ApmScope scope, + ApmServiceId service, + String deploymentId, + String instanceId, + String endpointRoute, + String traceId, + String spanId +) { + public ApmBackendLinkContext { + Objects.requireNonNull(scope, "scope"); + service = service != null ? service : scope.service(); + deploymentId = firstNonBlank(deploymentId, scope.deploymentId()); + instanceId = firstNonBlank(instanceId, scope.instanceId()); + endpointRoute = firstNonBlank(endpointRoute, scope.endpointRoute()); + traceId = traceId == null ? "" : traceId; + spanId = spanId == null ? "" : spanId; + } + + public static ApmBackendLinkContext from(ApmBackendLinksRequest request) { + Objects.requireNonNull(request, "request"); + return new ApmBackendLinkContext( + request.scope(), + request.service(), + null, + request.instanceId(), + request.endpointRoute(), + request.traceId(), + request.spanId() + ); + } + + private static String firstNonBlank(String preferred, String fallback) { + if (preferred != null && !preferred.isBlank()) { + return preferred; + } + return fallback == null ? "" : fallback; + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/link/ApmBackendLinkRouter.java b/argus-apm/src/main/java/io/argus/apm/link/ApmBackendLinkRouter.java new file mode 100644 index 0000000..7fa1633 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/link/ApmBackendLinkRouter.java @@ -0,0 +1,48 @@ +package io.argus.apm.link; + +import io.argus.apm.model.ApmBackendLink; +import io.argus.apm.model.ApmBackendSignal; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public final class ApmBackendLinkRouter { + private final List templates; + + public ApmBackendLinkRouter(List templates) { + this.templates = List.copyOf(Objects.requireNonNull(templates, "templates")); + } + + public static ApmBackendLinkRouter mvp(URI grafanaBase, URI tempoOrJaegerBase, + URI lokiBase, URI pyroscopeBase) { + return new ApmBackendLinkRouter(List.of( + ApmBackendLinkTemplate.of(ApmBackendSignal.METRICS, "prometheus", grafanaBase, "/d/argus-apm"), + ApmBackendLinkTemplate.of(ApmBackendSignal.TRACES, "tempo-or-jaeger", tempoOrJaegerBase, "/trace"), + ApmBackendLinkTemplate.of(ApmBackendSignal.LOGS, "loki", lokiBase, "/explore"), + ApmBackendLinkTemplate.of(ApmBackendSignal.PROFILES, "pyroscope", pyroscopeBase, "/profiles") + )); + } + + public List linksFor(ApmBackendLinkContext context) { + Objects.requireNonNull(context, "context"); + List links = new ArrayList<>(); + for (ApmBackendLinkTemplate template : templates) { + links.add(template.render(context)); + } + return links; + } + + public List linksFor(ApmBackendSignal signal, ApmBackendLinkContext context) { + Objects.requireNonNull(signal, "signal"); + Objects.requireNonNull(context, "context"); + List links = new ArrayList<>(); + for (ApmBackendLinkTemplate template : templates) { + if (template.signal() == signal) { + links.add(template.render(context)); + } + } + return links; + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/link/ApmBackendLinkTemplate.java b/argus-apm/src/main/java/io/argus/apm/link/ApmBackendLinkTemplate.java new file mode 100644 index 0000000..16ae9a2 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/link/ApmBackendLinkTemplate.java @@ -0,0 +1,86 @@ +package io.argus.apm.link; + +import io.argus.apm.model.ApmBackendLink; +import io.argus.apm.model.ApmBackendSignal; + +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +public record ApmBackendLinkTemplate( + ApmBackendSignal signal, + String label, + URI baseUri, + String path +) { + public ApmBackendLinkTemplate { + Objects.requireNonNull(signal, "signal"); + if (label == null || label.isBlank()) { + throw new IllegalArgumentException("label must not be blank"); + } + Objects.requireNonNull(baseUri, "baseUri"); + if (path == null || path.isBlank() || !path.startsWith("/")) { + throw new IllegalArgumentException("path must start with /"); + } + } + + public static ApmBackendLinkTemplate of(ApmBackendSignal signal, String label, URI baseUri, String path) { + return new ApmBackendLinkTemplate(signal, label, baseUri, path); + } + + public ApmBackendLink render(ApmBackendLinkContext context) { + Objects.requireNonNull(context, "context"); + Map params = new LinkedHashMap<>(); + put(params, "tenant", context.scope().tenant()); + put(params, "project", context.scope().project()); + put(params, "environment", context.scope().environment()); + if (context.service() != null) { + put(params, "service", context.service().displayName()); + } + put(params, "deployment", context.deploymentId()); + put(params, "instance", context.instanceId()); + put(params, "endpoint", context.endpointRoute()); + put(params, "traceId", context.traceId()); + put(params, "spanId", context.spanId()); + if (context.scope().timeRange() != null) { + put(params, "from", instant(context.scope().timeRange().start())); + put(params, "to", instant(context.scope().timeRange().end())); + } + return new ApmBackendLink(signal, label, URI.create(renderUri(params)), false, context.scope()); + } + + private String renderUri(Map params) { + String base = baseUri.toString(); + while (base.endsWith("/")) { + base = base.substring(0, base.length() - 1); + } + StringBuilder sb = new StringBuilder(base).append(path).append('?'); + boolean first = true; + for (Map.Entry entry : params.entrySet()) { + if (!first) { + sb.append('&'); + } + first = false; + sb.append(encode(entry.getKey())).append('=').append(encode(entry.getValue())); + } + return sb.toString(); + } + + private static void put(Map params, String key, String value) { + if (value != null && !value.isBlank()) { + params.put(key, value); + } + } + + private static String instant(Instant instant) { + return instant == null ? "" : instant.toString(); + } + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/metrics/ApmSelfMetrics.java b/argus-apm/src/main/java/io/argus/apm/metrics/ApmSelfMetrics.java new file mode 100644 index 0000000..4ebf413 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/metrics/ApmSelfMetrics.java @@ -0,0 +1,75 @@ +package io.argus.apm.metrics; + +import java.util.concurrent.atomic.AtomicLong; + +public final class ApmSelfMetrics { + private final AtomicLong facadeRequests = new AtomicLong(); + private final AtomicLong facadeErrors = new AtomicLong(); + private final AtomicLong authorizationDenied = new AtomicLong(); + private final AtomicLong routeRejected = new AtomicLong(); + private final AtomicLong backendLinksGenerated = new AtomicLong(); + private final AtomicLong totalLatencyMillis = new AtomicLong(); + + public void recordFacadeRequest(long latencyMillis, boolean error) { + if (latencyMillis < 0) { + throw new IllegalArgumentException("latencyMillis must be non-negative"); + } + facadeRequests.incrementAndGet(); + totalLatencyMillis.addAndGet(latencyMillis); + if (error) { + facadeErrors.incrementAndGet(); + } + } + + public void recordAuthorizationDenied() { + authorizationDenied.incrementAndGet(); + } + + public void recordRouteRejected() { + routeRejected.incrementAndGet(); + } + + public void recordBackendLinksGenerated(int count) { + if (count < 0) { + throw new IllegalArgumentException("count must be non-negative"); + } + backendLinksGenerated.addAndGet(count); + } + + public Snapshot snapshot() { + long requests = facadeRequests.get(); + return new Snapshot( + requests, + facadeErrors.get(), + authorizationDenied.get(), + routeRejected.get(), + backendLinksGenerated.get(), + requests == 0 ? 0.0 : (double) totalLatencyMillis.get() / requests + ); + } + + public String toPrometheusText() { + Snapshot s = snapshot(); + return "# TYPE argus_apm_facade_requests_total counter\n" + + "argus_apm_facade_requests_total " + s.facadeRequests() + "\n" + + "# TYPE argus_apm_facade_errors_total counter\n" + + "argus_apm_facade_errors_total " + s.facadeErrors() + "\n" + + "# TYPE argus_apm_authorization_denied_total counter\n" + + "argus_apm_authorization_denied_total " + s.authorizationDenied() + "\n" + + "# TYPE argus_apm_route_rejected_total counter\n" + + "argus_apm_route_rejected_total " + s.routeRejected() + "\n" + + "# TYPE argus_apm_backend_links_generated_total counter\n" + + "argus_apm_backend_links_generated_total " + s.backendLinksGenerated() + "\n" + + "# TYPE argus_apm_facade_latency_avg_ms gauge\n" + + "argus_apm_facade_latency_avg_ms " + s.averageLatencyMillis() + "\n"; + } + + public record Snapshot( + long facadeRequests, + long facadeErrors, + long authorizationDenied, + long routeRejected, + long backendLinksGenerated, + double averageLatencyMillis + ) {} +} diff --git a/argus-apm/src/main/java/io/argus/apm/model/ApmBackendLink.java b/argus-apm/src/main/java/io/argus/apm/model/ApmBackendLink.java new file mode 100644 index 0000000..f908efd --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/model/ApmBackendLink.java @@ -0,0 +1,19 @@ +package io.argus.apm.model; + +import java.net.URI; +import java.util.Objects; + +public record ApmBackendLink( + ApmBackendSignal signal, + String label, + URI uri, + boolean bestEffort, + ApmScope scope +) { + public ApmBackendLink { + Objects.requireNonNull(signal, "signal"); + label = ApmValidation.requireText(label, "label"); + Objects.requireNonNull(uri, "uri"); + Objects.requireNonNull(scope, "scope"); + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/model/ApmBackendSignal.java b/argus-apm/src/main/java/io/argus/apm/model/ApmBackendSignal.java new file mode 100644 index 0000000..c6d8205 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/model/ApmBackendSignal.java @@ -0,0 +1,8 @@ +package io.argus.apm.model; + +public enum ApmBackendSignal { + METRICS, + TRACES, + LOGS, + PROFILES +} diff --git a/argus-apm/src/main/java/io/argus/apm/model/ApmDeployment.java b/argus-apm/src/main/java/io/argus/apm/model/ApmDeployment.java new file mode 100644 index 0000000..cd8ad7b --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/model/ApmDeployment.java @@ -0,0 +1,29 @@ +package io.argus.apm.model; + +import java.time.Instant; +import java.util.Objects; + +public record ApmDeployment( + String deploymentId, + ApmServiceId service, + String version, + String environment, + Instant firstSeen, + Instant lastSeen, + ApmHealth health, + ApmEntityIdentity identity +) { + public ApmDeployment { + deploymentId = ApmValidation.requireText(deploymentId, "deploymentId"); + Objects.requireNonNull(service, "service"); + version = version == null ? "" : version; + environment = ApmValidation.requireText(environment, "environment"); + Objects.requireNonNull(firstSeen, "firstSeen"); + Objects.requireNonNull(lastSeen, "lastSeen"); + if (lastSeen.isBefore(firstSeen)) { + throw new IllegalArgumentException("lastSeen must not be before firstSeen"); + } + Objects.requireNonNull(health, "health"); + Objects.requireNonNull(identity, "identity"); + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/model/ApmEndpointSummary.java b/argus-apm/src/main/java/io/argus/apm/model/ApmEndpointSummary.java new file mode 100644 index 0000000..eac7e1f --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/model/ApmEndpointSummary.java @@ -0,0 +1,24 @@ +package io.argus.apm.model; + +import java.util.List; +import java.util.Objects; + +public record ApmEndpointSummary( + ApmServiceId service, + String method, + String route, + ApmEntityIdentity identity, + ApmSignalStats signals, + List findings, + List backendLinks +) { + public ApmEndpointSummary { + Objects.requireNonNull(service, "service"); + method = ApmValidation.requireText(method, "method").toUpperCase(); + route = ApmValidation.requireText(route, "route"); + Objects.requireNonNull(identity, "identity"); + Objects.requireNonNull(signals, "signals"); + findings = ApmValidation.copyList(findings, "findings"); + backendLinks = ApmValidation.copyList(backendLinks, "backendLinks"); + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/model/ApmEntityIdentity.java b/argus-apm/src/main/java/io/argus/apm/model/ApmEntityIdentity.java new file mode 100644 index 0000000..86af224 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/model/ApmEntityIdentity.java @@ -0,0 +1,19 @@ +package io.argus.apm.model; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public record ApmEntityIdentity( + String stableId, + ApmMetadataSource winningSource, + Map observedAttributes, + List conflicts +) { + public ApmEntityIdentity { + stableId = ApmValidation.requireText(stableId, "stableId"); + Objects.requireNonNull(winningSource, "winningSource"); + observedAttributes = ApmValidation.copyMap(observedAttributes, "observedAttributes"); + conflicts = ApmValidation.copyList(conflicts, "conflicts"); + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/model/ApmFindingKind.java b/argus-apm/src/main/java/io/argus/apm/model/ApmFindingKind.java new file mode 100644 index 0000000..cdc4853 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/model/ApmFindingKind.java @@ -0,0 +1,12 @@ +package io.argus.apm.model; + +public enum ApmFindingKind { + GC_PAUSE, + GC_PRESSURE, + MEMORY_LEAK, + LOCK_CONTENTION, + VIRTUAL_THREAD_PINNING, + CPU_SATURATION, + PROFILE_HOTSPOT, + DEPLOYMENT_REGRESSION +} diff --git a/argus-apm/src/main/java/io/argus/apm/model/ApmHealth.java b/argus-apm/src/main/java/io/argus/apm/model/ApmHealth.java new file mode 100644 index 0000000..49e839c --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/model/ApmHealth.java @@ -0,0 +1,8 @@ +package io.argus.apm.model; + +public enum ApmHealth { + UNKNOWN, + HEALTHY, + DEGRADED, + UNHEALTHY +} diff --git a/argus-apm/src/main/java/io/argus/apm/model/ApmIncident.java b/argus-apm/src/main/java/io/argus/apm/model/ApmIncident.java new file mode 100644 index 0000000..83bd3ff --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/model/ApmIncident.java @@ -0,0 +1,32 @@ +package io.argus.apm.model; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +public record ApmIncident( + String incidentId, + ApmIncidentStatus status, + ApmSeverity severity, + String title, + ApmServiceId service, + Instant startedAt, + Instant updatedAt, + List findings, + List backendLinks +) { + public ApmIncident { + incidentId = ApmValidation.requireText(incidentId, "incidentId"); + Objects.requireNonNull(status, "status"); + Objects.requireNonNull(severity, "severity"); + title = ApmValidation.requireText(title, "title"); + Objects.requireNonNull(service, "service"); + Objects.requireNonNull(startedAt, "startedAt"); + Objects.requireNonNull(updatedAt, "updatedAt"); + if (updatedAt.isBefore(startedAt)) { + throw new IllegalArgumentException("updatedAt must not be before startedAt"); + } + findings = ApmValidation.copyList(findings, "findings"); + backendLinks = ApmValidation.copyList(backendLinks, "backendLinks"); + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/model/ApmIncidentStatus.java b/argus-apm/src/main/java/io/argus/apm/model/ApmIncidentStatus.java new file mode 100644 index 0000000..2361e81 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/model/ApmIncidentStatus.java @@ -0,0 +1,8 @@ +package io.argus.apm.model; + +public enum ApmIncidentStatus { + OPEN, + INVESTIGATING, + MITIGATED, + RESOLVED +} diff --git a/argus-apm/src/main/java/io/argus/apm/model/ApmInstance.java b/argus-apm/src/main/java/io/argus/apm/model/ApmInstance.java new file mode 100644 index 0000000..22756dd --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/model/ApmInstance.java @@ -0,0 +1,28 @@ +package io.argus.apm.model; + +import java.time.Instant; +import java.util.Objects; + +public record ApmInstance( + String instanceId, + ApmServiceId service, + String deploymentId, + String namespace, + String podName, + String hostId, + ApmInstanceStatus status, + Instant lastSeen, + ApmEntityIdentity identity +) { + public ApmInstance { + instanceId = ApmValidation.requireText(instanceId, "instanceId"); + Objects.requireNonNull(service, "service"); + deploymentId = ApmValidation.requireText(deploymentId, "deploymentId"); + namespace = namespace == null ? "" : namespace; + podName = podName == null ? "" : podName; + hostId = hostId == null ? "" : hostId; + Objects.requireNonNull(status, "status"); + Objects.requireNonNull(lastSeen, "lastSeen"); + Objects.requireNonNull(identity, "identity"); + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/model/ApmInstanceStatus.java b/argus-apm/src/main/java/io/argus/apm/model/ApmInstanceStatus.java new file mode 100644 index 0000000..0df2ba8 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/model/ApmInstanceStatus.java @@ -0,0 +1,8 @@ +package io.argus.apm.model; + +public enum ApmInstanceStatus { + UP, + DEGRADED, + DOWN, + UNKNOWN +} diff --git a/argus-apm/src/main/java/io/argus/apm/model/ApmMetadataConflict.java b/argus-apm/src/main/java/io/argus/apm/model/ApmMetadataConflict.java new file mode 100644 index 0000000..e3f15d8 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/model/ApmMetadataConflict.java @@ -0,0 +1,21 @@ +package io.argus.apm.model; + +import java.util.Objects; + +public record ApmMetadataConflict( + String entityType, + String field, + ApmMetadataSource winningSource, + String winningValue, + ApmMetadataSource losingSource, + String losingValue +) { + public ApmMetadataConflict { + entityType = ApmValidation.requireText(entityType, "entityType"); + field = ApmValidation.requireText(field, "field"); + Objects.requireNonNull(winningSource, "winningSource"); + winningValue = ApmValidation.requireText(winningValue, "winningValue"); + Objects.requireNonNull(losingSource, "losingSource"); + losingValue = ApmValidation.requireText(losingValue, "losingValue"); + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/model/ApmMetadataSource.java b/argus-apm/src/main/java/io/argus/apm/model/ApmMetadataSource.java new file mode 100644 index 0000000..4d21761 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/model/ApmMetadataSource.java @@ -0,0 +1,10 @@ +package io.argus.apm.model; + +public enum ApmMetadataSource { + OPEN_TELEMETRY_RESOURCE, + KUBERNETES_METADATA, + ARGUS_FLEET_LABEL, + USER_OVERRIDE, + SERVICE_CATALOG, + ROUTE_NORMALIZER +} diff --git a/argus-apm/src/main/java/io/argus/apm/model/ApmOwner.java b/argus-apm/src/main/java/io/argus/apm/model/ApmOwner.java new file mode 100644 index 0000000..e932924 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/model/ApmOwner.java @@ -0,0 +1,13 @@ +package io.argus.apm.model; + +public record ApmOwner(String team, String contact, String escalationPolicy) { + public ApmOwner { + team = ApmValidation.requireText(team, "team"); + contact = contact == null ? "" : contact; + escalationPolicy = escalationPolicy == null ? "" : escalationPolicy; + } + + public static ApmOwner unassigned() { + return new ApmOwner("unassigned", "", ""); + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/model/ApmPrincipal.java b/argus-apm/src/main/java/io/argus/apm/model/ApmPrincipal.java new file mode 100644 index 0000000..6b2e4f0 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/model/ApmPrincipal.java @@ -0,0 +1,47 @@ +package io.argus.apm.model; + +import java.util.Objects; +import java.util.Set; + +public record ApmPrincipal( + String subject, + String tenant, + String project, + Set environments, + Set roles, + Set serviceAllowlist +) { + public ApmPrincipal { + subject = ApmValidation.requireText(subject, "subject"); + tenant = ApmValidation.requireText(tenant, "tenant"); + project = ApmValidation.requireText(project, "project"); + environments = ApmValidation.copySet(environments, "environments"); + roles = ApmValidation.copySet(roles, "roles"); + serviceAllowlist = ApmValidation.copySet(serviceAllowlist, "serviceAllowlist"); + if (environments.isEmpty()) { + throw new IllegalArgumentException("environments must not be empty"); + } + if (roles.isEmpty()) { + throw new IllegalArgumentException("roles must not be empty"); + } + } + + public boolean canRead(ApmScope scope) { + Objects.requireNonNull(scope, "scope"); + boolean roleAllowed = roles.contains(ApmRole.VIEWER) + || roles.contains(ApmRole.OPERATOR) + || roles.contains(ApmRole.ADMIN); + if (!roleAllowed) { + return false; + } + if (!tenant.equals(scope.tenant()) || !project.equals(scope.project())) { + return false; + } + if (!environments.contains(scope.environment())) { + return false; + } + return scope.service() == null + || serviceAllowlist.isEmpty() + || serviceAllowlist.contains(scope.service()); + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/model/ApmProfileReference.java b/argus-apm/src/main/java/io/argus/apm/model/ApmProfileReference.java new file mode 100644 index 0000000..2f378de --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/model/ApmProfileReference.java @@ -0,0 +1,27 @@ +package io.argus.apm.model; + +import java.time.Instant; +import java.util.Objects; + +public record ApmProfileReference( + String profileId, + ApmServiceId service, + String instanceId, + String profileType, + Instant startTime, + Instant endTime, + ApmBackendLink backendLink +) { + public ApmProfileReference { + profileId = ApmValidation.requireText(profileId, "profileId"); + Objects.requireNonNull(service, "service"); + instanceId = ApmValidation.requireText(instanceId, "instanceId"); + profileType = ApmValidation.requireText(profileType, "profileType"); + Objects.requireNonNull(startTime, "startTime"); + Objects.requireNonNull(endTime, "endTime"); + if (endTime.isBefore(startTime)) { + throw new IllegalArgumentException("endTime must not be before startTime"); + } + Objects.requireNonNull(backendLink, "backendLink"); + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/model/ApmRole.java b/argus-apm/src/main/java/io/argus/apm/model/ApmRole.java new file mode 100644 index 0000000..6d400dd --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/model/ApmRole.java @@ -0,0 +1,7 @@ +package io.argus.apm.model; + +public enum ApmRole { + VIEWER, + OPERATOR, + ADMIN +} diff --git a/argus-apm/src/main/java/io/argus/apm/model/ApmScope.java b/argus-apm/src/main/java/io/argus/apm/model/ApmScope.java new file mode 100644 index 0000000..a30a77a --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/model/ApmScope.java @@ -0,0 +1,52 @@ +package io.argus.apm.model; + +public record ApmScope( + String tenant, + String project, + String environment, + ApmServiceId service, + String deploymentId, + String instanceId, + String endpointRoute, + ApmTimeRange timeRange +) { + public ApmScope { + tenant = ApmValidation.requireText(tenant, "tenant"); + project = ApmValidation.requireText(project, "project"); + environment = ApmValidation.requireText(environment, "environment"); + deploymentId = optionalText(deploymentId, "deploymentId"); + instanceId = optionalText(instanceId, "instanceId"); + endpointRoute = optionalText(endpointRoute, "endpointRoute"); + } + + public static ApmScope environment(String tenant, String project, String environment) { + return new ApmScope(tenant, project, environment, null, null, null, null, null); + } + + public ApmScope withService(ApmServiceId service) { + return new ApmScope(tenant, project, environment, service, deploymentId, instanceId, endpointRoute, timeRange); + } + + public ApmScope withDeployment(String deploymentId) { + return new ApmScope(tenant, project, environment, service, deploymentId, instanceId, endpointRoute, timeRange); + } + + public ApmScope withInstance(String instanceId) { + return new ApmScope(tenant, project, environment, service, deploymentId, instanceId, endpointRoute, timeRange); + } + + public ApmScope withEndpointRoute(String endpointRoute) { + return new ApmScope(tenant, project, environment, service, deploymentId, instanceId, endpointRoute, timeRange); + } + + public ApmScope withTimeRange(ApmTimeRange timeRange) { + return new ApmScope(tenant, project, environment, service, deploymentId, instanceId, endpointRoute, timeRange); + } + + private static String optionalText(String value, String name) { + if (value == null) { + return null; + } + return ApmValidation.requireText(value, name); + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/model/ApmServiceDetail.java b/argus-apm/src/main/java/io/argus/apm/model/ApmServiceDetail.java new file mode 100644 index 0000000..65f2011 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/model/ApmServiceDetail.java @@ -0,0 +1,20 @@ +package io.argus.apm.model; + +import java.util.List; +import java.util.Objects; + +public record ApmServiceDetail( + ApmServiceSummary summary, + List deployments, + List instances, + List endpoints, + List profiles +) { + public ApmServiceDetail { + Objects.requireNonNull(summary, "summary"); + deployments = ApmValidation.copyList(deployments, "deployments"); + instances = ApmValidation.copyList(instances, "instances"); + endpoints = ApmValidation.copyList(endpoints, "endpoints"); + profiles = ApmValidation.copyList(profiles, "profiles"); + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/model/ApmServiceId.java b/argus-apm/src/main/java/io/argus/apm/model/ApmServiceId.java new file mode 100644 index 0000000..0849bd1 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/model/ApmServiceId.java @@ -0,0 +1,12 @@ +package io.argus.apm.model; + +public record ApmServiceId(String namespace, String name) { + public ApmServiceId { + namespace = namespace == null ? "" : namespace; + name = ApmValidation.requireText(name, "name"); + } + + public String displayName() { + return namespace.isBlank() ? name : namespace + "/" + name; + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/model/ApmServiceSummary.java b/argus-apm/src/main/java/io/argus/apm/model/ApmServiceSummary.java new file mode 100644 index 0000000..a348926 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/model/ApmServiceSummary.java @@ -0,0 +1,27 @@ +package io.argus.apm.model; + +import java.util.List; +import java.util.Objects; + +public record ApmServiceSummary( + ApmServiceId service, + String displayName, + ApmHealth health, + ApmOwner owner, + RunbookLink runbook, + ApmSignalStats signals, + ApmEntityIdentity identity, + List findings, + List backendLinks +) { + public ApmServiceSummary { + Objects.requireNonNull(service, "service"); + displayName = ApmValidation.requireText(displayName, "displayName"); + Objects.requireNonNull(health, "health"); + owner = owner == null ? ApmOwner.unassigned() : owner; + Objects.requireNonNull(signals, "signals"); + Objects.requireNonNull(identity, "identity"); + findings = ApmValidation.copyList(findings, "findings"); + backendLinks = ApmValidation.copyList(backendLinks, "backendLinks"); + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/model/ApmSeverity.java b/argus-apm/src/main/java/io/argus/apm/model/ApmSeverity.java new file mode 100644 index 0000000..518e5e0 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/model/ApmSeverity.java @@ -0,0 +1,7 @@ +package io.argus.apm.model; + +public enum ApmSeverity { + INFO, + WARNING, + CRITICAL +} diff --git a/argus-apm/src/main/java/io/argus/apm/model/ApmSignalStats.java b/argus-apm/src/main/java/io/argus/apm/model/ApmSignalStats.java new file mode 100644 index 0000000..feeb7a9 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/model/ApmSignalStats.java @@ -0,0 +1,33 @@ +package io.argus.apm.model; + +public record ApmSignalStats( + double requestRatePerSecond, + double errorRate, + double latencyP50Millis, + double latencyP95Millis, + double latencyP99Millis, + double gcPauseP95Millis, + double heapUsedRatio, + double cpuUsageRatio +) { + public ApmSignalStats { + requireFiniteNonNegative(requestRatePerSecond, "requestRatePerSecond"); + requireFiniteNonNegative(errorRate, "errorRate"); + requireFiniteNonNegative(latencyP50Millis, "latencyP50Millis"); + requireFiniteNonNegative(latencyP95Millis, "latencyP95Millis"); + requireFiniteNonNegative(latencyP99Millis, "latencyP99Millis"); + requireFiniteNonNegative(gcPauseP95Millis, "gcPauseP95Millis"); + requireFiniteNonNegative(heapUsedRatio, "heapUsedRatio"); + requireFiniteNonNegative(cpuUsageRatio, "cpuUsageRatio"); + } + + public static ApmSignalStats empty() { + return new ApmSignalStats(0, 0, 0, 0, 0, 0, 0, 0); + } + + private static void requireFiniteNonNegative(double value, String name) { + if (!Double.isFinite(value) || value < 0) { + throw new IllegalArgumentException(name + " must be finite and non-negative"); + } + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/model/ApmSpanSummary.java b/argus-apm/src/main/java/io/argus/apm/model/ApmSpanSummary.java new file mode 100644 index 0000000..229219d --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/model/ApmSpanSummary.java @@ -0,0 +1,30 @@ +package io.argus.apm.model; + +import java.time.Instant; +import java.util.Objects; + +public record ApmSpanSummary( + String spanId, + String parentSpanId, + ApmServiceId service, + String name, + String endpointRoute, + Instant startTime, + long durationMillis +) { + public ApmSpanSummary { + spanId = ApmValidation.requireText(spanId, "spanId"); + parentSpanId = parentSpanId == null ? "" : parentSpanId; + Objects.requireNonNull(service, "service"); + name = ApmValidation.requireText(name, "name"); + endpointRoute = endpointRoute == null ? "" : endpointRoute; + Objects.requireNonNull(startTime, "startTime"); + if (durationMillis < 0) { + throw new IllegalArgumentException("durationMillis must be non-negative"); + } + } + + public Instant endTime() { + return startTime.plusMillis(durationMillis); + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/model/ApmTimeRange.java b/argus-apm/src/main/java/io/argus/apm/model/ApmTimeRange.java new file mode 100644 index 0000000..fb26d75 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/model/ApmTimeRange.java @@ -0,0 +1,14 @@ +package io.argus.apm.model; + +import java.time.Instant; +import java.util.Objects; + +public record ApmTimeRange(Instant start, Instant end) { + public ApmTimeRange { + Objects.requireNonNull(start, "start"); + Objects.requireNonNull(end, "end"); + if (end.isBefore(start)) { + throw new IllegalArgumentException("end must not be before start"); + } + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/model/ApmTraceContext.java b/argus-apm/src/main/java/io/argus/apm/model/ApmTraceContext.java new file mode 100644 index 0000000..24d317d --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/model/ApmTraceContext.java @@ -0,0 +1,33 @@ +package io.argus.apm.model; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +public record ApmTraceContext( + String traceId, + ApmServiceId rootService, + String rootSpanId, + Instant startTime, + long durationMillis, + ApmHealth health, + List spans, + List findings, + List profiles, + List backendLinks +) { + public ApmTraceContext { + traceId = ApmValidation.requireText(traceId, "traceId"); + Objects.requireNonNull(rootService, "rootService"); + rootSpanId = ApmValidation.requireText(rootSpanId, "rootSpanId"); + Objects.requireNonNull(startTime, "startTime"); + if (durationMillis < 0) { + throw new IllegalArgumentException("durationMillis must be non-negative"); + } + Objects.requireNonNull(health, "health"); + spans = ApmValidation.copyList(spans, "spans"); + findings = ApmValidation.copyList(findings, "findings"); + profiles = ApmValidation.copyList(profiles, "profiles"); + backendLinks = ApmValidation.copyList(backendLinks, "backendLinks"); + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/model/ApmValidation.java b/argus-apm/src/main/java/io/argus/apm/model/ApmValidation.java new file mode 100644 index 0000000..12dfbb4 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/model/ApmValidation.java @@ -0,0 +1,31 @@ +package io.argus.apm.model; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +final class ApmValidation { + private ApmValidation() { + } + + static String requireText(String value, String name) { + Objects.requireNonNull(value, name); + if (value.isBlank()) { + throw new IllegalArgumentException(name + " must not be blank"); + } + return value; + } + + static List copyList(List values, String name) { + return List.copyOf(Objects.requireNonNull(values, name)); + } + + static Set copySet(Set values, String name) { + return Set.copyOf(Objects.requireNonNull(values, name)); + } + + static Map copyMap(Map values, String name) { + return Map.copyOf(Objects.requireNonNull(values, name)); + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/model/JvmFinding.java b/argus-apm/src/main/java/io/argus/apm/model/JvmFinding.java new file mode 100644 index 0000000..cb6a5c7 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/model/JvmFinding.java @@ -0,0 +1,33 @@ +package io.argus.apm.model; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +public record JvmFinding( + String findingId, + ApmFindingKind kind, + ApmSeverity severity, + String title, + String detail, + ApmServiceId service, + String instanceId, + String traceId, + String spanId, + Instant observedAt, + List backendLinks +) { + public JvmFinding { + findingId = ApmValidation.requireText(findingId, "findingId"); + Objects.requireNonNull(kind, "kind"); + Objects.requireNonNull(severity, "severity"); + title = ApmValidation.requireText(title, "title"); + detail = detail == null ? "" : detail; + Objects.requireNonNull(service, "service"); + instanceId = instanceId == null ? "" : instanceId; + traceId = traceId == null ? "" : traceId; + spanId = spanId == null ? "" : spanId; + Objects.requireNonNull(observedAt, "observedAt"); + backendLinks = ApmValidation.copyList(backendLinks, "backendLinks"); + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/model/RunbookLink.java b/argus-apm/src/main/java/io/argus/apm/model/RunbookLink.java new file mode 100644 index 0000000..326ddb2 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/model/RunbookLink.java @@ -0,0 +1,11 @@ +package io.argus.apm.model; + +import java.net.URI; +import java.util.Objects; + +public record RunbookLink(String title, URI uri) { + public RunbookLink { + title = ApmValidation.requireText(title, "title"); + Objects.requireNonNull(uri, "uri"); + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/otel/OtelSemanticAttributes.java b/argus-apm/src/main/java/io/argus/apm/otel/OtelSemanticAttributes.java new file mode 100644 index 0000000..127c5e9 --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/otel/OtelSemanticAttributes.java @@ -0,0 +1,20 @@ +package io.argus.apm.otel; + +/** + * OTel semantic-convention keys used by the APM facade model. + */ +public final class OtelSemanticAttributes { + public static final String SERVICE_NAME = "service.name"; + public static final String SERVICE_NAMESPACE = "service.namespace"; + public static final String SERVICE_VERSION = "service.version"; + public static final String DEPLOYMENT_ENVIRONMENT_NAME = "deployment.environment.name"; + public static final String DEPLOYMENT_ENVIRONMENT_LEGACY = "deployment.environment"; + public static final String HTTP_ROUTE = "http.route"; + public static final String HTTP_REQUEST_METHOD = "http.request.method"; + public static final String URL_PATH = "url.path"; + public static final String TRACE_ID = "trace_id"; + public static final String SPAN_ID = "span_id"; + + private OtelSemanticAttributes() { + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/security/ApmAuthorizationDecision.java b/argus-apm/src/main/java/io/argus/apm/security/ApmAuthorizationDecision.java new file mode 100644 index 0000000..c218d6e --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/security/ApmAuthorizationDecision.java @@ -0,0 +1,15 @@ +package io.argus.apm.security; + +public record ApmAuthorizationDecision(boolean allowed, String reason) { + public ApmAuthorizationDecision { + reason = reason == null ? "" : reason; + } + + public static ApmAuthorizationDecision allow() { + return new ApmAuthorizationDecision(true, "allowed"); + } + + public static ApmAuthorizationDecision deny(String reason) { + return new ApmAuthorizationDecision(false, reason); + } +} diff --git a/argus-apm/src/main/java/io/argus/apm/security/ApmAuthorizer.java b/argus-apm/src/main/java/io/argus/apm/security/ApmAuthorizer.java new file mode 100644 index 0000000..42e64fb --- /dev/null +++ b/argus-apm/src/main/java/io/argus/apm/security/ApmAuthorizer.java @@ -0,0 +1,42 @@ +package io.argus.apm.security; + +import io.argus.apm.dto.ApmBackendLinksRequest; +import io.argus.apm.model.ApmPrincipal; +import io.argus.apm.model.ApmScope; +import io.argus.apm.model.ApmServiceId; + +public final class ApmAuthorizer { + private ApmAuthorizer() { + } + + public static ApmAuthorizationDecision authorizeRead(ApmPrincipal principal, ApmScope scope) { + if (principal == null) { + return ApmAuthorizationDecision.deny("missing principal"); + } + if (scope == null) { + return ApmAuthorizationDecision.deny("missing scope"); + } + if (!principal.canRead(scope)) { + return ApmAuthorizationDecision.deny("principal is outside tenant/project/environment/service scope"); + } + return ApmAuthorizationDecision.allow(); + } + + public static ApmAuthorizationDecision authorizeBackendLinks( + ApmPrincipal principal, + ApmBackendLinksRequest request + ) { + if (request == null) { + return ApmAuthorizationDecision.deny("missing backend links request"); + } + ApmScope scope = request.scope(); + ApmServiceId requestedService = request.service(); + if (requestedService != null) { + if (scope.service() != null && !scope.service().equals(requestedService)) { + return ApmAuthorizationDecision.deny("backend link service must match scope service"); + } + scope = scope.withService(requestedService); + } + return authorizeRead(principal, scope); + } +} diff --git a/argus-apm/src/test/java/io/argus/apm/ApmBackendLinkRouterTest.java b/argus-apm/src/test/java/io/argus/apm/ApmBackendLinkRouterTest.java new file mode 100644 index 0000000..dc5a848 --- /dev/null +++ b/argus-apm/src/test/java/io/argus/apm/ApmBackendLinkRouterTest.java @@ -0,0 +1,80 @@ +package io.argus.apm; + +import io.argus.apm.link.ApmBackendLinkContext; +import io.argus.apm.link.ApmBackendLinkRouter; +import io.argus.apm.model.ApmBackendLink; +import io.argus.apm.model.ApmBackendSignal; +import io.argus.apm.model.ApmScope; +import io.argus.apm.model.ApmServiceId; +import io.argus.apm.model.ApmTimeRange; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.time.Instant; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ApmBackendLinkRouterTest { + @Test + void mvpLinksPreserveScopeAndSignalContext() { + Instant start = Instant.parse("2026-06-09T00:00:00Z"); + ApmScope scope = ApmScope.environment("tenant-a", "payments", "prod") + .withService(new ApmServiceId("shop", "checkout")) + .withDeployment("checkout-7f4c") + .withInstance("pod-1") + .withEndpointRoute("/checkout/{id}") + .withTimeRange(new ApmTimeRange(start, start.plusSeconds(60))); + + ApmBackendLinkRouter router = ApmBackendLinkRouter.mvp( + URI.create("https://grafana.example"), + URI.create("https://tempo.example"), + URI.create("https://loki.example"), + URI.create("https://pyroscope.example") + ); + + List links = router.linksFor(new ApmBackendLinkContext( + scope, + scope.service(), + null, + null, + null, + "trace-1", + "span-1" + )); + + assertEquals(4, links.size()); + assertTrue(links.stream().anyMatch(link -> link.signal() == ApmBackendSignal.METRICS)); + assertTrue(links.stream().anyMatch(link -> link.label().equals("tempo-or-jaeger"))); + for (ApmBackendLink link : links) { + String uri = link.uri().toString(); + assertTrue(uri.contains("tenant=tenant-a"), uri); + assertTrue(uri.contains("project=payments"), uri); + assertTrue(uri.contains("environment=prod"), uri); + assertTrue(uri.contains("service=shop%2Fcheckout"), uri); + assertTrue(uri.contains("traceId=trace-1"), uri); + assertFalse(ApmFacadeRoutes.isForbiddenAggregatorRoute(link.uri().getPath()), uri); + } + } + + @Test + void canFilterLinksBySignal() { + ApmScope scope = ApmScope.environment("tenant-a", "payments", "prod"); + ApmBackendLinkRouter router = ApmBackendLinkRouter.mvp( + URI.create("https://grafana.example"), + URI.create("https://tempo.example"), + URI.create("https://loki.example"), + URI.create("https://pyroscope.example") + ); + + List traces = router.linksFor( + ApmBackendSignal.TRACES, + new ApmBackendLinkContext(scope, null, null, null, null, "trace-1", "span-1") + ); + + assertEquals(1, traces.size()); + assertEquals("tempo-or-jaeger", traces.get(0).label()); + } +} diff --git a/argus-apm/src/test/java/io/argus/apm/ApmDemoCatalogTest.java b/argus-apm/src/test/java/io/argus/apm/ApmDemoCatalogTest.java new file mode 100644 index 0000000..6ad56fb --- /dev/null +++ b/argus-apm/src/test/java/io/argus/apm/ApmDemoCatalogTest.java @@ -0,0 +1,48 @@ +package io.argus.apm; + +import io.argus.apm.demo.ApmDemoCatalog; +import io.argus.apm.demo.ApmDemoScenario; +import io.argus.apm.demo.ApmDemoScenarioType; +import io.argus.apm.demo.ApmDemoTopology; +import org.junit.jupiter.api.Test; + +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ApmDemoCatalogTest { + @Test + void defaultTopologyCoversRequiredE2eIncidentScenarios() { + ApmDemoTopology topology = ApmDemoCatalog.defaultTopology(); + + assertEquals(Set.of( + ApmDemoScenarioType.GC_LATENCY, + ApmDemoScenarioType.LOCK_CONTENTION, + ApmDemoScenarioType.VIRTUAL_THREAD_PINNING, + ApmDemoScenarioType.BAD_RELEASE_REGRESSION + ), topology.scenarios().stream().map(ApmDemoScenario::type).collect(Collectors.toSet())); + assertEquals(2, topology.services().size()); + assertEquals(4, topology.incidents().size()); + assertEquals(4, topology.traces().size()); + assertTrue(topology.services().stream() + .flatMap(service -> service.endpoints().stream()) + .anyMatch(endpoint -> endpoint.method().equals("POST") && endpoint.route().equals("/session"))); + } + + @Test + void eachScenarioHasTraceIncidentFindingAndSafeDrilldowns() { + for (ApmDemoScenario scenario : ApmDemoCatalog.defaultTopology().scenarios()) { + assertFalse(scenario.findings().isEmpty(), scenario.type().name()); + assertFalse(scenario.backendLinks().isEmpty(), scenario.type().name()); + assertEquals(scenario.trace().traceId(), scenario.findings().get(0).traceId()); + assertEquals(scenario.incident().findings().get(0), scenario.findings().get(0)); + assertTrue(scenario.localDashboardPath().startsWith("/?service=")); + assertTrue(scenario.grafanaPath().contains("tenant=tenant-a")); + scenario.backendLinks().forEach(link -> + assertFalse(ApmFacadeRoutes.isForbiddenAggregatorRoute(link.uri().getPath()), link.uri().toString())); + } + } +} diff --git a/argus-apm/src/test/java/io/argus/apm/ApmFacadeBoundaryTest.java b/argus-apm/src/test/java/io/argus/apm/ApmFacadeBoundaryTest.java new file mode 100644 index 0000000..7f65c03 --- /dev/null +++ b/argus-apm/src/test/java/io/argus/apm/ApmFacadeBoundaryTest.java @@ -0,0 +1,37 @@ +package io.argus.apm; + +import io.argus.apm.model.ApmBackendSignal; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.lang.reflect.Type; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ApmFacadeBoundaryTest { + @Test + void publicRoutesDoNotExposeAggregatorV1Alpha1Routes() { + for (String route : ApmFacadeRoutes.publicRoutes()) { + assertFalse(ApmFacadeRoutes.isForbiddenAggregatorRoute(route)); + } + assertThrows(ClassNotFoundException.class, + () -> Class.forName("io.argus.aggregator.model.PodTarget")); + } + + @Test + void facadeSignaturesStayOnApmDtos() { + for (Method method : ApmFacade.class.getDeclaredMethods()) { + assertNoAggregatorType(method.getGenericReturnType()); + for (Type parameterType : method.getGenericParameterTypes()) { + assertNoAggregatorType(parameterType); + } + } + assertFalse(ApmFacadeRoutes.isForbiddenAggregatorRoute("/apm/backend-links")); + assertFalse(ApmBackendSignal.METRICS.name().isBlank()); + } + + private static void assertNoAggregatorType(Type type) { + assertFalse(type.getTypeName().contains("io.argus.aggregator"), type.getTypeName()); + } +} diff --git a/argus-apm/src/test/java/io/argus/apm/ApmModelTest.java b/argus-apm/src/test/java/io/argus/apm/ApmModelTest.java new file mode 100644 index 0000000..3b5f506 --- /dev/null +++ b/argus-apm/src/test/java/io/argus/apm/ApmModelTest.java @@ -0,0 +1,200 @@ +package io.argus.apm; + +import io.argus.apm.dto.ApmBackendLinksRequest; +import io.argus.apm.dto.ApmEndpointListResponse; +import io.argus.apm.dto.ApmIncidentListResponse; +import io.argus.apm.dto.ApmServiceDetailResponse; +import io.argus.apm.dto.ApmServiceInventoryResponse; +import io.argus.apm.dto.ApmTraceResponse; +import io.argus.apm.model.ApmBackendLink; +import io.argus.apm.model.ApmBackendSignal; +import io.argus.apm.model.ApmDeployment; +import io.argus.apm.model.ApmEndpointSummary; +import io.argus.apm.model.ApmEntityIdentity; +import io.argus.apm.model.ApmFindingKind; +import io.argus.apm.model.ApmHealth; +import io.argus.apm.model.ApmIncident; +import io.argus.apm.model.ApmIncidentStatus; +import io.argus.apm.model.ApmInstance; +import io.argus.apm.model.ApmInstanceStatus; +import io.argus.apm.model.ApmMetadataConflict; +import io.argus.apm.model.ApmMetadataSource; +import io.argus.apm.model.ApmOwner; +import io.argus.apm.model.ApmPrincipal; +import io.argus.apm.model.ApmProfileReference; +import io.argus.apm.model.ApmRole; +import io.argus.apm.model.ApmScope; +import io.argus.apm.model.ApmServiceDetail; +import io.argus.apm.model.ApmServiceId; +import io.argus.apm.model.ApmServiceSummary; +import io.argus.apm.model.ApmSeverity; +import io.argus.apm.model.ApmSignalStats; +import io.argus.apm.model.ApmSpanSummary; +import io.argus.apm.model.ApmTimeRange; +import io.argus.apm.model.ApmTraceContext; +import io.argus.apm.model.JvmFinding; +import io.argus.apm.model.RunbookLink; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ApmModelTest { + @Test + void principalScopesTenantProjectEnvironmentAndServiceAllowlist() { + ApmServiceId checkout = new ApmServiceId("shop", "checkout"); + ApmPrincipal principal = new ApmPrincipal( + "user-1", + "tenant-a", + "payments", + Set.of("prod"), + Set.of(ApmRole.VIEWER), + Set.of(checkout) + ); + + assertTrue(principal.canRead(ApmScope.environment("tenant-a", "payments", "prod").withService(checkout))); + assertFalse(principal.canRead(ApmScope.environment("tenant-a", "payments", "dev").withService(checkout))); + assertFalse(principal.canRead(ApmScope.environment("tenant-a", "payments", "prod") + .withService(new ApmServiceId("shop", "catalog")))); + assertThrows(IllegalArgumentException.class, + () -> new ApmPrincipal("user-1", "tenant-a", "payments", Set.of(), Set.of(ApmRole.VIEWER), Set.of())); + } + + @Test + void facadeDtoGraphCoversApmEntities() { + Instant now = Instant.parse("2026-06-09T00:00:00Z"); + ApmServiceId serviceId = new ApmServiceId("shop", "checkout"); + ApmScope scope = ApmScope.environment("tenant-a", "payments", "prod") + .withService(serviceId) + .withTimeRange(new ApmTimeRange(now.minusSeconds(300), now)); + ApmBackendLink metricsLink = new ApmBackendLink( + ApmBackendSignal.METRICS, + "prometheus", + URI.create("https://grafana.example/d/checkout?var-service=checkout"), + false, + scope + ); + ApmEntityIdentity identity = new ApmEntityIdentity( + "otel:shop/checkout", + ApmMetadataSource.OPEN_TELEMETRY_RESOURCE, + Map.of("service.name", "checkout"), + List.of(new ApmMetadataConflict( + "service", + "name", + ApmMetadataSource.OPEN_TELEMETRY_RESOURCE, + "checkout", + ApmMetadataSource.KUBERNETES_METADATA, + "checkout-v2" + )) + ); + JvmFinding finding = new JvmFinding( + "finding-1", + ApmFindingKind.GC_PAUSE, + ApmSeverity.WARNING, + "GC pause regression", + "p95 pause exceeded SLO", + serviceId, + "pod-1", + "trace-1", + "span-1", + now, + List.of(metricsLink) + ); + ApmServiceSummary summary = new ApmServiceSummary( + serviceId, + serviceId.displayName(), + ApmHealth.DEGRADED, + new ApmOwner("payments-platform", "payments@example.com", "pagerduty/payments"), + new RunbookLink("GC latency", URI.create("https://runbooks.example/gc")), + new ApmSignalStats(120.0, 0.02, 20.0, 180.0, 420.0, 95.0, 0.71, 0.64), + identity, + List.of(finding), + List.of(metricsLink) + ); + ApmDeployment deployment = new ApmDeployment( + "checkout-7f4c", + serviceId, + "2026.06.09", + "prod", + now.minusSeconds(3600), + now, + ApmHealth.DEGRADED, + identity + ); + ApmInstance instance = new ApmInstance( + "pod-1", + serviceId, + "checkout-7f4c", + "shop", + "checkout-7f4c-abc", + "node-a", + ApmInstanceStatus.DEGRADED, + now, + identity + ); + ApmEndpointSummary endpoint = new ApmEndpointSummary( + serviceId, + "get", + "/checkout/{id}", + identity, + ApmSignalStats.empty(), + List.of(finding), + List.of(metricsLink) + ); + ApmProfileReference profile = new ApmProfileReference( + "profile-1", + serviceId, + "pod-1", + "allocation", + now.minusSeconds(60), + now, + metricsLink + ); + ApmServiceDetail detail = new ApmServiceDetail( + summary, + List.of(deployment), + List.of(instance), + List.of(endpoint), + List.of(profile) + ); + ApmTraceContext trace = new ApmTraceContext( + "trace-1", + serviceId, + "span-1", + now.minusSeconds(5), + 120, + ApmHealth.DEGRADED, + List.of(new ApmSpanSummary("span-1", "", serviceId, "GET /checkout/{id}", "/checkout/{id}", now, 120)), + List.of(finding), + List.of(profile), + List.of(metricsLink) + ); + ApmIncident incident = new ApmIncident( + "incident-1", + ApmIncidentStatus.OPEN, + ApmSeverity.WARNING, + "Checkout latency regression", + serviceId, + now.minusSeconds(120), + now, + List.of(finding), + List.of(metricsLink) + ); + + assertEquals(1, new ApmServiceInventoryResponse(scope, List.of(summary)).services().size()); + assertEquals(serviceId, new ApmServiceDetailResponse(scope, detail).service().summary().service()); + assertEquals("/checkout/{id}", new ApmEndpointListResponse(scope, serviceId, List.of(endpoint)).endpoints().get(0).route()); + assertEquals("trace-1", new ApmTraceResponse(scope, trace).trace().traceId()); + assertEquals(ApmIncidentStatus.OPEN, new ApmIncidentListResponse(scope, List.of(incident)).incidents().get(0).status()); + assertEquals(ApmBackendSignal.METRICS, new ApmBackendLinksRequest(scope, ApmBackendSignal.METRICS, + serviceId, "pod-1", "/checkout/{id}", "trace-1", "span-1").signal()); + } +} diff --git a/argus-apm/src/test/java/io/argus/apm/ApmOtelSemanticAttributesTest.java b/argus-apm/src/test/java/io/argus/apm/ApmOtelSemanticAttributesTest.java new file mode 100644 index 0000000..6db816b --- /dev/null +++ b/argus-apm/src/test/java/io/argus/apm/ApmOtelSemanticAttributesTest.java @@ -0,0 +1,20 @@ +package io.argus.apm; + +import io.argus.apm.otel.OtelSemanticAttributes; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ApmOtelSemanticAttributesTest { + @Test + void keepsOtelResourceAndRouteAttributeNamesStable() { + assertEquals("service.name", OtelSemanticAttributes.SERVICE_NAME); + assertEquals("service.namespace", OtelSemanticAttributes.SERVICE_NAMESPACE); + assertEquals("service.version", OtelSemanticAttributes.SERVICE_VERSION); + assertEquals("deployment.environment.name", OtelSemanticAttributes.DEPLOYMENT_ENVIRONMENT_NAME); + assertEquals("http.route", OtelSemanticAttributes.HTTP_ROUTE); + assertEquals("http.request.method", OtelSemanticAttributes.HTTP_REQUEST_METHOD); + assertEquals("trace_id", OtelSemanticAttributes.TRACE_ID); + assertEquals("span_id", OtelSemanticAttributes.SPAN_ID); + } +} diff --git a/argus-apm/src/test/java/io/argus/apm/ApmSecurityAndGuardrailsTest.java b/argus-apm/src/test/java/io/argus/apm/ApmSecurityAndGuardrailsTest.java new file mode 100644 index 0000000..42c0596 --- /dev/null +++ b/argus-apm/src/test/java/io/argus/apm/ApmSecurityAndGuardrailsTest.java @@ -0,0 +1,139 @@ +package io.argus.apm; + +import io.argus.apm.dto.ApmBackendLinksRequest; +import io.argus.apm.guard.ApmEndpointCardinalityGuard; +import io.argus.apm.guard.ApmOverheadBudget; +import io.argus.apm.guard.ApmRouteNormalization; +import io.argus.apm.metrics.ApmSelfMetrics; +import io.argus.apm.model.ApmBackendSignal; +import io.argus.apm.model.ApmPrincipal; +import io.argus.apm.model.ApmRole; +import io.argus.apm.model.ApmScope; +import io.argus.apm.model.ApmServiceId; +import io.argus.apm.security.ApmAuthorizer; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ApmSecurityAndGuardrailsTest { + @Test + void authorizerFailsClosedAcrossTenantProjectEnvironmentAndService() { + ApmServiceId checkout = new ApmServiceId("shop", "checkout"); + ApmPrincipal principal = new ApmPrincipal( + "user-1", "tenant-a", "payments", Set.of("prod"), + Set.of(ApmRole.VIEWER), Set.of(checkout)); + + assertTrue(ApmAuthorizer.authorizeRead(principal, + ApmScope.environment("tenant-a", "payments", "prod").withService(checkout)).allowed()); + assertFalse(ApmAuthorizer.authorizeRead(null, + ApmScope.environment("tenant-a", "payments", "prod")).allowed()); + assertFalse(ApmAuthorizer.authorizeRead(principal, + ApmScope.environment("tenant-a", "payments", "staging").withService(checkout)).allowed()); + assertFalse(ApmAuthorizer.authorizeRead(principal, + ApmScope.environment("tenant-a", "payments", "prod") + .withService(new ApmServiceId("shop", "catalog"))).allowed()); + } + + @Test + void backendLinkAuthorizationBindsRequestServiceToScope() { + ApmServiceId checkout = new ApmServiceId("shop", "checkout"); + ApmServiceId catalog = new ApmServiceId("shop", "catalog"); + ApmPrincipal principal = new ApmPrincipal( + "user-1", "tenant-a", "payments", Set.of("prod"), + Set.of(ApmRole.VIEWER), Set.of(checkout)); + ApmScope environmentScope = ApmScope.environment("tenant-a", "payments", "prod"); + + assertTrue(ApmAuthorizer.authorizeBackendLinks(principal, + new ApmBackendLinksRequest( + environmentScope, ApmBackendSignal.METRICS, checkout, + null, null, null, null)).allowed()); + assertFalse(ApmAuthorizer.authorizeBackendLinks(principal, + new ApmBackendLinksRequest( + environmentScope, ApmBackendSignal.METRICS, catalog, + null, null, null, null)).allowed()); + assertFalse(ApmAuthorizer.authorizeBackendLinks(principal, + new ApmBackendLinksRequest( + environmentScope.withService(checkout), ApmBackendSignal.METRICS, catalog, + null, null, null, null)).allowed()); + assertFalse(ApmAuthorizer.authorizeBackendLinks(principal, null).allowed()); + } + + @Test + void endpointCardinalityGuardNormalizesIdsAndRejectsRunawayRoutes() { + ApmRouteNormalization normalized = ApmEndpointCardinalityGuard.normalize( + "GET", "/orders/1234567890/items/8f14e45fceea167a5a36dedd4bea2543?debug=true"); + + assertEquals("GET", normalized.method()); + assertEquals("/orders/{id}/items/{id}", normalized.route()); + assertTrue(normalized.normalized()); + assertThrows(IllegalArgumentException.class, + () -> ApmEndpointCardinalityGuard.normalize("GET", "/a/b/c/d/e/f/g/h/i/j/k/l/m")); + } + + @Test + void overheadBudgetStopsCardinalityAndFanoutBlowups() { + ApmOverheadBudget budget = new ApmOverheadBudget(10, 20, 5, 4); + + assertTrue(budget.validate(10, 20, 5, 4).allowed()); + assertEquals("endpoints_limit", budget.validate(10, 21, 5, 4).code()); + assertEquals("backend_links_limit", budget.validate(10, 20, 5, 5).code()); + } + + @Test + void selfMetricsExposeFacadeGuardrailCounters() { + ApmSelfMetrics metrics = new ApmSelfMetrics(); + metrics.recordFacadeRequest(20, false); + metrics.recordFacadeRequest(40, true); + metrics.recordAuthorizationDenied(); + metrics.recordRouteRejected(); + metrics.recordBackendLinksGenerated(5); + + ApmSelfMetrics.Snapshot snapshot = metrics.snapshot(); + assertEquals(2, snapshot.facadeRequests()); + assertEquals(1, snapshot.facadeErrors()); + assertEquals(30.0, snapshot.averageLatencyMillis()); + assertTrue(metrics.toPrometheusText().contains("argus_apm_authorization_denied_total 1")); + assertTrue(metrics.toPrometheusText().contains("argus_apm_backend_links_generated_total 5")); + } + + @Test + void apmModuleHasNoAggregatorDependencyOrImports() throws Exception { + assertFalse(readRepoFile("argus-apm/build.gradle.kts").contains("argus-aggregator")); + String source = allJavaSource("argus-apm/src/main/java"); + assertFalse(source.contains("import io.argus.aggregator")); + assertFalse(source.contains("project(\":argus-aggregator\")")); + } + + private static String allJavaSource(String relativeDir) throws IOException { + Path dir = repoRoot().resolve(relativeDir); + StringBuilder out = new StringBuilder(); + try (var stream = Files.walk(dir)) { + for (Path path : stream.filter(p -> p.toString().endsWith(".java")).toList()) { + out.append(Files.readString(path)).append('\n'); + } + } + return out.toString(); + } + + private static String readRepoFile(String relative) throws IOException { + return Files.readString(repoRoot().resolve(relative)); + } + + private static Path repoRoot() throws IOException { + Path userDir = Path.of(System.getProperty("user.dir")).toAbsolutePath(); + for (Path root = userDir; root != null; root = root.getParent()) { + if (Files.exists(root.resolve("settings.gradle.kts"))) { + return root; + } + } + throw new IOException("Unable to find repo root from " + userDir); + } +} diff --git a/argus-apm/src/test/java/io/argus/apm/ApmTraceFindingCorrelatorTest.java b/argus-apm/src/test/java/io/argus/apm/ApmTraceFindingCorrelatorTest.java new file mode 100644 index 0000000..31c91c0 --- /dev/null +++ b/argus-apm/src/test/java/io/argus/apm/ApmTraceFindingCorrelatorTest.java @@ -0,0 +1,68 @@ +package io.argus.apm; + +import io.argus.apm.correlation.ApmCorrelationReason; +import io.argus.apm.correlation.ApmTraceCorrelationResult; +import io.argus.apm.correlation.ApmTraceFindingCorrelator; +import io.argus.apm.model.ApmFindingKind; +import io.argus.apm.model.ApmHealth; +import io.argus.apm.model.ApmServiceId; +import io.argus.apm.model.ApmSeverity; +import io.argus.apm.model.ApmSpanSummary; +import io.argus.apm.model.ApmTraceContext; +import io.argus.apm.model.JvmFinding; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ApmTraceFindingCorrelatorTest { + @Test + void prefersSpanIdThenTraceIdThenTimingOverlap() { + Instant now = Instant.parse("2026-06-09T00:00:00Z"); + ApmServiceId checkout = new ApmServiceId("shop", "checkout"); + ApmTraceContext trace = new ApmTraceContext( + "trace-1", + checkout, + "span-1", + now, + 200, + ApmHealth.DEGRADED, + List.of(new ApmSpanSummary("span-1", "", checkout, "GET /checkout/{id}", "/checkout/{id}", now, 200)), + List.of(), + List.of(), + List.of() + ); + + JvmFinding spanMatch = finding("finding-1", checkout, "trace-1", "span-1", now.plusMillis(10)); + JvmFinding traceMatch = finding("finding-2", checkout, "trace-1", "", now.plusMillis(20)); + JvmFinding timingMatch = finding("finding-3", checkout, "", "", now.plusMillis(100)); + JvmFinding noMatch = finding("finding-4", new ApmServiceId("shop", "catalog"), "trace-2", "", now); + + ApmTraceCorrelationResult result = new ApmTraceFindingCorrelator() + .correlate(trace, List.of(spanMatch, traceMatch, timingMatch, noMatch)); + + assertEquals(3, result.matches().size()); + assertEquals(ApmCorrelationReason.TRACE_AND_SPAN_ID, result.matches().get(0).reason()); + assertEquals(ApmCorrelationReason.TRACE_ID, result.matches().get(1).reason()); + assertEquals(ApmCorrelationReason.SERVICE_AND_TIME_OVERLAP, result.matches().get(2).reason()); + assertEquals(List.of(noMatch), result.unmatchedFindings()); + } + + private static JvmFinding finding(String id, ApmServiceId service, String traceId, String spanId, Instant observedAt) { + return new JvmFinding( + id, + ApmFindingKind.GC_PAUSE, + ApmSeverity.WARNING, + "GC pause", + "", + service, + "pod-1", + traceId, + spanId, + observedAt, + List.of() + ); + } +} diff --git a/argus-frontend/src/main/resources/public/apm.html b/argus-frontend/src/main/resources/public/apm.html new file mode 100644 index 0000000..9259b66 --- /dev/null +++ b/argus-frontend/src/main/resources/public/apm.html @@ -0,0 +1,149 @@ + + + + + + Argus - APM Workflows + + + + + + + +

+ +
+ Dashboard + Fleet + APM + Profiles + Console + +
+ + Loading +
+
+
+ +
+
+
+ Services + 0 +
+
+
+ Degraded + 0 +
+
+
+ Incidents + 0 +
+
+
+ p95 Latency + 0ms +
+
+
+ GC p95 + 0ms +
+
+
+ Trace Links + 0 +
+
+ +
+ + + + +
+ + Grafana +
+
+ +
+ + +
+
+
+

Endpoint View

+ - +
+
+
+ +
+
+

Incident Timeline

+ last 60m +
+
+
+ +
+
+

Root Cause Cards

+ 0 findings +
+
+
+
+ + +
+
+ + + + diff --git a/argus-frontend/src/main/resources/public/console.html b/argus-frontend/src/main/resources/public/console.html index 59448a0..8b31159 100644 --- a/argus-frontend/src/main/resources/public/console.html +++ b/argus-frontend/src/main/resources/public/console.html @@ -21,6 +21,7 @@

Argus Console

Dashboard Fleet + APM Profiles