feat(scadabridge): emit scadabridge.inbound_api.requests (by method) at inbound API entry

This commit is contained in:
Joseph Doherty
2026-06-01 17:03:10 -04:00
parent 547b685a42
commit 782fb73015
2 changed files with 127 additions and 0 deletions
@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
@@ -44,6 +45,16 @@ public static class EndpointExtensions
if (!validationResult.IsValid)
{
// Telemetry follow-on: count every inbound request, including auth
// failures. The raw {methodName} route value is arbitrary caller input
// and would be high-cardinality, so failures are tagged with a small
// bounded set of sentinels keyed off the validator's status code rather
// than the unvalidated name (401 → "<unauthorized>", 403 → "<forbidden>").
ScadaBridgeTelemetry.RecordInboundApiRequest(
validationResult.StatusCode == StatusCodes.Status401Unauthorized
? "<unauthorized>"
: "<forbidden>");
// WP-5: Failures-only logging
logger.LogWarning(
"Inbound API auth failure for method {Method}: {Error} (status {StatusCode})",
@@ -56,6 +67,12 @@ public static class EndpointExtensions
var method = validationResult.Method!;
// Telemetry follow-on: count this inbound request against the resolved,
// registered method name. method.Name comes from the repository's method
// catalogue (an exact-name lookup), so the `method` tag is bounded to the
// set of configured API methods — never the raw caller-supplied route value.
ScadaBridgeTelemetry.RecordInboundApiRequest(method.Name);
// Audit Log (#23 M4 Bundle D): publish the resolved API key name so
// AuditWriteMiddleware can populate AuditEvent.Actor in its finally
// block. Done AFTER validation succeeded — auth failures leave the
@@ -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)