chore: retarget SignalR quota watcher to Standard S1 limits + CI build hygiene

- SignalR quota watcher/health card: defaults 20/20k (Free tier) -> 1000/1M
  (Standard S1 x1 unit); settings renamed SignalRFreeTier* -> SignalRConnectionLimit/
  SignalRDailyMessageLimit (no Azure overrides were set); all operator-facing
  texts tier-neutral (connections 429 hard, message overage is billed not throttled)
- web: drop obsolete edge runtime from opengraph/twitter/apple-icon image routes
  (next/og runs on Node since 14; images now statically generated at build)
- web: update caniuse-lite browser db (silences 6-months-old Browserslist warning)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-07-18 16:38:15 +02:00
parent ce1ca3dbf4
commit 2d5f87140f
10 changed files with 45 additions and 45 deletions
@@ -3,7 +3,7 @@ using AutopilotMonitor.Functions.Services;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Tier-boundary + percent-math tests for the SignalR free-tier quota watcher.
/// Tier-boundary + percent-math tests for the SignalR quota watcher.
/// Mirrors the cert-expiry tests' approach: pure-function coverage of the
/// boundary risks (off-by-one, floor vs ceil, zero-division). Full-service
/// smoke tests would require constructing 16 dependencies; the percent +
@@ -7,10 +7,11 @@ using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Admin
{
/// <summary>
/// Timer-triggered watcher for the Azure SignalR Free-tier limits (20
/// concurrent connections / 20,000 messages per UTC day). Runs every full
/// hour and emits OpsEvents when the configured thresholds are crossed so
/// operators get a Telegram ping before clients start getting 429'd.
/// Timer-triggered watcher for the Azure SignalR plan limits (Standard S1
/// x1 unit: 1,000 concurrent connections / 1M messages per UTC day). Runs
/// every full hour and emits OpsEvents when the configured thresholds are
/// crossed so operators get a Telegram ping before clients start getting
/// 429'd or message overage is billed.
///
/// Separate from the 2h MaintenanceFunction: the SignalR limits can be
/// breached by a brief connection spike, so the cadence is tighter. Cost
@@ -290,7 +290,7 @@ public class HealthCheckService
var check = new HealthCheck
{
Name = "SignalR Quota",
Description = "Free-tier connection + daily message usage"
Description = "Connection + daily message usage vs. plan limits"
};
var resourceId = _configuration["SignalRResourceId"];
@@ -302,9 +302,9 @@ public class HealthCheckService
}
var connectionLimit = ParsePositiveInt(
"SignalRFreeTierConnectionLimit", MaintenanceService.DefaultSignalRConnectionLimit);
"SignalRConnectionLimit", MaintenanceService.DefaultSignalRConnectionLimit);
var messageLimit = ParsePositiveLong(
"SignalRFreeTierDailyMessageLimit", MaintenanceService.DefaultSignalRDailyMessageLimit);
"SignalRDailyMessageLimit", MaintenanceService.DefaultSignalRDailyMessageLimit);
var nowUtc = DateTimeOffset.UtcNow;
var startOfDayUtc = new DateTimeOffset(nowUtc.UtcDateTime.Date, TimeSpan.Zero);
@@ -372,9 +372,9 @@ public class HealthCheckService
check.Message = worstTier switch
{
MaintenanceService.SignalRQuotaTier.Critical => "Free-tier limit nearly saturated — scale to Standard before clients get 429'd",
MaintenanceService.SignalRQuotaTier.Warning => "Approaching free-tier limit — consider scaling to Standard",
_ => "Within free-tier limits"
MaintenanceService.SignalRQuotaTier.Critical => "Plan limit nearly saturated — add SignalR units before clients get 429'd",
MaintenanceService.SignalRQuotaTier.Warning => "Approaching plan limit — consider adding SignalR units",
_ => "Within plan limits"
};
check.Details = details;
return check;
@@ -9,11 +9,13 @@ using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services
{
/// <summary>
/// SignalR Free-tier quota watcher. Reads ConnectionCount (max over the last
/// hour) and MessageCount (total since UTC midnight) from Azure Monitor and
/// emits tiered OpsEvents when either approaches the documented Free-tier
/// caps - 20 concurrent connections and 20,000 messages per day. The
/// operator can then scale to Standard before clients get 429'd.
/// SignalR quota watcher. Reads ConnectionCount (max over the last hour)
/// and MessageCount (total since UTC midnight) from Azure Monitor and
/// emits tiered OpsEvents when either approaches the plan caps - on
/// Standard S1 that is 1,000 concurrent connections per unit (hard limit,
/// excess connections are rejected) and 1M messages per unit per day
/// (included quota; overage is billed, not throttled). The operator can
/// then add units before clients get 429'd or overage costs accumulate.
///
/// Globally scoped (TenantId = null) - SignalR is one shared resource for
/// the whole Function App, not a per-tenant concern.
@@ -25,12 +27,12 @@ namespace AutopilotMonitor.Functions.Services
internal const int SignalRQuotaCriticalPercent = 95;
internal const int SignalRQuotaWarningPercent = 80;
// Default Free-tier caps as documented by Microsoft. Overridable via
// app settings so an operator who has scaled to a paid tier can re-target
// the watcher without a code change (or disable it by setting the cap
// very high).
internal const int DefaultSignalRConnectionLimit = 20;
internal const long DefaultSignalRDailyMessageLimit = 20_000;
// Default caps for Standard S1 with one unit, as documented by Microsoft.
// Overridable via app settings so an operator who adds units or changes
// tier can re-target the watcher without a code change (or disable it by
// setting the cap very high).
internal const int DefaultSignalRConnectionLimit = 1_000;
internal const long DefaultSignalRDailyMessageLimit = 1_000_000;
// Dedup prefix - all SignalR EventTypes start with this, so the seen-index
// can be built with a single Security-category read.
@@ -59,7 +61,7 @@ namespace AutopilotMonitor.Functions.Services
/// <summary>
/// Reads SignalR ConnectionCount + MessageCount metrics from Azure Monitor
/// and emits Warning/Error OpsEvents when the Free-tier limits are
/// and emits Warning/Error OpsEvents when the plan limits are
/// approached. Dedup: one event per EventType per UTC day. Fail-soft -
/// missing config or Azure Monitor errors yield a single log line, never
/// a thrown exception.
@@ -75,9 +77,9 @@ namespace AutopilotMonitor.Functions.Services
}
var connectionLimit = ParseIntSetting(
"SignalRFreeTierConnectionLimit", DefaultSignalRConnectionLimit);
"SignalRConnectionLimit", DefaultSignalRConnectionLimit);
var messageLimit = ParseLongSetting(
"SignalRFreeTierDailyMessageLimit", DefaultSignalRDailyMessageLimit);
"SignalRDailyMessageLimit", DefaultSignalRDailyMessageLimit);
var seenToday = await BuildSignalRQuotaSeenIndexAsync();
@@ -310,25 +310,25 @@ namespace AutopilotMonitor.Functions.Services
public Task RecordSignalRConnectionsHighAsync(int observed, int limit, int percent, string resourceId)
=> WriteAsync(OpsEventCategory.Security, "SignalRConnectionsHigh", OpsEventSeverity.Warning,
$"SignalR concurrent connections at {percent}% of free-tier limit ({observed}/{limit}) - watch for 429s; consider scaling to Standard before saturation",
$"SignalR concurrent connections at {percent}% of plan limit ({observed}/{limit}) - watch for 429s; consider adding units before saturation",
null, "System.Monitoring",
new { metric = "ConnectionCount", aggregation = "Maximum", windowMinutes = 60, observed, limit, percent, resourceId });
public Task RecordSignalRConnectionsCriticalAsync(int observed, int limit, int percent, string resourceId)
=> WriteAsync(OpsEventCategory.Security, "SignalRConnectionsCritical", OpsEventSeverity.Error,
$"CRITICAL: SignalR concurrent connections at {percent}% of free-tier limit ({observed}/{limit}) - new client connections will be 429'd at 100%; scale to Standard now",
$"CRITICAL: SignalR concurrent connections at {percent}% of plan limit ({observed}/{limit}) - new client connections will be 429'd at 100%; add units now",
null, "System.Monitoring",
new { metric = "ConnectionCount", aggregation = "Maximum", windowMinutes = 60, observed, limit, percent, resourceId });
public Task RecordSignalRMessagesHighAsync(long observed, long limit, int percent, string resourceId)
=> WriteAsync(OpsEventCategory.Security, "SignalRMessagesHigh", OpsEventSeverity.Warning,
$"SignalR daily message count at {percent}% of free-tier limit ({observed}/{limit}) - quota resets at 00:00 UTC; consider scaling if pattern persists",
$"SignalR daily message count at {percent}% of included plan quota ({observed}/{limit}) - resets at 00:00 UTC; overage is billed per extra million messages",
null, "System.Monitoring",
new { metric = "MessageCount", aggregation = "Total", windowDay = "UTC", observed, limit, percent, resourceId });
public Task RecordSignalRMessagesCriticalAsync(long observed, long limit, int percent, string resourceId)
=> WriteAsync(OpsEventCategory.Security, "SignalRMessagesCritical", OpsEventSeverity.Error,
$"CRITICAL: SignalR daily message count at {percent}% of free-tier limit ({observed}/{limit}) - hub will throttle at 100% until 00:00 UTC reset; scale to Standard now",
$"CRITICAL: SignalR daily message count at {percent}% of included plan quota ({observed}/{limit}) - overage beyond 100% is billed per extra million messages; review traffic or add units",
null, "System.Monitoring",
new { metric = "MessageCount", aggregation = "Total", windowDay = "UTC", observed, limit, percent, resourceId });
@@ -6,9 +6,9 @@
"AzureTableStorageConnectionString": "UseDevelopmentStorage=true",
"AzureBlobStorageConnectionString": "UseDevelopmentStorage=true",
"// SignalR free-tier quota watcher": "Optional. Set to the full Azure resource id of the SignalR Service to enable hourly OpsEvents when the 20-connections / 20k-messages-per-day free-tier limits are approached. Function App's managed identity needs 'Monitoring Reader' on this resource. Leave unset to skip the watcher.",
"// SignalR quota watcher": "Optional. Set to the full Azure resource id of the SignalR Service to enable hourly OpsEvents when the plan limits are approached (defaults: Standard S1 x1 unit = 1000 connections / 1M messages per day; override the limits when adding units or changing tier). Function App's managed identity needs 'Monitoring Reader' on this resource. Leave unset to skip the watcher.",
"SignalRResourceId": "",
"SignalRFreeTierConnectionLimit": "20",
"SignalRFreeTierDailyMessageLimit": "20000"
"SignalRConnectionLimit": "1000",
"SignalRDailyMessageLimit": "1000000"
}
}
@@ -1,7 +1,5 @@
import { ImageResponse } from "next/og";
export const runtime = "edge";
export const size = { width: 180, height: 180 };
export const contentType = "image/png";
@@ -1,7 +1,5 @@
import { ImageResponse } from "next/og";
export const runtime = "edge";
export const alt = "AutopilotMonitor – Real-Time Windows Enrollment Monitoring";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
@@ -1,3 +1 @@
export { default, alt, size, contentType } from "./opengraph-image";
export const runtime = "edge";
+10 -7
View File
@@ -2851,13 +2851,16 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
"integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
"version": "2.10.43",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz",
"integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
"baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/binary-extensions": {
@@ -3002,9 +3005,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001769",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz",
"integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==",
"version": "1.0.30001806",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
"integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
"funding": [
{
"type": "opencollective",