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
34 changes: 30 additions & 4 deletions lib/revstack/tracking/service.ex
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ defmodule Revstack.Tracking.Service do
method = Map.get(attrs, :method)

with {:ok, visitor} <- find_or_create_visitor(ip, user_agent, referrer),
{:ok, _page_visit} <- create_page_visit(visitor, path, full_url, query_string, method) do
{:ok, _page_visit} <-
create_page_visit(visitor, path, full_url, query_string, method, referrer) do
maybe_enrich_location(visitor)
{:ok, visitor}
end
Expand All @@ -39,7 +40,9 @@ defmodule Revstack.Tracking.Service do
def find_or_create_visitor(ip_address, user_agent \\ nil, referrer \\ nil) do
case Visitor.by_ip(ip_address, authorize?: false) do
{:ok, visitor} ->
Visitor.record_visit(visitor, authorize?: false)
with {:ok, visitor} <- maybe_backfill_referrer(visitor, referrer) do
Visitor.record_visit(visitor, authorize?: false)
end

{:error, _} ->
Visitor.create(
Expand All @@ -56,19 +59,42 @@ defmodule Revstack.Tracking.Service do
@doc """
Creates a page visit record for a visitor.
"""
def create_page_visit(visitor, path, full_url \\ nil, query_string \\ nil, method \\ nil) do
def create_page_visit(
visitor,
path,
full_url \\ nil,
query_string \\ nil,
method \\ nil,
referrer \\ nil
) do
VisitorPageVisit.create(
%{
visitor_id: visitor.id,
path: path,
full_url: full_url,
query_string: query_string,
method: method
method: method,
referrer: referrer
},
authorize?: false
)
end

defp maybe_backfill_referrer(visitor, referrer) do
cond do
present_referrer?(visitor.referrer) ->
{:ok, visitor}

present_referrer?(referrer) ->
Visitor.backfill_referrer(visitor, %{referrer: referrer}, authorize?: false)

true ->
{:ok, visitor}
end
end

defp present_referrer?(value), do: is_binary(value) and String.trim(value) != ""

@doc """
Triggers location enrichment for a visitor if location data is missing.
Runs asynchronously to avoid blocking page rendering.
Expand Down
7 changes: 7 additions & 0 deletions lib/revstack/tracking/visitor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ defmodule Revstack.Tracking.Visitor do
define :read, action: :read
define :by_ip, action: :by_ip, args: [:ip_address]
define :record_visit, action: :record_visit
define :backfill_referrer, action: :backfill_referrer
define :enrich_location, action: :enrich_location
define :destroy, action: :destroy
end
Expand Down Expand Up @@ -53,6 +54,12 @@ defmodule Revstack.Tracking.Visitor do
change increment(:visit_count)
end

update :backfill_referrer do
require_atomic? false

accept [:referrer]
end

update :enrich_location do
require_atomic? false

Expand Down
6 changes: 6 additions & 0 deletions lib/revstack/tracking/visitor_page_visit.ex
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ defmodule Revstack.Tracking.VisitorPageVisit do
:full_url,
:query_string,
:method,
:referrer,
:visitor_id
]

Expand Down Expand Up @@ -80,6 +81,11 @@ defmodule Revstack.Tracking.VisitorPageVisit do
public? true
end

attribute :referrer, :string do
allow_nil? true
public? true
end

attribute :visited_at, :utc_datetime do
allow_nil? false
public? true
Expand Down
12 changes: 11 additions & 1 deletion lib/revstack_web/plugs/visitor_tracking.ex
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ defmodule RevstackWeb.Plugs.VisitorTracking do
def call(conn, _opts) do
ip = Service.extract_ip(conn)
user_agent = get_user_agent(conn)
referrer = get_referrer(conn)
referrer = get_referrer(conn) || Plug.Conn.get_session(conn, :visitor_referrer)

conn
|> Plug.Conn.assign(:visitor_ip, ip)
Expand Down Expand Up @@ -54,5 +54,15 @@ defmodule RevstackWeb.Plugs.VisitorTracking do
conn
|> Plug.Conn.get_req_header("referer")
|> List.first()
|> normalize_blank()
end

defp normalize_blank(value) when is_binary(value) do
case String.trim(value) do
"" -> nil
trimmed -> trimmed
end
end

defp normalize_blank(_value), do: nil
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
defmodule Revstack.Repo.Migrations.AddReferrerToVisitorPageVisits do
@moduledoc """
Updates resources based on their most recent snapshots.

This file was autogenerated with `mix ash_postgres.generate_migrations`
"""

use Ecto.Migration

def up do
alter table(:visitor_page_visits) do
add(:referrer, :text)
end
end

def down do
alter table(:visitor_page_visits) do
remove(:referrer)
end
end
end
159 changes: 159 additions & 0 deletions priv/resource_snapshots/repo/visitor_page_visits/20260413202952.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "path",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "full_url",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "query_string",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "method",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "referrer",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "visited_at",
"type": "utc_datetime"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "inserted_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "visitor_page_visits_visitor_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": null,
"table": "visitors"
},
"scale": null,
"size": null,
"source": "visitor_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"create_table_options": null,
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "CA26469D09F600007777880AC5713745DD76FBCAF8FE41A0B74034325D189045",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Revstack.Repo",
"schema": null,
"table": "visitor_page_visits"
}
41 changes: 40 additions & 1 deletion test/revstack/tracking/service_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,28 @@ defmodule Revstack.Tracking.ServiceTest do

assert visitor.referrer == "https://google.com"
end

test "backfills referrer for existing visitor when missing" do
{:ok, visitor1} = Service.find_or_create_visitor("203.0.113.6", "TestAgent", nil)
assert is_nil(visitor1.referrer)

{:ok, visitor2} =
Service.find_or_create_visitor("203.0.113.6", "TestAgent", "https://google.com")

assert visitor2.id == visitor1.id
assert visitor2.referrer == "https://google.com"
end

test "does not overwrite existing referrer for existing visitor" do
{:ok, visitor1} =
Service.find_or_create_visitor("203.0.113.7", "TestAgent", "https://google.com")

{:ok, visitor2} =
Service.find_or_create_visitor("203.0.113.7", "TestAgent", "https://bing.com")

assert visitor2.id == visitor1.id
assert visitor2.referrer == "https://google.com"
end
end

describe "create_page_visit/5" do
Expand Down Expand Up @@ -78,6 +100,22 @@ defmodule Revstack.Tracking.ServiceTest do
assert visit.method == "GET"
end

test "stores referrer on page visit" do
{:ok, visitor} = Service.find_or_create_visitor("203.0.113.13")

assert {:ok, visit} =
Service.create_page_visit(
visitor,
"/services",
"http://localhost/services",
nil,
"GET",
"https://example.com/source"
)

assert visit.referrer == "https://example.com/source"
end

test "creates multiple page visits for same visitor" do
{:ok, visitor} = Service.find_or_create_visitor("203.0.113.12")

Expand All @@ -96,7 +134,7 @@ defmodule Revstack.Tracking.ServiceTest do
Service.track_page_visit(%{
ip_address: "203.0.113.20",
user_agent: "TestUA",
referrer: nil,
referrer: "https://example.com/source",
path: "/",
full_url: "http://localhost/",
query_string: nil,
Expand All @@ -109,6 +147,7 @@ defmodule Revstack.Tracking.ServiceTest do
visits = VisitorPageVisit.for_visitor!(visitor.id, authorize?: false)
assert length(visits) == 1
assert hd(visits).path == "/"
assert hd(visits).referrer == "https://example.com/source"
end

test "tracks multiple pages for the same visitor" do
Expand Down
Loading
Loading