Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions Services/Configuration/ApiSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
namespace Craft.Configuration;

/// <summary>
/// Policy for dynamic <c>/api</c> responses (the PowerShell / native dispatch pipeline), kept separate
/// from <see cref="FrontendSettings"/> so the API surface can be governed on its own terms rather than
/// riding on the static-frontend toggles.
/// </summary>
public class ApiSettings
{
/// <summary>
/// Whether the host compresses dynamic <c>/api</c> responses on the fly (Brotli preferred, gzip
/// fallback, negotiated from the caller's <c>Accept-Encoding</c>). Default true.
/// <para>
/// Independent of <see cref="FrontendSettings.Compression"/>, which governs <i>static</i> assets:
/// turning static compression off (for example because an upstream CDN already compresses the
/// static bundle) does NOT turn this off, and vice versa. That separation is the whole point — a
/// CDN in front of the origin typically does not re-compress API JSON, so the origin should keep
/// doing it even when static compression is delegated to the edge.
/// </para>
/// <para>
/// This is a server capability, not a mandate: a response is only compressed when the caller
/// advertises an accepted encoding; a client that sends none is served identity. Overridable via
/// the <c>CRAFT_API_COMPRESSION</c> environment variable (true/false), which wins over this setting.
/// </para>
/// </summary>
public bool Compression { get; set; } = true;

/// <summary>
/// On-the-fly compression level for the response compressors (Brotli and gzip): one of
/// <c>Fastest</c>, <c>Optimal</c>, <c>SmallestSize</c>, or <c>NoCompression</c> (a
/// <see cref="System.IO.Compression.CompressionLevel"/> name). Default <c>Optimal</c>.
/// <para>
/// Optimal is the default because it is a near-free win over Fastest: measured on a 2-vCPU container
/// (PerfJson payload), Brotli Optimal compressed ~8.4x versus Fastest's ~4.9x at the <i>same</i> CPU
/// (~14%) and latency, and gzip likewise improved at the same cost. <c>SmallestSize</c> is
/// deliberately NOT the default: Brotli's SmallestSize is quality 11, which on a small shared-core
/// container pegged both cores (~180% CPU) and drove p95 into the tens of seconds — never use it for
/// dynamic <c>/api</c>. Drop to <c>Fastest</c> (or <c>NoCompression</c>) on a very small SKU if the
/// compressor is seen competing with the PowerShell worker pool. Applies to on-the-fly compression
/// generally (dynamic <c>/api</c> and the static fallback for assets without a precompressed sibling;
/// precompressed <c>.br</c>/<c>.gz</c> siblings are built ahead of time and unaffected). Overridable
/// via <c>CRAFT_API_COMPRESSION_LEVEL</c>. An unrecognised value falls back to Fastest.
/// </para>
/// </summary>
public string CompressionLevel { get; set; } = "Optimal";
}
4 changes: 4 additions & 0 deletions Services/Configuration/CraftSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ public class CraftSettings
/// <summary>Frontend serving policy — CSP header injection (EasyAuth handles auth/redirects).</summary>
public FrontendSettings Frontend { get; set; } = new();

/// <summary>Dynamic <c>/api</c> response policy (compression), independent of the static frontend.
/// See <see cref="ApiSettings"/>.</summary>
public ApiSettings Api { get; set; } = new();

/// <summary>
/// Deployment roles (capabilities) — which parts of the host this process serves. One image, three
/// independent switches. See <see cref="RolesSettings"/>. Also settable via the CRAFT_SERVE_FRONTEND /
Expand Down
52 changes: 52 additions & 0 deletions Services/Configuration/EgressLimitSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,35 @@ public class EgressLimitSettings
/// </summary>
public int FlushSeconds { get; set; } = 60;

/// <summary>
/// Table into which per-flush accounting is mirrored as time-bucketed rows (in addition to the local
/// file), so the product can show usage history. Written by Craft's own table store — the same
/// storage account the hosted app reads with Get-CIPPTable — so CIPP can query it directly. Default
/// <c>CraftEgressAccounting</c>. Env override: <c>CRAFT_API_EGRESS_TABLE</c>. Blank disables the table
/// mirror (file-only).
/// </summary>
public string TableName { get; set; } = "CraftEgressAccounting";

