add analytics-powered 404 monitoring with FTS5 auto-resolve
All checks were successful
deploy / deploy (push) Successful in 9m48s
All checks were successful
deploy / deploy (push) Successful in 9m48s
BrokenUrlTracker now queries real analytics pageview counts instead of hardcoding 0, so broken URLs with prior traffic are distinguished from bot noise. For /products/ 404s with a single FTS5 search match, auto- creates a redirect and marks the broken URL resolved. 1232 tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6e57af82fc
commit
0c54861eb6
@ -364,6 +364,20 @@ defmodule Berrypod.Analytics do
|
|||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Counts all-time pageviews for a specific path.
|
||||||
|
|
||||||
|
Used by the broken URL tracker to distinguish real broken URLs
|
||||||
|
(paths that previously had traffic) from bot noise.
|
||||||
|
"""
|
||||||
|
def count_pageviews_for_path(path) do
|
||||||
|
from(e in Event,
|
||||||
|
where: e.name == "pageview" and e.pathname == ^path,
|
||||||
|
select: count()
|
||||||
|
)
|
||||||
|
|> Repo.one()
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Deletes events older than the given datetime. Used by the retention worker.
|
Deletes events older than the given datetime. Used by the retention worker.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -336,6 +336,44 @@ defmodule Berrypod.Redirects do
|
|||||||
Repo.one(from b in BrokenUrl, where: b.path == ^path and b.status == "pending")
|
Repo.one(from b in BrokenUrl, where: b.path == ^path and b.status == "pending")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# ── Auto-resolve ──
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Attempts to auto-resolve a broken product URL using FTS5 search.
|
||||||
|
|
||||||
|
Converts the slug back to words and searches the product index.
|
||||||
|
If exactly one result is found, creates a redirect and marks
|
||||||
|
the broken URL as resolved. Multiple or zero results are left
|
||||||
|
for admin review.
|
||||||
|
"""
|
||||||
|
def attempt_auto_resolve("/products/" <> old_slug = path) do
|
||||||
|
query = old_slug |> String.replace("-", " ") |> String.trim()
|
||||||
|
|
||||||
|
case Berrypod.Search.search(query) do
|
||||||
|
[%{slug: new_slug}] when new_slug != old_slug ->
|
||||||
|
case create_auto(%{
|
||||||
|
from_path: path,
|
||||||
|
to_path: "/products/#{new_slug}",
|
||||||
|
source: "analytics_auto_resolved"
|
||||||
|
}) do
|
||||||
|
{:ok, _redirect} ->
|
||||||
|
if broken = get_broken_url_by_path(path) do
|
||||||
|
mark_broken_url_resolved(broken)
|
||||||
|
end
|
||||||
|
|
||||||
|
:resolved
|
||||||
|
|
||||||
|
_error ->
|
||||||
|
:no_match
|
||||||
|
end
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
:no_match
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def attempt_auto_resolve(_path), do: :no_match
|
||||||
|
|
||||||
# ── Pruning ──
|
# ── Pruning ──
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|||||||
@ -5,7 +5,7 @@ defmodule Berrypod.Redirects.Redirect do
|
|||||||
@primary_key {:id, :binary_id, autogenerate: true}
|
@primary_key {:id, :binary_id, autogenerate: true}
|
||||||
@foreign_key_type :binary_id
|
@foreign_key_type :binary_id
|
||||||
|
|
||||||
@sources ~w(auto_slug_change auto_product_deleted analytics_detected admin)
|
@sources ~w(auto_slug_change auto_product_deleted analytics_auto_resolved admin)
|
||||||
|
|
||||||
schema "redirects" do
|
schema "redirects" do
|
||||||
field :from_path, :string
|
field :from_path, :string
|
||||||
|
|||||||
@ -20,7 +20,12 @@ defmodule BerrypodWeb.Plugs.BrokenUrlTracker do
|
|||||||
rescue
|
rescue
|
||||||
e in Phoenix.Router.NoRouteError ->
|
e in Phoenix.Router.NoRouteError ->
|
||||||
unless static_path?(conn.request_path) do
|
unless static_path?(conn.request_path) do
|
||||||
Berrypod.Redirects.record_broken_url(conn.request_path, 0)
|
prior_hits = Berrypod.Analytics.count_pageviews_for_path(conn.request_path)
|
||||||
|
Berrypod.Redirects.record_broken_url(conn.request_path, prior_hits)
|
||||||
|
|
||||||
|
if prior_hits > 0 do
|
||||||
|
Berrypod.Redirects.attempt_auto_resolve(conn.request_path)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
reraise e, __STACKTRACE__
|
reraise e, __STACKTRACE__
|
||||||
|
|||||||
@ -366,6 +366,23 @@ defmodule Berrypod.AnalyticsTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "count_pageviews_for_path/1" do
|
||||||
|
test "counts all pageviews for a specific path" do
|
||||||
|
v1 = :crypto.strong_rand_bytes(8)
|
||||||
|
v2 = :crypto.strong_rand_bytes(8)
|
||||||
|
|
||||||
|
insert_event(%{visitor_hash: v1, pathname: "/products/classic-tee"})
|
||||||
|
insert_event(%{visitor_hash: v2, pathname: "/products/classic-tee"})
|
||||||
|
insert_event(%{visitor_hash: v1, pathname: "/products/other"})
|
||||||
|
|
||||||
|
assert Analytics.count_pageviews_for_path("/products/classic-tee") == 2
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns 0 for paths with no history" do
|
||||||
|
assert Analytics.count_pageviews_for_path("/products/never-visited") == 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "delete_events_before/1" do
|
describe "delete_events_before/1" do
|
||||||
test "deletes old events" do
|
test "deletes old events" do
|
||||||
old = DateTime.add(DateTime.utc_now(), -400, :day) |> DateTime.truncate(:second)
|
old = DateTime.add(DateTime.utc_now(), -400, :day) |> DateTime.truncate(:second)
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
defmodule Berrypod.RedirectsIntegrationTest do
|
defmodule Berrypod.RedirectsIntegrationTest do
|
||||||
use Berrypod.DataCase, async: true
|
# async: false because FTS5 search index is shared state
|
||||||
|
use Berrypod.DataCase, async: false
|
||||||
|
|
||||||
alias Berrypod.{Products, Redirects}
|
alias Berrypod.{Products, Redirects, Search}
|
||||||
import Berrypod.ProductsFixtures
|
import Berrypod.ProductsFixtures
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
@ -88,4 +89,45 @@ defmodule Berrypod.RedirectsIntegrationTest do
|
|||||||
Redirects.lookup("/products/#{product.slug}")
|
Redirects.lookup("/products/#{product.slug}")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "attempt_auto_resolve/1" do
|
||||||
|
test "creates redirect when FTS5 finds a single match" do
|
||||||
|
# Product was renamed: "Classic Tee" -> "Classic Tee V2"
|
||||||
|
# Old slug was "classic-tee", new slug is "classic-tee-v2"
|
||||||
|
product =
|
||||||
|
product_fixture(%{
|
||||||
|
title: "Classic Tee V2",
|
||||||
|
slug: "classic-tee-v2",
|
||||||
|
category: "T-Shirts",
|
||||||
|
visible: true,
|
||||||
|
status: "active"
|
||||||
|
})
|
||||||
|
|
||||||
|
product_variant_fixture(%{product: product, title: "Medium / Black", price: 2500})
|
||||||
|
Search.rebuild_index()
|
||||||
|
|
||||||
|
# Record a broken URL for the old slug
|
||||||
|
{:ok, _} = Redirects.record_broken_url("/products/classic-tee", 10)
|
||||||
|
|
||||||
|
assert :resolved = Redirects.attempt_auto_resolve("/products/classic-tee")
|
||||||
|
|
||||||
|
# Redirect should point to the matched product
|
||||||
|
assert {:ok, %{to_path: "/products/classic-tee-v2"}} =
|
||||||
|
Redirects.lookup("/products/classic-tee")
|
||||||
|
|
||||||
|
# Broken URL should be marked resolved
|
||||||
|
assert Redirects.list_broken_urls("pending") == []
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns :no_match when no search results" do
|
||||||
|
Search.rebuild_index()
|
||||||
|
|
||||||
|
assert :no_match = Redirects.attempt_auto_resolve("/products/totally-unique-nonexistent")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns :no_match for non-product paths" do
|
||||||
|
assert :no_match = Redirects.attempt_auto_resolve("/about")
|
||||||
|
assert :no_match = Redirects.attempt_auto_resolve("/collections/old-category")
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user