feat(scadabridge): emit scadabridge.inbound_api.requests (by method) at inbound API entry
This commit is contained in:
@@ -9,8 +9,10 @@ using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
|
||||
@@ -232,6 +234,114 @@ public class EndpointExtensionsTests
|
||||
Assert.Equal("audit-actor-name", capture.CapturedActor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidRequest_EmitsInboundApiRequestCounter_TaggedWithResolvedMethodName()
|
||||
{
|
||||
// Telemetry follow-on: a successful inbound request increments
|
||||
// scadabridge.inbound_api.requests once, tagged with the resolved,
|
||||
// registered method name (method.Name) — the bounded identifier, not the
|
||||
// raw route value.
|
||||
var key = SeedKey();
|
||||
var method = SeedMethod(1, "echo", "return Parameters[\"value\"];",
|
||||
"""[{"name":"value","type":"Integer","required":true}]""");
|
||||
|
||||
using var collector = new InboundApiRequestCounterCollector();
|
||||
|
||||
using var host = await BuildHostAsync(key, method);
|
||||
var client = host.GetTestClient();
|
||||
|
||||
var response = await client.SendAsync(BuildPost("echo", """{"value":7}"""));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
// Filter by the method tag this test produced: the counter is a process-wide
|
||||
// static, so a parallel test class could otherwise leak measurements in.
|
||||
var echoTotal = collector.Measurements
|
||||
.Where(m => m.Method == "echo")
|
||||
.Sum(m => m.Value);
|
||||
Assert.Equal(1, echoTotal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownMethod_EmitsInboundApiRequestCounter_WithBoundedForbiddenSentinel()
|
||||
{
|
||||
// Telemetry follow-on: an auth/authz failure is still counted, but the
|
||||
// tag is a bounded sentinel ("<forbidden>") rather than the arbitrary
|
||||
// caller-supplied route value — so an attacker posting random method
|
||||
// names cannot blow up the `method` tag cardinality.
|
||||
var key = SeedKey();
|
||||
var method = SeedMethod(1, "knownMethod", "return 1;");
|
||||
|
||||
using var collector = new InboundApiRequestCounterCollector();
|
||||
|
||||
using var host = await BuildHostAsync(key, method);
|
||||
var client = host.GetTestClient();
|
||||
|
||||
var response = await client.SendAsync(BuildPost("totally-made-up-name", "{}"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
var measurements = collector.Measurements;
|
||||
// Cardinality safety: the arbitrary route value is never used as a tag.
|
||||
Assert.DoesNotContain(measurements, m => m.Method == "totally-made-up-name");
|
||||
// The failure path counts the request against the bounded sentinel.
|
||||
Assert.Contains(measurements, m => m.Method == "<forbidden>" && m.Value == 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures <c>scadabridge.inbound_api.requests</c> measurements (value + the
|
||||
/// <c>method</c> tag) via a <see cref="MeterListener"/> for the duration of a test.
|
||||
/// </summary>
|
||||
private sealed class InboundApiRequestCounterCollector : IDisposable
|
||||
{
|
||||
private readonly MeterListener _listener;
|
||||
private readonly List<(long Value, string? Method)> _measurements = new();
|
||||
private readonly object _gate = new();
|
||||
|
||||
public InboundApiRequestCounterCollector()
|
||||
{
|
||||
_listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (instrument, listener) =>
|
||||
{
|
||||
if (instrument.Meter.Name == ScadaBridgeTelemetry.MeterName
|
||||
&& instrument.Name == "scadabridge.inbound_api.requests")
|
||||
{
|
||||
listener.EnableMeasurementEvents(instrument);
|
||||
}
|
||||
},
|
||||
};
|
||||
_listener.SetMeasurementEventCallback<long>((instrument, value, tags, state) =>
|
||||
{
|
||||
string? method = null;
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
if (tag.Key == "method")
|
||||
{
|
||||
method = tag.Value as string;
|
||||
}
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_measurements.Add((value, method));
|
||||
}
|
||||
});
|
||||
_listener.Start();
|
||||
}
|
||||
|
||||
public IReadOnlyList<(long Value, string? Method)> Measurements
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _measurements.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => _listener.Dispose();
|
||||
}
|
||||
|
||||
private static HttpRequestMessage BuildPost(string methodName, string body)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName)
|
||||
|
||||
Reference in New Issue
Block a user