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
26 changes: 21 additions & 5 deletions Areas/Public/Controllers/TilesController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ public class TilesController : Controller
/// </summary>
private const int MaxZoomLevel = 22;

/// <summary>
/// Retry-After header value (in seconds) sent with HTTP 503 when the outbound budget is exhausted.
/// Set to 5s to align with <see cref="TileCacheService.OutboundBudget"/>: at 2 tokens/sec
/// (ReplenishIntervalMs=500) with BurstCapacity=10, a full burst refills in ~5 seconds.
/// </summary>
private const string BudgetRetryAfterSeconds = "5";

/// <summary>
/// Thread-safe dictionary for rate limiting anonymous tile requests by IP address.
/// Uses atomic operations via <see cref="RateLimitHelper"/> to prevent race conditions.
Expand Down Expand Up @@ -149,11 +156,20 @@ public async Task<IActionResult> GetTile(int z, int x, int y)
}

// Call the tile cache service to retrieve the tile.
// The service will either return the cached tile data or (if missing) download, cache, and then return it.
var tileData = await _tileCacheService.RetrieveTileAsync(z.ToString(), x.ToString(), y.ToString(), tileUrl, HttpContext.RequestAborted);
if (tileData == null)
// The service will either return the cached tile data, signal budget exhaustion (503),
// or indicate the tile was not found (404).
var result = await _tileCacheService.RetrieveTileAsync(z.ToString(), x.ToString(), y.ToString(), tileUrl, HttpContext.RequestAborted);

if (result.BudgetExhausted)
{
_logger.LogWarning("Tile budget exhausted for {Z}/{X}/{Y}", z, x, y);
Response.Headers["Retry-After"] = BudgetRetryAfterSeconds;
return StatusCode(503, "Tile server busy. Please retry shortly.");
}

if (result.TileData == null)
{
_logger.LogError("Tile data not found for {z}/{x}/{y}", z, x, y);
_logger.LogError("Tile data not found for {Z}/{X}/{Y}", z, x, y);
return NotFound("Tile not found.");
}

Expand All @@ -164,7 +180,7 @@ public async Task<IActionResult> GetTile(int z, int x, int y)
Response.Headers["X-Content-Type-Options"] = "nosniff";

// Return the tile data with the appropriate content type.
return File(tileData, "image/png");
return File(result.TileData, "image/png");
}

/// <summary>
Expand Down
23 changes: 22 additions & 1 deletion Areas/User/Views/HiddenAreas/Create.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,32 @@ ViewData["LoadLeaflet"] = true; // Make sure your _Layout includes leaflet and
const tilesAttribution = tilesConfig.attribution || '&copy; OpenStreetMap contributors';

