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
};
}
}