/// <summary>
/// Width in minutes of each accounting bucket written to the table. Default 15 (→ 96 buckets/day),
/// which is the granularity the product surfaces. The local file keeps only running daily totals; the
/// bucketed time-series lives in the table. Floored at 1. Env override: <c>CRAFT_API_EGRESS_BUCKET_MINUTES</c>.
/// </summary>
public int BucketMinutes { get; set; } = 15;

/// <summary>
/// Days of bucketed rows to retain in the table before a periodic purge deletes them. Default 7.
/// Floored at 1. Env override: <c>CRAFT_API_EGRESS_RETENTION_DAYS</c>. Does not affect the local file,
/// which only ever holds the current UTC day.
/// </summary>
public int RetentionDays { get; set; } = 7;

internal const string EnabledEnv = "CRAFT_API_EGRESS_LIMIT_ENABLED";
internal const string BytesEnv = "CRAFT_API_EGRESS_LIMIT_BYTES";
internal const string FlushEnv = "CRAFT_API_EGRESS_FLUSH_SECONDS";
internal const string TableEnv = "CRAFT_API_EGRESS_TABLE";
internal const string BucketEnv = "CRAFT_API_EGRESS_BUCKET_MINUTES";
internal const string RetentionEnv = "CRAFT_API_EGRESS_RETENTION_DAYS";

/// <summary>
/// Whether egress accounting (and therefore the middleware) should be active, resolved through
Expand Down Expand Up @@ -94,6 +120,32 @@ public bool ResolveEnabled(Func<string, string?> env)
? fromEnv
: Math.Max(1, FlushSeconds);

/// <summary>Resolved table name, honouring <c>CRAFT_API_EGRESS_TABLE</c>. Blank/whitespace = the table
/// mirror is off (file-only accounting).</summary>
public string ResolvedTableName
{
get
{
var fromEnv = Environment.GetEnvironmentVariable(TableEnv);
var name = string.IsNullOrWhiteSpace(fromEnv) ? TableName : fromEnv;
return (name ?? string.Empty).Trim();
}
}

/// <summary>Resolved bucket width in minutes, honouring <c>CRAFT_API_EGRESS_BUCKET_MINUTES</c>. Floored at 1.</summary>
public int ResolvedBucketMinutes =>
int.TryParse(Environment.GetEnvironmentVariable(BucketEnv), NumberStyles.Integer,
CultureInfo.InvariantCulture, out var fromEnv) && fromEnv > 0
? fromEnv
: Math.Max(1, BucketMinutes);

/// <summary>Resolved retention in days, honouring <c>CRAFT_API_EGRESS_RETENTION_DAYS</c>. Floored at 1.</summary>
public int ResolvedRetentionDays =>
int.TryParse(Environment.GetEnvironmentVariable(RetentionEnv), NumberStyles.Integer,
CultureInfo.InvariantCulture, out var fromEnv) && fromEnv > 0
? fromEnv
: Math.Max(1, RetentionDays);

// Tri-state flag parse (mirrors Craft.Hosting.EnvFlag, inlined to keep Configuration free of a
// dependency on Hosting): null when unset/blank, true for "true"/"1" (any casing), false otherwise.
private static bool? ParseFlag(string? value)
Expand Down
43 changes: 24 additions & 19 deletions Services/Hosting/ApiEgressLimiterMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,24 @@
namespace Craft.Hosting;

/// <summary>
/// Sheds app-only API traffic once the instance has served its daily egress budget, and records the
/// outbound bytes of the responses it lets through. Runs only for API clients (client-credentials
/// callers); every interactive request passes straight through after a single header check.
/// Sheds app-only API traffic once the instance has served its daily egress budget, and marks the
/// requests it lets through for billing. Runs only for API clients (client-credentials callers); every
/// interactive request passes straight through after a single header check.
/// <para>
/// This is the <i>policy</i> half of the egress feature: it classifies the caller and decides whether
/// to reject. It no longer counts bytes itself — the outbound size is measured post-compression by
/// <see cref="ApiEgressWireCounterMiddleware"/>, which runs outside the response compressor. When this
/// middleware lets an API request through it sets <see cref="ApiEgressWireCounterMiddleware.ChargeItemKey"/>
/// on the request, and the wire counter records the response's on-the-wire bytes for exactly those
/// requests. Splitting it this way keeps the cap billing what actually transits the network (the
/// compressed body) while the shed decision stays here, after auth, where the caller is known.
/// </para>
/// <para>
/// Registered only when egress accounting is enabled (hosted env, or forced) — see
/// <c>CraftHostBuilderExtensions.AddCraftEgressLimiter</c>. Placement mirrors the rate limiter: after
/// the auth middleware (so an app-only caller's AppId is resolved) and after static file serving (so a
/// page load's assets are never charged). Enforcement (the 429) only bites once a budget is configured;
/// with no budget it counts silently, which is the accounting-only rollout phase.
/// with no budget it flags silently, which is the accounting-only rollout phase.
/// </para>
/// </summary>
public sealed class ApiEgressLimiterMiddleware
Expand Down Expand Up @@ -40,31 +49,27 @@ public async Task InvokeAsync(HttpContext context)
return;
}

