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()
{