feat(telemetry): add telemetry client and services for tracking events

- Implemented TelemetryClient to handle event queuing and flushing to the telemetry endpoint.
- Created TtfsTelemetryService for emitting specific telemetry events related to TTFS.
- Added tests for TelemetryClient to ensure event queuing and flushing functionality.
- Introduced models for reachability drift detection, including DriftResult and DriftedSink.
- Developed DriftApiService for interacting with the drift detection API.
- Updated FirstSignalCardComponent to emit telemetry events on signal appearance.
- Enhanced localization support for first signal component with i18n strings.
This commit is contained in:
master
2025-12-18 16:19:16 +02:00
parent 00d2c99af9
commit 811f35cba7
114 changed files with 13702 additions and 268 deletions

View File

@@ -0,0 +1,445 @@
// -----------------------------------------------------------------------------
// PathExplanationServiceTests.cs
// Sprint: SPRINT_3620_0002_0001_path_explanation
// Description: Unit tests for PathExplanationService.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.Reachability.Explanation;
using StellaOps.Scanner.Reachability.Gates;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests;
public class PathExplanationServiceTests
{
private readonly PathExplanationService _service;
private readonly PathRenderer _renderer;
public PathExplanationServiceTests()
{
_service = new PathExplanationService(
NullLogger<PathExplanationService>.Instance);
_renderer = new PathRenderer();
}
[Fact]
public async Task ExplainAsync_WithSimplePath_ReturnsExplainedPath()
{
// Arrange
var graph = CreateSimpleGraph();
var query = new PathExplanationQuery();
// Act
var result = await _service.ExplainAsync(graph, query);
// Assert
Assert.NotNull(result);
Assert.True(result.TotalCount >= 0);
}
[Fact]
public async Task ExplainAsync_WithSinkFilter_FiltersResults()
{
// Arrange
var graph = CreateGraphWithMultipleSinks();
var query = new PathExplanationQuery { SinkId = "sink-1" };
// Act
var result = await _service.ExplainAsync(graph, query);
// Assert
Assert.NotNull(result);
foreach (var path in result.Paths)
{
Assert.Equal("sink-1", path.SinkId);
}
}
[Fact]
public async Task ExplainAsync_WithGatesFilter_FiltersPathsWithGates()
{
// Arrange
var graph = CreateGraphWithGates();
var query = new PathExplanationQuery { HasGates = true };
// Act
var result = await _service.ExplainAsync(graph, query);
// Assert
Assert.NotNull(result);
foreach (var path in result.Paths)
{
Assert.True(path.Gates.Count > 0);
}
}
[Fact]
public async Task ExplainAsync_WithMaxPathLength_LimitsPathLength()
{
// Arrange
var graph = CreateDeepGraph(10);
var query = new PathExplanationQuery { MaxPathLength = 5 };
// Act
var result = await _service.ExplainAsync(graph, query);
// Assert
Assert.NotNull(result);
foreach (var path in result.Paths)
{
Assert.True(path.PathLength <= 5);
}
}
[Fact]
public async Task ExplainAsync_WithMaxPaths_LimitsResults()
{
// Arrange
var graph = CreateGraphWithMultiplePaths(20);
var query = new PathExplanationQuery { MaxPaths = 5 };
// Act
var result = await _service.ExplainAsync(graph, query);
// Assert
Assert.NotNull(result);
Assert.True(result.Paths.Count <= 5);
if (result.TotalCount > 5)
{
Assert.True(result.HasMore);
}
}
[Fact]
public void Renderer_Text_ProducesExpectedFormat()
{
// Arrange
var path = CreateTestPath();
// Act
var text = _renderer.Render(path, PathOutputFormat.Text);
// Assert
Assert.Contains(path.EntrypointSymbol, text);
Assert.Contains("SINK:", text);
}
[Fact]
public void Renderer_Markdown_ProducesExpectedFormat()
{
// Arrange
var path = CreateTestPath();
// Act
var markdown = _renderer.Render(path, PathOutputFormat.Markdown);
// Assert
Assert.Contains("###", markdown);
Assert.Contains("```", markdown);
Assert.Contains(path.EntrypointSymbol, markdown);
}
[Fact]
public void Renderer_Json_ProducesValidJson()
{
// Arrange
var path = CreateTestPath();
// Act
var json = _renderer.Render(path, PathOutputFormat.Json);
// Assert
Assert.StartsWith("{", json.Trim());
Assert.EndsWith("}", json.Trim());
Assert.Contains("sink_id", json);
Assert.Contains("entrypoint_id", json);
}
[Fact]
public void Renderer_WithGates_IncludesGateInfo()
{
// Arrange
var path = CreateTestPathWithGates();
// Act
var text = _renderer.Render(path, PathOutputFormat.Text);
// Assert
Assert.Contains("Gates:", text);
Assert.Contains("multiplier", text.ToLowerInvariant());
}
[Fact]
public async Task ExplainPathAsync_WithValidId_ReturnsPath()
{
// Arrange
var graph = CreateSimpleGraph();
// This test verifies the API works, actual path lookup depends on graph structure
// Act
var result = await _service.ExplainPathAsync(graph, "entry-1:sink-1:0");
// The result may be null if path doesn't exist, that's OK
Assert.True(result is null || result.PathId is not null);
}
[Fact]
public void GateMultiplier_Calculation_IsCorrect()
{
// Arrange - path with auth gate
var pathWithAuth = CreateTestPathWithGates();
// Assert - auth gate should reduce multiplier
Assert.True(pathWithAuth.GateMultiplierBps < 10000);
}
[Fact]
public void PathWithoutGates_HasFullMultiplier()
{
// Arrange
var path = CreateTestPath();
// Assert - no gates = 100% multiplier
Assert.Equal(10000, path.GateMultiplierBps);
}
private static RichGraph CreateSimpleGraph()
{
return new RichGraph
{
Schema = "stellaops.richgraph.v1",
Meta = new RichGraphMeta { Hash = "test-hash" },
Roots = new[]
{
new RichGraphRoot("entry-1", "runtime", null)
},
Nodes = new[]
{
new RichGraphNode(
Id: "entry-1",
SymbolId: "Handler.handle",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "http_handler",
Display: "GET /users",
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null),
new RichGraphNode(
Id: "sink-1",
SymbolId: "DB.query",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "sql_sink",
Display: "executeQuery",
BuildId: null,
Evidence: null,
Attributes: new Dictionary<string, string> { ["is_sink"] = "true" },
SymbolDigest: null)
},
Edges = new[]
{
new RichGraphEdge("entry-1", "sink-1", "call", null)
}
};
}
private static RichGraph CreateGraphWithMultipleSinks()
{
return new RichGraph
{
Schema = "stellaops.richgraph.v1",
Meta = new RichGraphMeta { Hash = "test-hash" },
Roots = new[] { new RichGraphRoot("entry-1", "runtime", null) },
Nodes = new[]
{
new RichGraphNode("entry-1", "Handler", null, null, "java", "handler", null, null, null, null, null),
new RichGraphNode("sink-1", "Sink1", null, null, "java", "sink", null, null, null,
new Dictionary<string, string> { ["is_sink"] = "true" }, null),
new RichGraphNode("sink-2", "Sink2", null, null, "java", "sink", null, null, null,
new Dictionary<string, string> { ["is_sink"] = "true" }, null)
},
Edges = new[]
{
new RichGraphEdge("entry-1", "sink-1", "call", null),
new RichGraphEdge("entry-1", "sink-2", "call", null)
}
};
}
private static RichGraph CreateGraphWithGates()
{
var gates = new[]
{
new DetectedGate
{
Type = GateType.AuthRequired,
Detail = "@Authenticated",
GuardSymbol = "AuthFilter",
Confidence = 0.9,
DetectionMethod = "annotation"
}
};
return new RichGraph
{
Schema = "stellaops.richgraph.v1",
Meta = new RichGraphMeta { Hash = "test-hash" },
Roots = new[] { new RichGraphRoot("entry-1", "runtime", null) },
Nodes = new[]
{
new RichGraphNode("entry-1", "Handler", null, null, "java", "handler", null, null, null, null, null),
new RichGraphNode("sink-1", "Sink", null, null, "java", "sink", null, null, null,
new Dictionary<string, string> { ["is_sink"] = "true" }, null)
},
Edges = new[]
{
new RichGraphEdge("entry-1", "sink-1", "call", gates)
}
};
}
private static RichGraph CreateDeepGraph(int depth)
{
var nodes = new List<RichGraphNode>();
var edges = new List<RichGraphEdge>();
for (var i = 0; i < depth; i++)
{
var attrs = i == depth - 1
? new Dictionary<string, string> { ["is_sink"] = "true" }
: null;
nodes.Add(new RichGraphNode($"node-{i}", $"Method{i}", null, null, "java", i == depth - 1 ? "sink" : "method", null, null, null, attrs, null));
if (i > 0)
{
edges.Add(new RichGraphEdge($"node-{i - 1}", $"node-{i}", "call", null));
}
}
return new RichGraph
{
Schema = "stellaops.richgraph.v1",
Meta = new RichGraphMeta { Hash = "test-hash" },
Roots = new[] { new RichGraphRoot("node-0", "runtime", null) },
Nodes = nodes,
Edges = edges
};
}
private static RichGraph CreateGraphWithMultiplePaths(int pathCount)
{
var nodes = new List<RichGraphNode>
{
new("entry-1", "Handler", null, null, "java", "handler", null, null, null, null, null)
};
var edges = new List<RichGraphEdge>();
for (var i = 0; i < pathCount; i++)
{
nodes.Add(new RichGraphNode($"sink-{i}", $"Sink{i}", null, null, "java", "sink", null, null, null,
new Dictionary<string, string> { ["is_sink"] = "true" }, null));
edges.Add(new RichGraphEdge("entry-1", $"sink-{i}", "call", null));
}
return new RichGraph
{
Schema = "stellaops.richgraph.v1",
Meta = new RichGraphMeta { Hash = "test-hash" },
Roots = new[] { new RichGraphRoot("entry-1", "runtime", null) },
Nodes = nodes,
Edges = edges
};
}
private static ExplainedPath CreateTestPath()
{
return new ExplainedPath
{
PathId = "entry:sink:0",
SinkId = "sink-1",
SinkSymbol = "DB.query",
SinkCategory = SinkCategory.SqlRaw,
EntrypointId = "entry-1",
EntrypointSymbol = "Handler.handle",
EntrypointType = EntrypointType.HttpEndpoint,
PathLength = 2,
Hops = new[]
{
new ExplainedPathHop
{
NodeId = "entry-1",
Symbol = "Handler.handle",
Package = "app",
Depth = 0,
IsEntrypoint = true,
IsSink = false
},
new ExplainedPathHop
{
NodeId = "sink-1",
Symbol = "DB.query",
Package = "database",
Depth = 1,
IsEntrypoint = false,
IsSink = true
}
},
Gates = Array.Empty<DetectedGate>(),
GateMultiplierBps = 10000
};
}
private static ExplainedPath CreateTestPathWithGates()
{
return new ExplainedPath
{
PathId = "entry:sink:0",
SinkId = "sink-1",
SinkSymbol = "DB.query",
SinkCategory = SinkCategory.SqlRaw,
EntrypointId = "entry-1",
EntrypointSymbol = "Handler.handle",
EntrypointType = EntrypointType.HttpEndpoint,
PathLength = 2,
Hops = new[]
{
new ExplainedPathHop
{
NodeId = "entry-1",
Symbol = "Handler.handle",
Package = "app",
Depth = 0,
IsEntrypoint = true,
IsSink = false
},
new ExplainedPathHop
{
NodeId = "sink-1",
Symbol = "DB.query",
Package = "database",
Depth = 1,
IsEntrypoint = false,
IsSink = true
}
},
Gates = new[]
{
new DetectedGate
{
Type = GateType.AuthRequired,
Detail = "@Authenticated",
GuardSymbol = "AuthFilter",
Confidence = 0.9,
DetectionMethod = "annotation"
}
},
GateMultiplierBps = 3000
};
}
}

