Record response status per path; mark 404 trails red in the viewer
Document GETs now stash their status (200/404) in a pending table, consumed by the matching ping: visits gain a per-path statuses map and crawler hits a status field. Trail links with a 404 status render in red with the status code in the tooltip, alongside the read time.
This commit is contained in:
+9
-1
@@ -147,6 +147,8 @@ Each `Visit` record:
|
|||||||
does not append.
|
does not append.
|
||||||
- `utm` — `utm_*` query parameters from the landing URL, as a dict.
|
- `utm` — `utm_*` query parameters from the landing URL, as a dict.
|
||||||
- `read` — active reading time per path (seconds), keyed by path.
|
- `read` — active reading time per path (seconds), keyed by path.
|
||||||
|
- `statuses` — HTTP status of the response when each path was first seen
|
||||||
|
(200 or 404), keyed by path.
|
||||||
|
|
||||||
Each `CrawlerHit` record:
|
Each `CrawlerHit` record:
|
||||||
|
|
||||||
@@ -154,7 +156,9 @@ Each `CrawlerHit` record:
|
|||||||
- `entry` — page path requested,
|
- `entry` — page path requested,
|
||||||
- `client` — 6-byte blake3 hash referencing `Analytics.clients`,
|
- `client` — 6-byte blake3 hash referencing `Analytics.clients`,
|
||||||
- `referer` — external https origin of the request, `""` for direct/none,
|
- `referer` — external https origin of the request, `""` for direct/none,
|
||||||
- `query` — raw query string of the request.
|
- `query` — raw query string of the request,
|
||||||
|
- `status` — HTTP status of the served response (200 for a real page, 404
|
||||||
|
for a category placeholder or missing page).
|
||||||
|
|
||||||
Each `AbuseHit` record:
|
Each `AbuseHit` record:
|
||||||
|
|
||||||
@@ -173,6 +177,10 @@ that triggered classification are lifted to the top, followed by other 404s
|
|||||||
and then document GETs from the abuser. Within each category paths are
|
and then document GETs from the abuser. Within each category paths are
|
||||||
sorted by count descending, then by their earliest hit.
|
sorted by count descending, then by their earliest hit.
|
||||||
|
|
||||||
|
In the visitor and crawler tables, internal paths that returned a 404 status
|
||||||
|
are shown in red and the link title includes the status code, so it is easy
|
||||||
|
to tell misses from real pages at a glance.
|
||||||
|
|
||||||
## Aggregates
|
## Aggregates
|
||||||
|
|
||||||
- `transitions`: time series of page transitions, sparse nested dict
|
- `transitions`: time series of page transitions, sparse nested dict
|
||||||
|
|||||||
@@ -425,6 +425,11 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
|
|||||||
margin-left: 0.5rem;
|
margin-left: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.analytics-view :deep(.trail-link.error),
|
||||||
|
.analytics-view :deep(.trail-link.error:hover) {
|
||||||
|
color: var(--error, #c00);
|
||||||
|
}
|
||||||
|
|
||||||
.visit-table .utm-tag {
|
.visit-table .utm-tag {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|||||||
@@ -1,18 +1,33 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { formatCount } from './analytics/format.js'
|
import { computed } from 'vue'
|
||||||
|
import { formatCount, formatReadTime } from './analytics/format.js'
|
||||||
|
|
||||||
defineProps({
|
const props = defineProps({
|
||||||
step: { type: Object, required: true },
|
step: { type: Object, required: true },
|
||||||
count: { type: Number, default: 0 },
|
count: { type: Number, default: 0 },
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits(['close'])
|
defineEmits(['close'])
|
||||||
|
|
||||||
|
const hasError = computed(() => props.step.status >= 400)
|
||||||
|
|
||||||
|
const title = computed(() => {
|
||||||
|
const parts = [props.step.title]
|
||||||
|
if (props.step.readSeconds > 0) {
|
||||||
|
parts.push(formatReadTime(props.step.readSeconds))
|
||||||
|
}
|
||||||
|
if (hasError.value) {
|
||||||
|
parts.push(`${props.step.status}`)
|
||||||
|
}
|
||||||
|
return parts.filter(Boolean).join(' — ')
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<a class="trail-link"
|
<a class="trail-link"
|
||||||
|
:class="{ error: hasError }"
|
||||||
:href="step.path"
|
:href="step.path"
|
||||||
:title="count > 1 ? `${step.title} (${count} hits)` : step.title"
|
:title="title"
|
||||||
:target="step.external ? '_blank' : undefined"
|
:target="step.external ? '_blank' : undefined"
|
||||||
:rel="step.external ? 'noopener' : undefined"
|
:rel="step.external ? 'noopener' : undefined"
|
||||||
@click="(e) => { if (!step.external) $emit('close') }">
|
@click="(e) => { if (!step.external) $emit('close') }">
|
||||||
|
|||||||
@@ -240,6 +240,14 @@ export function formatWhenIso(ts) {
|
|||||||
return `${new Date(ts).toISOString().split('.')[0]}Z`
|
return `${new Date(ts).toISOString().split('.')[0]}Z`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact read time for tooltips: "50s" under a minute, "1m23s" otherwise.
|
||||||
|
*/
|
||||||
|
export function formatReadTime(seconds) {
|
||||||
|
if (seconds < 60) return `${seconds}s`
|
||||||
|
return `${Math.floor(seconds / 60)}m${seconds % 60}s`
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact visitor counts: plain below 1k, then 1.2k / 10k / 1.2M.
|
* Compact visitor counts: plain below 1k, then 1.2k / 10k / 1.2M.
|
||||||
* Truncated, not rounded.
|
* Truncated, not rounded.
|
||||||
@@ -359,13 +367,16 @@ export function formatCrawlerRows(crawlers, clients, pageTree, now = Date.now())
|
|||||||
const start = new Date(c.start).getTime()
|
const start = new Date(c.start).getTime()
|
||||||
if (start > g.lastStart) g.lastStart = start
|
if (start > g.lastStart) g.lastStart = start
|
||||||
if (c.entry?.startsWith('/')) {
|
if (c.entry?.startsWith('/')) {
|
||||||
g.pages.set(c.entry, (g.pages.get(c.entry) || 0) + 1)
|
const existing = g.pages.get(c.entry) || { count: 0, status: c.status || 200 }
|
||||||
|
existing.count += 1
|
||||||
|
if (c.status != null) existing.status = c.status
|
||||||
|
g.pages.set(c.entry, existing)
|
||||||
}
|
}
|
||||||
groups.set(c.client, g)
|
groups.set(c.client, g)
|
||||||
}
|
}
|
||||||
const totalHits = (g) => {
|
const totalHits = (g) => {
|
||||||
let n = 0
|
let n = 0
|
||||||
for (const c of g.pages.values()) n += c
|
for (const p of g.pages.values()) n += p.count
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
return [...groups.values()]
|
return [...groups.values()]
|
||||||
@@ -380,8 +391,8 @@ export function formatCrawlerRows(crawlers, clients, pageTree, now = Date.now())
|
|||||||
lastSeenIso: formatWhenIso(g.lastStart),
|
lastSeenIso: formatWhenIso(g.lastStart),
|
||||||
lastSeenLocal: formatWhenLocal(g.lastStart),
|
lastSeenLocal: formatWhenLocal(g.lastStart),
|
||||||
pages: [...g.pages.entries()]
|
pages: [...g.pages.entries()]
|
||||||
.sort((a, b) => b[1] - a[1])
|
.sort((a, b) => b[1].count - a[1].count)
|
||||||
.map(([path, count]) => ({ ...stepOf(path, titles), count })),
|
.map(([path, info]) => ({ ...stepOf(path, titles), count: info.count, status: info.status })),
|
||||||
ip: client.ip || '',
|
ip: client.ip || '',
|
||||||
ipDisplay: isHost ? mainDomain(host) : hostIP(client.ip) || client.ip || '—',
|
ipDisplay: isHost ? mainDomain(host) : hostIP(client.ip) || client.ip || '—',
|
||||||
isHost,
|
isHost,
|
||||||
@@ -499,8 +510,17 @@ export function formatVisitRows(visits, clients, pageTree, now = Date.now()) {
|
|||||||
const titles = buildTitleMap(pageTree)
|
const titles = buildTitleMap(pageTree)
|
||||||
return [...(visits || [])].reverse().slice(0, 20).map((v) => {
|
return [...(visits || [])].reverse().slice(0, 20).map((v) => {
|
||||||
const client = (clients || {})[v.client] || {}
|
const client = (clients || {})[v.client] || {}
|
||||||
|
const read = v.read || {}
|
||||||
|
const statuses = v.statuses || {}
|
||||||
const trail = [v.entry, ...(v.trail || [])]
|
const trail = [v.entry, ...(v.trail || [])]
|
||||||
.map((p) => stepOf(p, titles))
|
.map((p) => {
|
||||||
|
const step = stepOf(p, titles)
|
||||||
|
if (step) {
|
||||||
|
if (read[p]) step.readSeconds = read[p]
|
||||||
|
if (statuses[p]) step.status = statuses[p]
|
||||||
|
}
|
||||||
|
return step
|
||||||
|
})
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
const utmKeys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']
|
const utmKeys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']
|
||||||
const utmValues = utmKeys.map((k) => (v.utm || {})[k]).filter(Boolean)
|
const utmValues = utmKeys.map((k) => (v.utm || {})[k]).filter(Boolean)
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ class Visit(msgspec.Struct, omit_defaults=True):
|
|||||||
utm: dict[str, str] = {}
|
utm: dict[str, str] = {}
|
||||||
#: Active reading time per path (seconds), keyed by path.
|
#: Active reading time per path (seconds), keyed by path.
|
||||||
read: dict[str, int] = {}
|
read: dict[str, int] = {}
|
||||||
|
#: HTTP status of the response when the path was first seen (200 or 404).
|
||||||
|
statuses: dict[str, int] = {}
|
||||||
|
|
||||||
|
|
||||||
class CrawlerHit(msgspec.Struct, omit_defaults=True):
|
class CrawlerHit(msgspec.Struct, omit_defaults=True):
|
||||||
@@ -125,6 +127,8 @@ class CrawlerHit(msgspec.Struct, omit_defaults=True):
|
|||||||
referer: str = ""
|
referer: str = ""
|
||||||
#: Raw query string of the landing URL (UTM tags can be parsed from it).
|
#: Raw query string of the landing URL (UTM tags can be parsed from it).
|
||||||
query: str = ""
|
query: str = ""
|
||||||
|
#: HTTP status of the served response (200 or 404 for content pages).
|
||||||
|
status: int = 200
|
||||||
|
|
||||||
|
|
||||||
class AbuseHit(msgspec.Struct, omit_defaults=True):
|
class AbuseHit(msgspec.Struct, omit_defaults=True):
|
||||||
@@ -324,6 +328,9 @@ class Store:
|
|||||||
#: Document GETs that have not yet been matched by a ping. Kept
|
#: Document GETs that have not yet been matched by a ping. Kept
|
||||||
#: in RAM only; expired entries are written to ``data.crawlers``.
|
#: in RAM only; expired entries are written to ``data.crawlers``.
|
||||||
self.pending_crawlers: list[CrawlerHit] = []
|
self.pending_crawlers: list[CrawlerHit] = []
|
||||||
|
#: client hash -> {path: status} for recent document GETs, consumed
|
||||||
|
#: by the matching ping to record the status of each visited path.
|
||||||
|
self.pending_statuses: dict[bytes, dict[str, int]] = {}
|
||||||
#: ip -> number of plain (non-telltale) 404s seen, in RAM only;
|
#: ip -> number of plain (non-telltale) 404s seen, in RAM only;
|
||||||
#: reaching ``_ABUSE_404_THRESHOLD`` classifies the IP as abuse.
|
#: reaching ``_ABUSE_404_THRESHOLD`` classifies the IP as abuse.
|
||||||
self.not_found_counts: dict[str, int] = {}
|
self.not_found_counts: dict[str, int] = {}
|
||||||
@@ -571,6 +578,7 @@ class Store:
|
|||||||
referer: str,
|
referer: str,
|
||||||
client_hash: bytes,
|
client_hash: bytes,
|
||||||
utm: dict[str, str] | None = None,
|
utm: dict[str, str] | None = None,
|
||||||
|
status: int = 200,
|
||||||
) -> Visit:
|
) -> Visit:
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
visit = Visit(
|
visit = Visit(
|
||||||
@@ -580,6 +588,7 @@ class Store:
|
|||||||
client=client_hash,
|
client=client_hash,
|
||||||
utm=utm or {},
|
utm=utm or {},
|
||||||
)
|
)
|
||||||
|
visit.statuses[entry] = status
|
||||||
self.data.visits.append(visit)
|
self.data.visits.append(visit)
|
||||||
self.sessions[client_hash] = len(self.data.visits) - 1
|
self.sessions[client_hash] = len(self.data.visits) - 1
|
||||||
self._count(self.data.site_visits, _bucket(now))
|
self._count(self.data.site_visits, _bucket(now))
|
||||||
@@ -595,6 +604,8 @@ class Store:
|
|||||||
ua: str,
|
ua: str,
|
||||||
full_path: str,
|
full_path: str,
|
||||||
accept_language: str = "",
|
accept_language: str = "",
|
||||||
|
*,
|
||||||
|
status: int = 200,
|
||||||
) -> list[bytes]:
|
) -> list[bytes]:
|
||||||
"""Stash the entry referer/UTM tags and queue a pending crawler hit.
|
"""Stash the entry referer/UTM tags and queue a pending crawler hit.
|
||||||
|
|
||||||
@@ -642,8 +653,10 @@ class Store:
|
|||||||
client=client_hash,
|
client=client_hash,
|
||||||
referer=self.pending_referers.get(ip, ""),
|
referer=self.pending_referers.get(ip, ""),
|
||||||
query=query,
|
query=query,
|
||||||
|
status=status,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
self.pending_statuses.setdefault(client_hash, {})[entry] = status
|
||||||
return flushed
|
return flushed
|
||||||
|
|
||||||
def _add_read(self, client_hash: bytes, path: str, seconds: int) -> None:
|
def _add_read(self, client_hash: bytes, path: str, seconds: int) -> None:
|
||||||
@@ -699,6 +712,7 @@ class Store:
|
|||||||
self.pending_crawlers = [
|
self.pending_crawlers = [
|
||||||
hit for hit in self.pending_crawlers if hit.client != client_hash
|
hit for hit in self.pending_crawlers if hit.client != client_hash
|
||||||
]
|
]
|
||||||
|
self.pending_statuses.pop(client_hash, None)
|
||||||
index = self.sessions.pop(client_hash, None)
|
index = self.sessions.pop(client_hash, None)
|
||||||
if index is not None and index < len(self.data.visits):
|
if index is not None and index < len(self.data.visits):
|
||||||
self._remove_visit(index)
|
self._remove_visit(index)
|
||||||
@@ -730,6 +744,10 @@ class Store:
|
|||||||
return None, flushed
|
return None, flushed
|
||||||
index = self.sessions.get(client_hash)
|
index = self.sessions.get(client_hash)
|
||||||
fr = fr_path or "(direct)"
|
fr = fr_path or "(direct)"
|
||||||
|
statuses = self.pending_statuses.setdefault(client_hash, {})
|
||||||
|
target_status = statuses.pop(target, None) or 200
|
||||||
|
if not statuses:
|
||||||
|
self.pending_statuses.pop(client_hash, None)
|
||||||
if index is None or index >= len(self.data.visits):
|
if index is None or index >= len(self.data.visits):
|
||||||
# No known session: the initial ping of a fresh page load (or
|
# No known session: the initial ping of a fresh page load (or
|
||||||
# missing data after a server restart) — start a visit.
|
# missing data after a server restart) — start a visit.
|
||||||
@@ -740,6 +758,7 @@ class Store:
|
|||||||
self.pending_referers.pop(ip, ""),
|
self.pending_referers.pop(ip, ""),
|
||||||
client_hash,
|
client_hash,
|
||||||
utm=self.pending_utms.pop(ip, {}),
|
utm=self.pending_utms.pop(ip, {}),
|
||||||
|
status=target_status,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
visit = self.data.visits[index]
|
visit = self.data.visits[index]
|
||||||
@@ -750,6 +769,7 @@ class Store:
|
|||||||
# First-seen only: repeat pages and repeated exits don't append.
|
# First-seen only: repeat pages and repeated exits don't append.
|
||||||
if visit.entry != target and target not in visit.trail:
|
if visit.entry != target and target not in visit.trail:
|
||||||
visit.trail.append(target)
|
visit.trail.append(target)
|
||||||
|
visit.statuses[target] = target_status
|
||||||
self._save()
|
self._save()
|
||||||
visit_index = index if index is not None and index < len(self.data.visits) else None
|
visit_index = index if index is not None and index < len(self.data.visits) else None
|
||||||
return visit_index, flushed
|
return visit_index, flushed
|
||||||
|
|||||||
+4
-3
@@ -853,7 +853,7 @@ async def analytics_ping(ping: AnalyticsPing, request: Request) -> None:
|
|||||||
_schedule_client_enrichment(flushed_clients)
|
_schedule_client_enrichment(flushed_clients)
|
||||||
|
|
||||||
|
|
||||||
def _track_entry(path: str, request: Request) -> list[bytes]:
|
def _track_entry(path: str, request: Request, *, status: int = 200) -> list[bytes]:
|
||||||
"""Stash the referer/UTM tags and queue a pending crawler hit for the GET.
|
"""Stash the referer/UTM tags and queue a pending crawler hit for the GET.
|
||||||
|
|
||||||
Nothing is counted on the GET itself — the client's /_a ping starts the
|
Nothing is counted on the GET itself — the client's /_a ping starts the
|
||||||
@@ -890,6 +890,7 @@ def _track_entry(path: str, request: Request) -> list[bytes]:
|
|||||||
request.headers.get("user-agent", ""),
|
request.headers.get("user-agent", ""),
|
||||||
full_path,
|
full_path,
|
||||||
request.headers.get("accept-language", ""),
|
request.headers.get("accept-language", ""),
|
||||||
|
status=status,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1228,7 +1229,7 @@ async def show_page(request: Request, path: str) -> Response:
|
|||||||
# Category label without a landing page: placeholder with the pen
|
# Category label without a landing page: placeholder with the pen
|
||||||
# to create it (404 — no page here, but the node is real).
|
# to create it (404 — no page here, but the node is real).
|
||||||
if _is_trackable_path(path):
|
if _is_trackable_path(path):
|
||||||
flushed = _track_entry(path, request)
|
flushed = _track_entry(path, request, status=404)
|
||||||
_schedule_client_enrichment(flushed)
|
_schedule_client_enrichment(flushed)
|
||||||
return _html_response(
|
return _html_response(
|
||||||
request,
|
request,
|
||||||
@@ -1254,6 +1255,6 @@ async def show_page(request: Request, path: str) -> Response:
|
|||||||
accept_language,
|
accept_language,
|
||||||
)
|
)
|
||||||
asyncio.create_task(_enrich_client(client_hash))
|
asyncio.create_task(_enrich_client(client_hash))
|
||||||
flushed = _track_entry(path, request)
|
flushed = _track_entry(path, request, status=404)
|
||||||
_schedule_client_enrichment(flushed)
|
_schedule_client_enrichment(flushed)
|
||||||
return _html_response(request, "not-found", path, 404)
|
return _html_response(request, "not-found", path, 404)
|
||||||
|
|||||||
Reference in New Issue
Block a user