Skip to content

Commit

Permalink
[seed] Record site visits in the database (initial slow implementation)
Browse files Browse the repository at this point in the history
  • Loading branch information
eahanson committed Jun 26, 2023
1 parent 6335efb commit 5c74696
Show file tree
Hide file tree
Showing 13 changed files with 210 additions and 18 deletions.
1 change: 1 addition & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,5 @@ config :seed, Core.Mailer, adapter: Swoosh.Adapters.Test
config :logger, level: :warning
config :pages, :phoenix_endpoint, Web.Endpoint
config :phoenix, :plug_init_mode, :runtime
config :schema_assertions, :ecto_repos, [Core.Repo]
config :swoosh, :api_client, false
22 changes: 18 additions & 4 deletions lib/core/metrics.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,25 @@ defmodule Core.Metrics do
System and usage metrics.
"""

alias Core.Metrics.Visit

@doc "Record `count` visits to `path` (`count` defaults to `1`)"
@spec record_visit!(binary(), non_neg_integer()) :: Visit.t()
def record_visit!(path, count \\ 1),
do: Core.Repo.insert!(Visit.new(path, count), Visit.opts_for_upsert(count))

@doc "Returns the status of the system. (Currently, always `:ok`)"
@spec status() :: :ok
def status, do: :ok
def status,
do: :ok

@doc "Returns the total number of visits"
@spec visits() :: non_neg_integer()
def visits,
do: Core.Repo.sum(Visit, :counter)

@doc "Returns the number of visits today. (Currently, a random number >= 10)"
@spec visits() :: integer()
def visits, do: Enum.random(10..1000)
@doc "Returns the total number of visits for `path`"
@spec visits(binary()) :: non_neg_integer()
def visits(path),
do: Visit.Query.with_path(path) |> Core.Repo.sum(:counter)
end
48 changes: 48 additions & 0 deletions lib/core/metrics/visit.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
defmodule Core.Metrics.Visit do
# @related [test](/test/core/metrics/visit_test.exs)
@moduledoc """
Counts the number of visits to the site per day. Intentionally doesn't capture any user info.
Inspired by: https://dashbit.co/blog/homemade-analytics-with-ecto-and-elixir
"""

use Core.Schema
alias Core.Metrics.Visit.Query

@type t() :: %__MODULE__{}

@primary_key false
schema "visits" do
field :counter, :integer, default: 0
field :date, :date, primary_key: true
field :path, :string, primary_key: true
end

@doc "Returns a new `Visit` for today at `path` and counter `count`"
@spec new(binary(), non_neg_integer()) :: t()
def new(path, count),
do: %__MODULE__{date: Date.utc_today(), path: path, counter: count}

@spec opts_for_upsert(non_neg_integer()) :: keyword()
def opts_for_upsert(count),
do: [on_conflict: Query.upsert(count), conflict_target: [:date, :path]]

defmodule Query do
import Ecto.Query
alias Core.Metrics.Visit

def display_order(query \\ base()),
do: query |> exclude(:order_by) |> order_by([visits: visits], asc: [visits.date, visits.path])

def sum(query \\ base()),
do: query |> select([visits: visits], sum(visits.counter))

def upsert(count),
do: from(visits in Visit, update: [inc: [counter: ^count]])

def with_path(query \\ base(), path),
do: query |> where([visits: visits], visits.path == ^path)

defp base,
do: from(_ in Visit, as: :visits) |> display_order()
end
end
10 changes: 10 additions & 0 deletions lib/core/repo.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,14 @@ defmodule Core.Repo do
use Ecto.Repo,
otp_app: :seed,
adapter: Ecto.Adapters.Postgres

@doc "Returns `aggregate(:count)` for the `query`"
@spec count(Ecto.Queryable.t()) :: term()
def count(query),
do: query |> aggregate(:count)

@doc "Returns the sum of field `field` in `query`"
@spec sum(Ecto.Queryable.t(), atom()) :: term()
def sum(query, field),
do: query |> Core.Repo.aggregate(:sum, field) || 0
end
25 changes: 25 additions & 0 deletions lib/core/schema.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
defmodule Core.Schema do
# @related [test](/test/core/schema_test.exs)

@type list_or_t_or_nil(type) :: [type] | type | nil

defmacro __using__(_) do
quote do
use Ecto.Schema
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@timestamp_opts [type: :utc_datetime_usec]
end
end

@doc """
When given a struct, returns the value of its `:id` key; otherwise returns the given value with the assumption
that it is an ID.
"""
@spec id(map() | struct() | binary()) :: binary()
def id(%_{id: id}), do: id
def id(%{id: id}), do: id
def id(%{"id" => id}), do: id
def id(id) when is_binary(id), do: id
def id(other), do: raise("Expected a map or struct with `id` key or a binary, got: #{inspect(other)}")
end
10 changes: 10 additions & 0 deletions lib/web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ defmodule Web.Router do
use Web, :router

pipeline :browser do
plug :record_visit
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
Expand Down Expand Up @@ -42,4 +43,13 @@ defmodule Web.Router do
forward "/mailbox", Plug.Swoosh.MailboxPreview
end
end

defp record_visit(conn, _opts) do
register_before_send(conn, fn conn ->
if conn.status == 200,
do: Core.Metrics.record_visit!("/" <> Enum.join(conn.path_info, "/"))

conn
end)
end
end
11 changes: 11 additions & 0 deletions priv/repo/migrations/20230623150834_create_visits_table.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
defmodule Core.Repo.Migrations.CreateVisitsTable do
use Ecto.Migration

def change do
create table(:visits, primary_key: false) do
add :counter, :integer, default: 0
add :date, :date, primary_key: true
add :path, :string, primary_key: true
end
end
end
11 changes: 11 additions & 0 deletions test/core/metrics/visit_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
defmodule Core.Metrics.VisitTest do
# @related [subject](lib/core/metrics/visit.ex)
use Test.DataCase, async: true

test "schema" do
assert_schema Core.Metrics.Visit, "visits",
counter: :integer,
date: :date,
path: :string
end
end
43 changes: 37 additions & 6 deletions test/core/metrics_test.exs
Original file line number Diff line number Diff line change
@@ -1,6 +1,27 @@
defmodule Core.MetricsTest do
# @related [subject](lib/core/metrics.ex)
use Test.SimpleCase
use Test.DataCase

describe "record_visit!" do
test "records a single visit" do
assert_that Core.Metrics.record_visit!("/path/a"),
changes: Core.Metrics.visits("/path/a"),
from: 0,
to: 1

assert_that Core.Metrics.record_visit!("/path/b", 1),
changes: Core.Metrics.visits("/path/b"),
from: 0,
to: 1
end

test "can optionally record multiple visits" do
assert_that Core.Metrics.record_visit!("/path/c", 3),
changes: Core.Metrics.visits("/path/c"),
from: 0,
to: 3
end
end

describe "status" do
test "returns :ok always" do
Expand All @@ -9,11 +30,21 @@ defmodule Core.MetricsTest do
end

describe "visits" do
test "returns a random number that's greater than or equal to 10" do
1..1000
|> Enum.map(fn _ -> Core.Metrics.visits() end)
|> Enum.reject(fn count -> count >= 10 end)
|> assert_eq([])
test "returns the current number of visits for all paths" do
assert Core.Metrics.visits() == 0

Core.Metrics.record_visit!("/path/a", 1)
Core.Metrics.record_visit!("/path/b", 2)
Core.Metrics.record_visit!("/path/c", 3)

assert Core.Metrics.visits() == 6
end

test "returns the current number of visits for a given path" do
Core.Metrics.record_visit!("/path/b", 2)

assert Core.Metrics.visits("/path/a") == 0
assert Core.Metrics.visits("/path/b") == 2
end
end
end
30 changes: 30 additions & 0 deletions test/core/schema_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
defmodule Core.SchemaTest do
# @related [subject](/lib/core/schema.ex)

defmodule SampleStruct do
defstruct ~w[id]a
end

use Test.SimpleCase, async: true

describe "id" do
test "returns the id value when given a struct with an :id key" do
assert Core.Schema.id(%SampleStruct{id: "1234"}) == "1234"
end

test "returns the id value when given a map with an :id key" do
assert Core.Schema.id(%{id: "1234"}) == "1234"
assert Core.Schema.id(%{"id" => "1234"}) == "1234"
end

test "returns the argument when given a binary" do
assert Core.Schema.id("1234") == "1234"
end

test "raises when given something else" do
assert_raise RuntimeError, "Expected a map or struct with `id` key or a binary, got: [:a, :b]", fn ->
Core.Schema.id([:a, :b])
end
end
end
end
3 changes: 3 additions & 0 deletions test/support/data_case.ex
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ defmodule Test.DataCase do
import Ecto
import Ecto.Changeset
import Ecto.Query
import Moar.Assertions
import Moar.Enum, only: [tids: 1]
import SchemaAssertions
import Test.DataCase
end
end
Expand Down
10 changes: 3 additions & 7 deletions test/support/pages/home.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ defmodule Test.Pages.Home do
# @related [LiveView](lib/web/live/home_live.ex)

import Moar.Assertions
import ExUnit.Assertions
alias HtmlQuery, as: Hq

@spec assert_here(Pages.Driver.t()) :: Pages.Driver.t()
Expand All @@ -13,12 +12,9 @@ defmodule Test.Pages.Home do
def assert_message(page, expected_message),
do: page |> Hq.find!(test_role: "message") |> Hq.text() |> assert_eq(expected_message, returning: page)

@spec assert_visits(Pages.Driver.t(), atom(), integer()) :: Pages.Driver.t()
def assert_visits(page, :gte, min_count) do
count = page |> Hq.find!(test_role: "visits") |> Hq.text() |> String.to_integer()
assert count >= min_count
page
end
@spec assert_visits(Pages.Driver.t(), non_neg_integer()) :: Pages.Driver.t()
def assert_visits(page, count),
do: page |> Hq.find!(test_role: "visits") |> Hq.text() |> String.to_integer() |> assert_eq(count, returning: page)

@spec visit(Pages.Driver.t()) :: Pages.Driver.t()
def visit(page),
Expand Down
4 changes: 3 additions & 1 deletion test/web/live/home_live_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ defmodule Web.HomeLiveTest do

@tag page: :logged_out
test "says 'hello world'", %{pages: %{logged_out: page}} do
visits_from_setup = Core.Metrics.visits()

page
|> Test.Pages.Home.visit()
|> Test.Pages.Home.assert_here()
|> Test.Pages.Home.assert_message("hello world!")
|> Test.Pages.Home.assert_visits(:gte, 10)
|> Test.Pages.Home.assert_visits(visits_from_setup + 1)
end
end

0 comments on commit 5c74696

Please sign in to comment.