// The AppId (GUID) is the per-client accounting key — the same principal name the concurrency
// limiter partitions on. Carried on the charge flag below and recorded on a shed.
var appId = context.Request.Headers["x-ms-client-principal-name"].ToString();

// Already over budget for today → shed before running the (often minute-long) downstream call,
// saving both the compute and the egress. Accounting is post-hoc, so the request that tips the
// total over still completes; the NEXT one is the first to be refused. Combined with the API
// concurrency cap, the overshoot is bounded to (concurrency × largest response).
if (_ledger.ShouldReject())
{
_ledger.RecordShed(appId);
await RejectAsync(context);
return;
}

// Count the body this request writes. Swapping Response.Body captures both a direct
// Body.WriteAsync and Response.WriteAsync(string), since the response writer is re-adapted onto
// our stream — see CountingStream.
var original = context.Response.Body;
var counting = new CountingStream(original);
context.Response.Body = counting;
try
{
await _next(context);
}
finally
{
context.Response.Body = original;
_ledger.Record(counting.BytesWritten);
}
// Greenlit: carry the AppId so ApiEgressWireCounterMiddleware (running outside the response
// compressor) bills its on-the-wire bytes against this client. We don't count here — a counter at
// this position would see the pre-compression body and miss the compressor's final flush, which
// unwinds further out. The shed body above is left unflagged, so it is never charged.
context.Items[ApiEgressWireCounterMiddleware.ChargeItemKey] = appId;
await _next(context);
}

private async Task RejectAsync(HttpContext context)
Expand Down
68 changes: 68 additions & 0 deletions Services/Hosting/ApiEgressWireCounterMiddleware.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
namespace Craft.Hosting;

/// <summary>
/// Measures the on-the-wire size of each <c>/api</c> response and, for the requests the egress limiter
/// greenlit, records it into the <see cref="EgressLedger"/>. It is the accounting half of the egress
/// feature and holds <b>no</b> compression logic of its own — it simply counts whatever bytes leave the
/// box, whether that is identity or a Brotli/gzip body produced by the generic <c>/api</c> response
/// compression that runs just inside it.
/// <para>
/// <b>Why a second middleware, and why here.</b> The egress cap is meant to bill the bytes that
/// actually transit the network, so the counter has to sit <i>outside</i> compression: response
/// compression only emits its trailing block when its middleware unwinds, so a counter placed inside it
/// would both count the pre-compression body and miss the final flush. This middleware is therefore
/// registered as the outermost link of the <c>/api</c> pipeline — ahead of
/// <c>UseResponseCompression</c> — so its <see cref="CountingStream"/> wraps the socket and sees the
/// compressed output, and its <c>finally</c> runs only after compression has fully flushed.
/// </para>
/// <para>
/// The decision of <i>which</i> requests to bill stays entirely in <see cref="ApiEgressLimiterMiddleware"/>,
/// which runs later (after auth, so it can classify the caller and shed when over budget). When it lets
/// an API request through it sets <see cref="ChargeItemKey"/> on the request; this middleware records
/// the wire bytes only when that flag is present, so UI traffic, anonymous requests, static assets and
/// shed 429s are never charged. Registered only when egress accounting is enabled — see
/// <c>CraftHostBuilderExtensions.AddCraftEgressLimiter</c> and the <c>/api</c> pipeline in <c>Program.cs</c>.
/// </para>
/// </summary>
public sealed class ApiEgressWireCounterMiddleware
{
/// <summary>
/// Request item set by <see cref="ApiEgressLimiterMiddleware"/> on an API request it lets through:
/// the caller's AppId (a non-empty string), signalling this middleware to bill the response's wire
/// bytes against that client. Absent for UI, anonymous, static and shed requests, which are never
/// charged.
/// </summary>
public const string ChargeItemKey = "Craft.Egress.Charge";

private readonly RequestDelegate _next;
private readonly EgressLedger _ledger;

public ApiEgressWireCounterMiddleware(RequestDelegate next, EgressLedger ledger)
{
_next = next ?? throw new ArgumentNullException(nameof(next));
_ledger = ledger ?? throw new ArgumentNullException(nameof(ledger));
}

public async Task InvokeAsync(HttpContext context)
{
ArgumentNullException.ThrowIfNull(context);

// Swapping Response.Body captures every write path (Body.WriteAsync, Response.WriteAsync, the
// BodyWriter pipe) — see CountingStream. Restored in the finally, whether the pipeline completed
// or threw. Only requests the limiter flagged are recorded; the flag is set downstream (inner)
// and read here after the whole pipeline — including compression's flush — has unwound.
var original = context.Response.Body;
var counting = new CountingStream(original);
context.Response.Body = counting;
try
{
await _next(context);
}
finally
{
context.Response.Body = original;
if (context.Items.TryGetValue(ChargeItemKey, out var charge) && charge is string appId && appId.Length > 0)
_ledger.Record(counting.BytesWritten, appId);
}
}
}
38 changes: 33 additions & 5 deletions Services/Hosting/CraftHostBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,38 @@ public static LogLevel AddCraftLogging(this WebApplicationBuilder builder)