// Add tile layer from the cache proxy.
L.tileLayer(tilesUrl, {
const tileLayer = L.tileLayer(tilesUrl, {
maxZoom: 19,
attribution: tilesAttribution
}).addTo(map);

// Retry failed tiles with backoff (max 5 attempts, matching retryTileLayer.js).
// Inline fallback for views that cannot use ES module imports.
// Note: unlike retryTileLayer.js (which uses fetch() to detect 503 vs 404),
// this img-based approach retries ALL errors indiscriminately. Acceptable for
// these admin-only views with low traffic — worst case is 5 wasted retries
// per genuinely missing tile.
(function () {
var retryCounts = {};
tileLayer.on('tileerror', function (e) {
var src = e.tile.src;
if (!src) return;
var key = src.split('?')[0];
retryCounts[key] = (retryCounts[key] || 0) + 1;
if (retryCounts[key] <= 5) {
setTimeout(function () {
e.tile.src = src;
}, 1000 * Math.pow(2, retryCounts[key] - 1));
}
});
})();

map.attributionControl.setPrefix('&copy; <a href="https://wayfarer.stefk.me" title="Powered by Wayfarer, made by Stef" target="_blank">Wayfarer</a> | <a href="https://stefk.me" title="Check my blog" target="_blank">Stef K</a> | &copy; <a href="https://leafletjs.com/" target="_blank">Leaflet</a>');

// Layer to hold drawn polygons
Expand Down
23 changes: 22 additions & 1 deletion Areas/User/Views/HiddenAreas/Edit.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,32 @@
const tilesUrl = tilesConfig.tilesUrl || `${window.location.origin}/Public/tiles/{z}/{x}/{y}.png`;
const tilesAttribution = tilesConfig.attribution || '&copy; OpenStreetMap contributors';

L.tileLayer(tilesUrl, {
const tileLayer = L.tileLayer(tilesUrl, {
maxZoom: 19,
attribution: tilesAttribution
}).addTo(map);

// Retry failed tiles with backoff (max 5 attempts, matching retryTileLayer.js).
// Inline fallback for views that cannot use ES module imports.
// Note: unlike retryTileLayer.js (which uses fetch() to detect 503 vs 404),
// this img-based approach retries ALL errors indiscriminately. Acceptable for
// these admin-only views with low traffic — worst case is 5 wasted retries
// per genuinely missing tile.
(function () {
var retryCounts = {};
tileLayer.on('tileerror', function (e) {
var src = e.tile.src;
if (!src) return;
var key = src.split('?')[0];
retryCounts[key] = (retryCounts[key] || 0) + 1;
if (retryCounts[key] <= 5) {
setTimeout(function () {
e.tile.src = src;
}, 1000 * Math.pow(2, retryCounts[key] - 1));
}
});
})();

map.attributionControl.setPrefix('&copy; <a href="https://wayfarer.stefk.me" title="Powered by Wayfarer, made by Stef" target="_blank">Wayfarer</a> | <a href="https://stefk.me" title="Check my blog" target="_blank">Stef K</a> | &copy; <a href="https://leafletjs.com/" target="_blank">Leaflet</a>');

const drawnItems = new L.FeatureGroup();
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# CHANGELOG

## [1.2.21] - 2026-03-26

### Added
- `RetryTileLayer` — custom Leaflet TileLayer subclass using `fetch()` for HTTP status code access; retries on 503 with exponential backoff and `Retry-After` header support (#206)
- `createTileLayer()` factory in `retryTileLayer.js` — centralizes tile layer creation, replacing duplicated boilerplate across 13 JS files (#206)
- `TileRetrievalResult` — typed result class distinguishing tile success, not-found, and budget-throttled states (#206)
- `RequestIdLoggingMiddleware` — pushes `HttpContext.TraceIdentifier` into Serilog `LogContext` so every log entry includes `RequestId` automatically (#206)
- Serilog `.Enrich.FromLogContext()` and `{Properties:j}` output templates for console and file sinks (#206)
- `DbMetadataZoomThreshold` constant replacing magic number `9` across `TileCacheService` (#206)
- Inline `tileerror` retry fallback for HiddenAreas Create/Edit views (cshtml inline scripts) (#206)

### Fixed
- **HIGH:** Cold-cache tile loading returned 404 for budget-exhausted tiles — Leaflet treated as permanent failure, showing persistent gray areas. Now returns 503 + `Retry-After` header; client retries automatically (#206)
- **MEDIUM:** Ghost metadata rows stored in DB when tile fetch was aborted by budget exhaustion — rows had `Size=0`, null `ETag`/`ExpiresAtUtc`, pointing to non-existent files (#206)

### Changed
- `CacheTileAsync` now returns `bool` (`false` = budget exhaustion) instead of `void` (#206)
- `RetrieveTileAsync` now returns `TileRetrievalResult` instead of `byte[]?` (#206)
- `PerformanceMonitoringMiddleware` log line now includes explicit `RequestId` parameter (#206)

## [1.2.20] - 2026-03-22

### Added
Expand Down
3 changes: 2 additions & 1 deletion Middleware/PerformanceMonitoringMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ public async Task InvokeAsync(HttpContext context)

stopwatch.Stop(); // Stop measuring time

// Log the duration of the request
// Log the duration of the request. RequestId is automatically included via
// {Properties:j} from LogContext (pushed by RequestIdLoggingMiddleware).
_logger.LogInformation("Request [{Method}] {Path} took {ElapsedMilliseconds} ms",
context.Request.Method,
context.Request.Path,
Expand Down
33 changes: 33 additions & 0 deletions Middleware/RequestIdLoggingMiddleware.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using Serilog.Context;

namespace Wayfarer.Middleware;

/// <summary>
/// Pushes HttpContext.TraceIdentifier into Serilog's LogContext so every log entry
/// within the request pipeline includes the RequestId property automatically.
/// </summary>
public class RequestIdLoggingMiddleware
{
private readonly RequestDelegate _next;

/// <summary>
/// Initializes a new instance of <see cref="RequestIdLoggingMiddleware"/>.
/// </summary>
public RequestIdLoggingMiddleware(RequestDelegate next)
{
_next = next;
}

/// <summary>
/// Pushes the request's TraceIdentifier as a "RequestId" property into Serilog's
/// LogContext, then invokes the next middleware. The property is automatically
/// removed when the request completes.
/// </summary>
public async Task InvokeAsync(HttpContext context)
{
using (LogContext.PushProperty("RequestId", context.TraceIdentifier))
{
await _next(context);
}
}
}
13 changes: 10 additions & 3 deletions Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -263,10 +263,16 @@ static void ConfigureLogging(WebApplicationBuilder builder)
throw new InvalidOperationException(
"Log file path is not configured. Please check your appsettings.json or appsettings.Development.json.");

// Configure Serilog for logging to console, file, and PostgreSQL
// Configure Serilog for logging to console, file, and PostgreSQL.
// .Enrich.FromLogContext() enables LogContext properties (e.g., RequestId pushed by
// RequestIdLoggingMiddleware) to flow into all sinks automatically.
// {Properties:j} in output templates renders pushed properties as JSON.
Log.Logger = new LoggerConfiguration()
.WriteTo.Console() // Logs to the console
.WriteTo.File(logFilePath, rollingInterval: RollingInterval.Day) // Logs to a file with daily rotation
.Enrich.FromLogContext()
.WriteTo.Console(outputTemplate:
"[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}")
.WriteTo.File(logFilePath, rollingInterval: RollingInterval.Day, outputTemplate:
"{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}")
.WriteTo.PostgreSQL(builder.Configuration.GetConnectionString("DefaultConnection"),
"AuditLogs", // Table for storing logs
needAutoCreateTable: true) // Auto-creates the table if it doesn't exist
Expand Down Expand Up @@ -694,6 +700,7 @@ static async Task ConfigureMiddleware(WebApplication app)
// CRITICAL: Add this as the FIRST middleware to process forwarded headers from nginx
app.UseForwardedHeaders();

app.UseMiddleware<RequestIdLoggingMiddleware>(); // Enriches Serilog LogContext with HttpContext.TraceIdentifier
app.UseMiddleware<PerformanceMonitoringMiddleware>(); // Custom middleware for monitoring performance

// Use specific middlewares based on the environment
Expand Down
Loading
Loading