View File

@@ -0,0 +1,412 @@
// -----------------------------------------------------------------------------
// RichGraphBoundaryExtractorTests.cs
// Sprint: SPRINT_3800_0002_0001_boundary_richgraph
// Description: Unit tests for RichGraphBoundaryExtractor.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.Reachability.Boundary;
using StellaOps.Scanner.Reachability.Gates;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests;
public class RichGraphBoundaryExtractorTests
{
private readonly RichGraphBoundaryExtractor _extractor;
public RichGraphBoundaryExtractorTests()
{
_extractor = new RichGraphBoundaryExtractor(
NullLogger<RichGraphBoundaryExtractor>.Instance);
}
[Fact]
public void Extract_HttpRoot_ReturnsBoundaryWithApiSurface()
{
var root = new RichGraphRoot("root-http", "runtime", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "com.example.Controller.handle",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "http_handler",
Display: "POST /api/users",
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
var result = _extractor.Extract(root, rootNode, BoundaryExtractionContext.Empty);
Assert.NotNull(result);
Assert.Equal("network", result.Kind);
Assert.NotNull(result.Surface);
Assert.Equal("api", result.Surface.Type);
Assert.Equal("https", result.Surface.Protocol);
}
[Fact]
public void Extract_GrpcRoot_ReturnsBoundaryWithGrpcProtocol()
{
var root = new RichGraphRoot("root-grpc", "runtime", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "com.example.UserService.getUser",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "grpc_method",
Display: "UserService.GetUser",
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
var result = _extractor.Extract(root, rootNode, BoundaryExtractionContext.Empty);
Assert.NotNull(result);
Assert.NotNull(result.Surface);
Assert.Equal("grpc", result.Surface.Protocol);
}
[Fact]
public void Extract_CliRoot_ReturnsProcessBoundary()
{
var root = new RichGraphRoot("root-cli", "runtime", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "Main",
CodeId: null,
Purl: null,
Lang: "csharp",
Kind: "cli_command",
Display: "stella scan",
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
var result = _extractor.Extract(root, rootNode, BoundaryExtractionContext.Empty);
Assert.NotNull(result);
Assert.Equal("process", result.Kind);
Assert.NotNull(result.Surface);
Assert.Equal("cli", result.Surface.Type);
}
[Fact]
public void Extract_LibraryPhase_ReturnsLibraryBoundary()
{
var root = new RichGraphRoot("root-lib", "library", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "Utils.parseJson",
CodeId: null,
Purl: null,
Lang: "javascript",
Kind: "function",
Display: "parseJson",
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
var result = _extractor.Extract(root, rootNode, BoundaryExtractionContext.Empty);
Assert.NotNull(result);
Assert.Equal("library", result.Kind);
Assert.NotNull(result.Surface);
Assert.Equal("library", result.Surface.Type);
}
[Fact]
public void Extract_WithAuthGate_SetsAuthRequired()
{
var root = new RichGraphRoot("root-auth", "runtime", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "Controller.handle",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "http_handler",
Display: null,
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
var context = BoundaryExtractionContext.FromGates(new[]
{
new DetectedGate
{
Type = GateType.AuthRequired,
Detail = "JWT token required",
GuardSymbol = "AuthFilter.doFilter",
Confidence = 0.9,
DetectionMethod = "pattern_match"
}
});
var result = _extractor.Extract(root, rootNode, context);
Assert.NotNull(result);
Assert.NotNull(result.Auth);
Assert.True(result.Auth.Required);
Assert.Equal("jwt", result.Auth.Type);
}
[Fact]
public void Extract_WithAdminGate_SetsAdminRole()
{
var root = new RichGraphRoot("root-admin", "runtime", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "AdminController.handle",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "http_handler",
Display: null,
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
var context = BoundaryExtractionContext.FromGates(new[]
{
new DetectedGate
{
Type = GateType.AdminOnly,
Detail = "Requires admin role",
GuardSymbol = "RoleFilter.check",
Confidence = 0.85,
DetectionMethod = "annotation"
}
});
var result = _extractor.Extract(root, rootNode, context);
Assert.NotNull(result);
Assert.NotNull(result.Auth);
Assert.True(result.Auth.Required);
Assert.NotNull(result.Auth.Roles);
Assert.Contains("admin", result.Auth.Roles);
}
[Fact]
public void Extract_WithFeatureFlagGate_AddsControl()
{
var root = new RichGraphRoot("root-ff", "runtime", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "BetaFeature.handle",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "http_handler",
Display: null,
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
var context = BoundaryExtractionContext.FromGates(new[]
{
new DetectedGate
{
Type = GateType.FeatureFlag,
Detail = "beta_users_only",
GuardSymbol = "FeatureFlags.isEnabled",
Confidence = 0.95,
DetectionMethod = "call_analysis"
}
});
var result = _extractor.Extract(root, rootNode, context);
Assert.NotNull(result);
Assert.NotNull(result.Controls);
Assert.Single(result.Controls);
Assert.Equal("feature_flag", result.Controls[0].Type);
Assert.True(result.Controls[0].Active);
}
[Fact]
public void Extract_WithInternetFacingContext_SetsExposure()
{
var root = new RichGraphRoot("root-public", "runtime", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "PublicApi.handle",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "http_handler",
Display: null,
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
var context = BoundaryExtractionContext.ForEnvironment(
"production",
isInternetFacing: true,
networkZone: "dmz");
var result = _extractor.Extract(root, rootNode, context);
Assert.NotNull(result);
Assert.NotNull(result.Exposure);
Assert.True(result.Exposure.InternetFacing);
Assert.Equal("dmz", result.Exposure.Zone);
Assert.Equal("public", result.Exposure.Level);
}
[Fact]
public void Extract_InternalService_SetsInternalExposure()
{
var root = new RichGraphRoot("root-internal", "runtime", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "InternalService.process",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "internal_handler",
Display: null,
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
var result = _extractor.Extract(root, rootNode, BoundaryExtractionContext.Empty);
Assert.NotNull(result);
Assert.NotNull(result.Exposure);
Assert.False(result.Exposure.InternetFacing);
Assert.Equal("internal", result.Exposure.Level);
}
[Fact]
public void Extract_SetsConfidenceBasedOnContext()
{
var root = new RichGraphRoot("root-1", "runtime", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "Api.handle",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "http_handler",
Display: null,
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
// Empty context should have lower confidence
var emptyResult = _extractor.Extract(root, rootNode, BoundaryExtractionContext.Empty);
// Rich context should have higher confidence
var richContext = new BoundaryExtractionContext
{
IsInternetFacing = true,
NetworkZone = "dmz",
DetectedGates = new[]
{
new DetectedGate
{
Type = GateType.AuthRequired,
Detail = "auth",
GuardSymbol = "auth",
Confidence = 0.9,
DetectionMethod = "test"
}
}
};
var richResult = _extractor.Extract(root, rootNode, richContext);
Assert.NotNull(emptyResult);
Assert.NotNull(richResult);
Assert.True(richResult.Confidence > emptyResult.Confidence);
}
[Fact]
public void Extract_IsDeterministic()
{
var root = new RichGraphRoot("root-det", "runtime", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "Api.handle",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "http_handler",
Display: "GET /api/test",
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
var context = BoundaryExtractionContext.FromGates(new[]
{
new DetectedGate
{
Type = GateType.AuthRequired,
Detail = "JWT",
GuardSymbol = "Auth",
Confidence = 0.9,
DetectionMethod = "test"
}
});
var result1 = _extractor.Extract(root, rootNode, context);
var result2 = _extractor.Extract(root, rootNode, context);
Assert.NotNull(result1);
Assert.NotNull(result2);
Assert.Equal(result1.Kind, result2.Kind);
Assert.Equal(result1.Surface?.Type, result2.Surface?.Type);
Assert.Equal(result1.Auth?.Required, result2.Auth?.Required);
Assert.Equal(result1.Confidence, result2.Confidence);
}
[Fact]
public void CanHandle_AlwaysReturnsTrue()
{
Assert.True(_extractor.CanHandle(BoundaryExtractionContext.Empty));
Assert.True(_extractor.CanHandle(BoundaryExtractionContext.ForEnvironment("test")));
}
[Fact]
public void Priority_ReturnsBaseValue()
{
Assert.Equal(100, _extractor.Priority);
}
[Fact]
public async Task ExtractAsync_ReturnsResult()
{
var root = new RichGraphRoot("root-async", "runtime", null);
var rootNode = new RichGraphNode(
Id: "node-1",
SymbolId: "Api.handle",
CodeId: null,
Purl: null,
Lang: "java",
Kind: "http_handler",
Display: null,
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: null);
var result = await _extractor.ExtractAsync(root, rootNode, BoundaryExtractionContext.Empty);
Assert.NotNull(result);
Assert.Equal("network", result.Kind);
}
}