From ca0be0461be33c7fe0fb5847da81a1f3fe239713 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20N=C3=A4geli?= Date: Wed, 14 Oct 2026 14:15:42 +0200 Subject: [PATCH] Do not count an icon answered with the page as a page view A single page app answers every path with its page, /favicon.ico and the Apple touch icons included, and a browser fetching a site's icons sends no Sec-Fetch-Dest - so the rule for programs (a GET answered with HTML) took them for pages opened. A request without the header for a file with a known ending of an image, a script, a style sheet, a font and the like is no page. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- .../Services/Visits/LambdaVisitConcern.cs | 58 ++++++++++++++----- .../Analytics/AnalyticsTests.cs | 29 ++++++++++ 3 files changed, 77 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index ab51bf4..d4f6b41 100644 --- a/README.md +++ b/README.md @@ -828,7 +828,9 @@ feature's preview. - **What a visit is counted as.** A *hit* is every request answered; a *view* is somebody opening a page - a browser's navigation (`Sec-Fetch-Dest: document`, a revalidation included) or, from a program that does not send - that header, a successful `GET` answered with HTML. Of the views, those + that header, a successful `GET` answered with HTML - unless it asked for + a file that is no page (`/favicon.ico`, an image, a script, a style + sheet, …), which a single page app answers with its page too. Of the views, those of programs - crawlers, and whatever names no browser - are counted apart as well (`bot_views`, `botViews` in the answer), everywhere a view is: the editor shows the pages people opened and adds the bots' behind a switch diff --git a/src/GenHTTP.Lambda/Services/Visits/LambdaVisitConcern.cs b/src/GenHTTP.Lambda/Services/Visits/LambdaVisitConcern.cs index 1d71a2e..e099c5e 100644 --- a/src/GenHTTP.Lambda/Services/Visits/LambdaVisitConcern.cs +++ b/src/GenHTTP.Lambda/Services/Visits/LambdaVisitConcern.cs @@ -42,7 +42,9 @@ public sealed class LambdaVisitConcern : IDependentConcern // read before the lambda routes it: the target is a pointer that the // handlers below move along, and afterwards it points at nothing - var path = PathOf(request); + var target = TargetOf(request); + + var path = target.Length <= 100 ? target : string.Concat(target.AsSpan(0, 99), "…"); var domain = request.GetHost()?.Name; @@ -89,22 +91,23 @@ public sealed class LambdaVisitConcern : IDependentConcern request.SetVisit(new LambdaVisit(lambda, DateTime.UtcNow, path, domain, status, Stopwatch.GetElapsedTime(started), (long)(response?.Content?.Length ?? 0), caller?.Client, caller?.Agent, caller?.Country, - referrer, counted && IsPage(method, status, destination, response), hints)); + referrer, counted && IsPage(method, target, status, destination, response), hints)); } } /// - /// What was asked for, relative to the lambda and cut to a length worth - /// showing - the lookup above has already stepped past the key, where - /// there was one. + /// What was asked for, relative to the lambda - the lookup above has + /// already stepped past the key, where there was one. /// - private static string PathOf(IRequest request) + /// + /// In full, so its ending is there to be read; the visit keeps it cut to + /// a length worth showing. + /// + private static string TargetOf(IRequest request) { var remaining = request.Header.Target.AsString(decode: false, remainingOnly: true); - var path = remaining.Length == 0 ? "/" : remaining[0] == '/' ? remaining : "/" + remaining; - - return path.Length <= 100 ? path : string.Concat(path.AsSpan(0, 99), "…"); + return remaining.Length == 0 ? "/" : remaining[0] == '/' ? remaining : "/" + remaining; } /// @@ -131,10 +134,13 @@ private static string PathOf(IRequest request) /// from its cache (Not Modified) is a page opened again, and a script or /// a call to an API is not one, whatever it answers with. Programs that /// do not send the header - crawlers, mostly - opened a page when what - /// they got back was HTML. A redirect is not a page: the page it sends to - /// is counted when it is opened. + /// they got back was HTML - unless they asked for a file that is no page + /// (): a single page app answers every path with its + /// page, /favicon.ico included, and a browser fetching its icons + /// sends no Sec-Fetch-Dest. A redirect is not a page: the page it + /// sends to is counted when it is opened. /// - private static bool IsPage(RequestMethod method, int status, string? destination, IResponse? response) + private static bool IsPage(RequestMethod method, string target, int status, string? destination, IResponse? response) { if (method != RequestMethod.Get || status is not (>= 200 and < 300 or 304)) { @@ -146,7 +152,33 @@ private static bool IsPage(RequestMethod method, int status, string? destination return string.Equals(destination, "document", StringComparison.OrdinalIgnoreCase); } - return status != 304 && response?.Content?.Type is { } type && type.Bytes.Span.StartsWith("text/html"u8); + return status != 304 && response?.Content?.Type is { } type && type.Bytes.Span.StartsWith("text/html"u8) && !NamesAFile(target); + } + + /// + /// The endings of files a page is made of or comes with, which nobody + /// opens as a page. + /// + /// + /// A known few rather than every ending: a path with a dot in it may well + /// be a page (/user/jane.doe, /release/1.2). + /// + private static readonly HashSet Files = new(StringComparer.OrdinalIgnoreCase) + { + "ico", "png", "jpg", "jpeg", "gif", "webp", "avif", "svg", "bmp", + "js", "mjs", "map", "css", "json", "webmanifest", "xml", "txt", + "woff", "woff2", "ttf", "otf", "eot", + "mp3", "mp4", "webm", "ogg", "wav", + "pdf", "zip", "wasm" + }; + + private static bool NamesAFile(string target) + { + var name = target.AsSpan(target.LastIndexOf('/') + 1); + + var dot = name.LastIndexOf('.'); + + return dot >= 0 && Files.GetAlternateLookup>().Contains(name[(dot + 1)..]); } } diff --git a/tests/GenHTTP.Lambda.Tests/Analytics/AnalyticsTests.cs b/tests/GenHTTP.Lambda.Tests/Analytics/AnalyticsTests.cs index b0fb7a0..ad253ea 100644 --- a/tests/GenHTTP.Lambda.Tests/Analytics/AnalyticsTests.cs +++ b/tests/GenHTTP.Lambda.Tests/Analytics/AnalyticsTests.cs @@ -218,6 +218,35 @@ public async Task OnlyOpeningAPageIsAView() Assert.IsEmpty(shown.Referrers!, "the lambda referring to itself is nobody coming from elsewhere"); } + [TestMethod] + public async Task AnIconAnsweredWithThePageIsNoPage() + { + await using var fixture = await LambdaFixture.CreateAsync(); + + var lambda = await OnlineAsync(fixture, "iconless"); + + await EnableAsync(fixture, lambda.PrivateKey); + + // a browser fetching the icons of a site sends no Sec-Fetch-Dest, and + // the website answers every path with its page + await VisitAsync(fixture, "http://iconless.localhost/favicon.ico", IPhone); + await VisitAsync(fixture, "http://iconless.localhost/apple-touch-icon-152x152-precomposed.png", IPhone); + + // while a dot alone does not make a file + await VisitAsync(fixture, "http://iconless.localhost/release/1.2", Googlebot); + + Flush(fixture); + + var shown = await ReadAsync(fixture, lambda.PrivateKey); + + Assert.AreEqual(3, shown.Totals!.Hits); + Assert.AreEqual(1, shown.Totals.Views); + Assert.AreEqual(0, shown.Totals.Visitors, "nobody opened a page"); + + Assert.AreEqual(0, Views(shown.Pages, "/favicon.ico")); + Assert.AreEqual(1, Views(shown.Pages, "/release/1.2")); + } + [TestMethod] public async Task AVisitorIsSomebodyWhoOpenedAPage() {