private static readonly string[] second = new[] { "application/json", "text/json", "application/javascript", "text/javascript" };

/// <summary>
/// Resolves the on-the-fly compression level from the <c>CRAFT_API_COMPRESSION_LEVEL</c> env var
/// (which wins) or <c>App:Api:CompressionLevel</c>, parsed as a
/// <see cref="CompressionLevel"/> name (case-insensitive). An unset or unrecognised value is
/// <see cref="CompressionLevel.Fastest"/> — the safe default on a small, shared-core container.
/// </summary>
public static CompressionLevel ResolveCompressionLevel(CraftSettings settings, Func<string, string?> env)
{
ArgumentNullException.ThrowIfNull(settings);
ArgumentNullException.ThrowIfNull(env);

var raw = env("CRAFT_API_COMPRESSION_LEVEL");
if (string.IsNullOrWhiteSpace(raw)) raw = settings.Api.CompressionLevel;

return Enum.TryParse<CompressionLevel>(raw, ignoreCase: true, out var level)
? level
: CompressionLevel.Fastest;
}

/// <summary>Convenience overload resolving against the real process environment.</summary>
public static CompressionLevel ResolveCompressionLevel(CraftSettings settings) =>
ResolveCompressionLevel(settings, Environment.GetEnvironmentVariable);

/// <summary>Response compression, matching Azure Static Web Apps behaviour.</summary>
public static IServiceCollection AddCraftResponseCompression(this IServiceCollection services)
/// <param name="level">
/// Compression level applied to both the Brotli and gzip providers. Defaults to
/// <see cref="CompressionLevel.Fastest"/> — deliberate on a small container, where the request-path
/// CPU of Optimal/SmallestSize usually costs more than the bytes it saves. Resolve it from config
/// with <see cref="ResolveCompressionLevel(CraftSettings)"/>.
/// </param>
public static IServiceCollection AddCraftResponseCompression(
this IServiceCollection services, CompressionLevel level = CompressionLevel.Fastest)
{
ArgumentNullException.ThrowIfNull(services);

Expand All @@ -199,10 +229,8 @@ public static IServiceCollection AddCraftResponseCompression(this IServiceCollec
second);
});

// Fastest, not Optimal: these run on the request path on a small container, where the extra
// CPU costs more than the bytes saved. Precompressed .br/.gz siblings cover the static assets.
services.Configure<BrotliCompressionProviderOptions>(o => o.Level = CompressionLevel.Fastest);
services.Configure<GzipCompressionProviderOptions>(o => o.Level = CompressionLevel.Fastest);
services.Configure<BrotliCompressionProviderOptions>(o => o.Level = level);
services.Configure<GzipCompressionProviderOptions>(o => o.Level = level);

return services;
}
Expand Down
Loading
Loading