| defmodule Plausible.Session.CacheStoreTest do |
| use Plausible.DataCase |
|
|
| alias Plausible.Session.CacheStore |
|
|
| @session_params %{ |
| referrer: "ref", |
| referrer_source: "refsource", |
| utm_medium: "medium", |
| utm_source: "source", |
| utm_campaign: "campaign", |
| utm_content: "content", |
| utm_term: "term", |
| browser: "browser", |
| browser_version: "55", |
| country_code: "EE", |
| screen_size: "Desktop", |
| operating_system: "Mac", |
| operating_system_version: "11" |
| } |
|
|
| setup do |
| current_pid = self() |
|
|
| buffer = fn sessions -> |
| send(current_pid, {:buffer, :insert, [sessions]}) |
| {:ok, sessions} |
| end |
|
|
| slow_buffer = fn sessions -> |
| Process.sleep(200) |
| send(current_pid, {:slow_buffer, :insert, [sessions]}) |
| {:ok, sessions} |
| end |
|
|
| [buffer: buffer, slow_buffer: slow_buffer] |
| end |
|
|
| test "event processing is sequential within session", %{ |
| buffer: buffer, |
| slow_buffer: slow_buffer, |
| test: test |
| } do |
| telemetry_event = CacheStore.lock_telemetry_event() |
|
|
| test_pid = self() |
|
|
| :telemetry.attach( |
| "#{test}-telemetry-handler", |
| telemetry_event, |
| fn ^telemetry_event, %{duration: d}, _, _ when is_integer(d) -> |
| send(test_pid, {:telemetry_handled, d}) |
| end, |
| %{} |
| ) |
|
|
| event1 = build(:event, name: "pageview") |
| event2 = build(:event, name: "pageview", user_id: event1.user_id, site_id: event1.site_id) |
| event3 = build(:event, name: "pageview", user_id: event1.user_id, site_id: event1.site_id) |
|
|
| CacheStore.on_event(event1, @session_params, nil, buffer_insert: buffer) |
|
|
| assert_receive({:buffer, :insert, [[session1]]}) |
| assert_receive({:telemetry_handled, duration}) |
| assert is_integer(duration) |
|
|
| [event2, event3] |
| |> Enum.map(fn e -> |
| Task.async(fn -> |
| CacheStore.on_event(e, @session_params, nil, buffer_insert: slow_buffer) |
| end) |
| end) |
| |> Task.await_many() |
|
|
| assert_receive({:slow_buffer, :insert, [[removed_session11, updated_session12]]}) |
| assert_receive({:slow_buffer, :insert, [[removed_session12, updated_session13]]}) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| assert removed_session11 == %{session1 | sign: -1} |
| assert updated_session12.sign == 1 |
| assert updated_session12.events == 2 |
| assert updated_session12.pageviews == 2 |
| assert removed_session12 == %{updated_session12 | sign: -1} |
| assert updated_session13.sign == 1 |
| assert updated_session13.events == 3 |
| assert updated_session13.pageviews == 3 |
| end |
|
|
| @tag :slow |
| test "in case of lock kicking in, the slow event finishes processing", %{buffer: buffer} do |
| current_pid = self() |
|
|
| very_slow_buffer = fn sessions -> |
| Process.sleep(1000) |
| send(current_pid, {:very_slow_buffer, :insert, [sessions]}) |
| {:ok, sessions} |
| end |
|
|
| event1 = build(:event, name: "pageview") |
| event2 = build(:event, name: "pageview", user_id: event1.user_id, site_id: event1.site_id) |
| event3 = build(:event, name: "pageview", user_id: event1.user_id, site_id: event1.site_id) |
|
|
| async1 = |
| Task.async(fn -> |
| CacheStore.on_event(event1, @session_params, nil, buffer_insert: very_slow_buffer) |
| end) |
|
|
| |
| Process.sleep(100) |
|
|
| async2 = |
| Task.async(fn -> |
| CacheStore.on_event(event2, @session_params, nil, buffer_insert: buffer) |
| end) |
|
|
| async3 = |
| Task.async(fn -> |
| CacheStore.on_event(event3, @session_params, nil, buffer_insert: buffer) |
| end) |
|
|
| Task.await_many([async1, async2, async3]) |
|
|
| assert_receive({:very_slow_buffer, :insert, [[_session]]}) |
| refute_receive({:buffer, :insert, [[_updated_session]]}) |
| end |
|
|
| @tag :slow |
| test "lock on slow processing of one event does not affect unrelated events", %{buffer: buffer} do |
| current_pid = self() |
|
|
| very_slow_buffer = fn sessions -> |
| Process.sleep(1000) |
| send(current_pid, {:very_slow_buffer, :insert, [sessions]}) |
| {:ok, sessions} |
| end |
|
|
| event1 = build(:event, name: "pageview") |
| event2 = build(:event, name: "pageview") |
| event3 = build(:event, name: "pageview", user_id: event2.user_id, site_id: event2.site_id) |
|
|
| async1 = |
| Task.async(fn -> |
| CacheStore.on_event(event1, @session_params, nil, buffer_insert: very_slow_buffer) |
| end) |
|
|
| |
| Process.sleep(100) |
|
|
| async2 = |
| Task.async(fn -> |
| CacheStore.on_event(event2, @session_params, nil, buffer_insert: buffer) |
| end) |
|
|
| Process.sleep(100) |
|
|
| async3 = |
| Task.async(fn -> |
| CacheStore.on_event(event3, @session_params, nil, buffer_insert: buffer) |
| end) |
|
|
| Task.await_many([async1, async2, async3]) |
|
|
| assert_receive({:very_slow_buffer, :insert, [[_slow_session]]}) |
| assert_receive({:buffer, :insert, [[new_session1]]}) |
| assert_receive({:buffer, :insert, [[removed_session1, updated_session1]]}) |
| assert new_session1.sign == 1 |
| assert removed_session1.session_id == new_session1.session_id |
| assert removed_session1.sign == -1 |
| assert updated_session1.session_id == removed_session1.session_id |
| assert updated_session1.sign == 1 |
| end |
|
|
| test "exploding event processing is passed through by locking mechanism" do |
| crashing_buffer = fn _sessions -> |
| raise "boom" |
| end |
|
|
| event = build(:event, name: "pageview") |
|
|
| assert_raise RuntimeError, "boom", fn -> |
| CacheStore.on_event(event, @session_params, nil, buffer_insert: crashing_buffer) |
| end |
| end |
|
|
| test "creates a session from an event", %{buffer: buffer} do |
| event = |
| build(:event, |
| name: "pageview", |
| "meta.key": ["logged_in", "darkmode"], |
| "meta.value": ["true", "false"] |
| ) |
|
|
| CacheStore.on_event(event, @session_params, nil, buffer_insert: buffer) |
|
|
| assert_receive({:buffer, :insert, [sessions]}) |
| assert [session] = sessions |
| assert session.hostname == event.hostname |
|
|
| assert session.site_id == event.site_id |
|
|
| assert session.user_id == event.user_id |
| assert session.entry_page == event.pathname |
| assert session.exit_page == event.pathname |
| assert session.is_bounce == true |
| assert session.duration == 0 |
| assert session.pageviews == 1 |
| assert session.events == 1 |
| assert session.referrer == Map.get(@session_params, :referrer) |
| assert session.referrer_source == Map.get(@session_params, :referrer_source) |
| assert session.utm_medium == Map.get(@session_params, :utm_medium) |
| assert session.utm_source == Map.get(@session_params, :utm_source) |
| assert session.utm_campaign == Map.get(@session_params, :utm_campaign) |
| assert session.utm_content == Map.get(@session_params, :utm_content) |
| assert session.utm_term == Map.get(@session_params, :utm_term) |
| assert session.country_code == Map.get(@session_params, :country_code) |
| assert session.screen_size == Map.get(@session_params, :screen_size) |
| assert session.operating_system == Map.get(@session_params, :operating_system) |
| assert session.operating_system_version == Map.get(@session_params, :operating_system_version) |
| assert session.browser == Map.get(@session_params, :browser) |
| assert session.browser_version == Map.get(@session_params, :browser_version) |
| assert session.timestamp == event.timestamp |
| assert session.start === event.timestamp |
| |
| |
| end |
|
|
| test "updates session counters", %{buffer: buffer} do |
| timestamp = DateTime.utc_now() |
|
|
| event1 = |
| build(:event, name: "pageview", timestamp: timestamp |> NaiveDateTime.shift(second: -10)) |
|
|
| event2 = %{ |
| event1 |
| | timestamp: timestamp |
| } |
|
|
| CacheStore.on_event(event1, %{}, nil, buffer_insert: buffer) |
| CacheStore.on_event(event2, %{}, nil, buffer_insert: buffer) |
| assert_receive({:buffer, :insert, [[_negative_record, session]]}) |
| assert session.is_bounce == false |
| assert session.duration == 10 |
| assert session.pageviews == 2 |
| assert session.events == 2 |
| end |
|
|
| test "does not update session counters on engagement event", %{buffer: buffer} do |
| now = NaiveDateTime.utc_now(:second) |
| pageview = build(:pageview, timestamp: NaiveDateTime.shift(now, second: -10)) |
| engagement = %{pageview | name: "engagement", timestamp: now} |
|
|
| CacheStore.on_event(pageview, %{}, nil, buffer_insert: buffer) |
| CacheStore.on_event(engagement, %{}, nil, buffer_insert: buffer) |
| assert_receive({:buffer, :insert, [[session]]}) |
|
|
| assert session.is_bounce == true |
| assert session.duration == 0 |
| assert session.pageviews == 1 |
| assert session.events == 1 |
| end |
|
|
| test "keeps session in cache after engagement events", %{buffer: buffer} do |
| start = NaiveDateTime.utc_now(:second) |> NaiveDateTime.shift(day: -1) |
|
|
| pageview1 = build(:event, name: "pageview", timestamp: start) |
|
|
| CacheStore.on_event(pageview1, %{}, nil, buffer_insert: buffer) |
| assert_receive({:buffer, :insert, [[start_session]]}) |
|
|
| for delta <- [20, 40, 60] do |
| engagement = |
| Map.merge(pageview1, %{ |
| name: "engagement", |
| timestamp: start |> NaiveDateTime.shift(minute: delta) |
| }) |
|
|
| CacheStore.on_event(engagement, %{}, nil, buffer_insert: buffer) |
| end |
|
|
| pageview2 = Map.put(pageview1, :timestamp, start |> NaiveDateTime.shift(minute: 80)) |
| CacheStore.on_event(pageview2, %{}, nil, buffer_insert: buffer) |
| assert_receive({:buffer, :insert, [[_negative_record, updated_session]]}) |
|
|
| assert updated_session.session_id == start_session.session_id |
| assert updated_session.is_bounce == false |
| assert updated_session.duration == 80 * 60 |
| assert updated_session.pageviews == 2 |
| assert updated_session.events == 2 |
| end |
|
|
| describe "hostname-related attributes" do |
| test "initial for non-pageview" do |
| site_id = new_site_id() |
|
|
| event = |
| build(:event, |
| name: "custom_event", |
| site_id: site_id, |
| pathname: "/path/1", |
| hostname: "example.com" |
| ) |
|
|
| flush([event]) |
| session = get_session(site_id) |
| assert session.hostname == "" |
| assert session.exit_page_hostname == "" |
| end |
|
|
| test "initial for pageview" do |
| site_id = new_site_id() |
|
|
| event = |
| build(:event, |
| name: "pageview", |
| site_id: site_id, |
| pathname: "/path/1", |
| hostname: "example.com" |
| ) |
|
|
| flush([event]) |
| session = get_session(site_id) |
| assert session.hostname == "example.com" |
| assert session.exit_page_hostname == "example.com" |
| end |
|
|
| test "subsequent pageview after custom_event" do |
| site_id = new_site_id() |
|
|
| events = [ |
| build(:event, |
| name: "custom_event", |
| site_id: site_id, |
| pathname: "/path/1", |
| hostname: "whatever.example.com", |
| timestamp: DateTime.shift(DateTime.utc_now(), second: -5), |
| user_id: 1 |
| ), |
| build(:event, |
| name: "pageview", |
| site_id: site_id, |
| pathname: "/path/2", |
| hostname: "example.com", |
| user_id: 1 |
| ) |
| ] |
|
|
| flush(events) |
| session = get_session(site_id) |
| assert session.hostname == "example.com" |
| assert session.exit_page_hostname == "example.com" |
| end |
|
|
| test "hostname change" do |
| site_id = new_site_id() |
|
|
| events = [ |
| build(:event, |
| name: "pageview", |
| site_id: site_id, |
| pathname: "/landing", |
| hostname: "example.com", |
| timestamp: DateTime.shift(DateTime.utc_now(), second: -5), |
| user_id: 1 |
| ), |
| build(:event, |
| name: "pageview", |
| site_id: site_id, |
| pathname: "/post/1", |
| hostname: "blog.example.com", |
| user_id: 1 |
| ) |
| ] |
|
|
| flush(events) |
| session = get_session(site_id) |
| assert session.hostname == "example.com" |
| assert session.exit_page_hostname == "blog.example.com" |
| end |
|
|
| test "hostname change with custom event in the middle" do |
| site_id = new_site_id() |
|
|
| events = [ |
| build(:event, |
| name: "pageview", |
| site_id: site_id, |
| pathname: "/landing", |
| hostname: "example.com", |
| timestamp: DateTime.shift(DateTime.utc_now(), second: -5), |
| user_id: 1 |
| ), |
| build(:event, |
| name: "custom_event", |
| site_id: site_id, |
| pathname: "/path/1", |
| hostname: "analytics.example.com", |
| timestamp: DateTime.shift(DateTime.utc_now(), second: -3), |
| user_id: 1 |
| ), |
| build(:event, |
| name: "pageview", |
| site_id: site_id, |
| pathname: "/post/1", |
| hostname: "blog.example.com", |
| user_id: 1 |
| ) |
| ] |
|
|
| flush(events) |
| session = get_session(site_id) |
| assert session.hostname == "example.com" |
| assert session.exit_page_hostname == "blog.example.com" |
| end |
| end |
|
|
| test "initial pageview-specific attributes" do |
| site_id = new_site_id() |
|
|
| event = |
| build(:event, |
| name: "custom_event", |
| site_id: site_id, |
| pathname: "/path/1", |
| user_id: 1 |
| ) |
|
|
| flush([event]) |
|
|
| session = get_session(site_id) |
|
|
| assert session.exit_page == "" |
| assert session.events == 1 |
| assert session.pageviews == 0 |
| end |
|
|
| test "updating pageview-specific attributes" do |
| site_id = new_site_id() |
|
|
| event1 = |
| build(:event, |
| name: "custom_event", |
| site_id: site_id, |
| pathname: "/path/1", |
| user_id: 1 |
| ) |
|
|
| event2 = |
| build(:pageview, |
| pathname: "/path/2", |
| site_id: site_id, |
| user_id: 1 |
| ) |
|
|
| event3 = |
| build(:pageview, |
| pathname: "/path/3", |
| site_id: site_id, |
| user_id: 1 |
| ) |
|
|
| event4 = |
| build(:event, |
| name: "custom_event", |
| site_id: site_id, |
| pathname: "/path/4", |
| user_id: 1 |
| ) |
|
|
| flush([event1, event2, event3, event4]) |
|
|
| session = get_session(site_id) |
|
|
| assert session.entry_page == "/path/2" |
| assert session.exit_page == "/path/3" |
| assert session.events == 4 |
| assert session.pageviews == 2 |
| end |
|
|
| test "calculates duration correctly for out-of-order events", %{buffer: buffer} do |
| timestamp = DateTime.utc_now() |
|
|
| event1 = |
| build(:event, name: "pageview", timestamp: timestamp |> NaiveDateTime.shift(second: 10)) |
|
|
| event2 = %{event1 | timestamp: timestamp} |
|
|
| CacheStore.on_event(event1, %{}, nil, buffer_insert: buffer) |
| CacheStore.on_event(event2, %{}, nil, buffer_insert: buffer) |
|
|
| assert_receive({:buffer, :insert, [[_negative_record, session]]}) |
| assert session.duration == 10 |
| end |
|
|
| describe "collapse order" do |
| test "across parts" do |
| e = build(:event, name: "pageview", site_id: new_site_id()) |
|
|
| flush([%{e | pathname: "/"}]) |
| flush([%{e | pathname: "/exit"}]) |
|
|
| session_q = from s in Plausible.ClickhouseSessionV2, where: s.site_id == ^e.site_id |
| session = Plausible.ClickhouseRepo.one!(session_q, settings: [final: true]) |
|
|
| refute session.is_bounce |
| assert session.entry_page == "/" |
| assert session.exit_page == "/exit" |
| end |
|
|
| test "within parts" do |
| e = build(:event, name: "pageview", site_id: new_site_id()) |
|
|
| flush([ |
| %{e | pathname: "/"}, |
| %{e | pathname: "/exit"} |
| ]) |
|
|
| session = get_session(e.site_id) |
|
|
| refute session.is_bounce |
| assert session.entry_page == "/" |
| assert session.exit_page == "/exit" |
| end |
|
|
| test "across and within parts" do |
| e = build(:event, name: "pageview", site_id: new_site_id()) |
|
|
| flush([ |
| %{e | pathname: "/"}, |
| %{e | pathname: "/about"} |
| ]) |
|
|
| flush([ |
| %{e | pathname: "/login"}, |
| %{e | pathname: "/exit"} |
| ]) |
|
|
| session = get_session(e.site_id, final: true) |
|
|
| refute session.is_bounce |
| assert session.entry_page == "/" |
| assert session.exit_page == "/exit" |
| assert session.events == 4 |
| end |
| end |
|
|
| describe "bounce detection" do |
| test "single pageview is bounce" do |
| session = create_session([build(:pageview)]) |
| assert session.is_bounce |
| end |
|
|
| test "two pageviews is not a bounce" do |
| session = create_session([build(:pageview), build(:pageview)]) |
| refute session.is_bounce |
| end |
|
|
| test "(implicitly interactive) custom event is not a bounce" do |
| session = create_session([build(:event, name: "interactive")]) |
| refute session.is_bounce |
| end |
|
|
| test "non-interactive custom event is a bounce" do |
| session = create_session([build(:event, name: "non-interactive", interactive?: false)]) |
| assert session.is_bounce |
| end |
|
|
| test "pageview followed by non-interactive custom event is a bounce" do |
| session = |
| create_session([ |
| build(:pageview), |
| build(:event, name: "non-interactive", interactive?: false) |
| ]) |
|
|
| assert session.is_bounce |
| end |
|
|
| test "non-interactive custom event followed by pageview is a bounce" do |
| session = |
| create_session([ |
| build(:event, name: "non-interactive", interactive?: false), |
| build(:pageview) |
| ]) |
|
|
| assert session.is_bounce |
| end |
|
|
| test "non-interactive custom event followed by interactive custom event is not a bounce" do |
| session = |
| create_session([ |
| build(:event, name: "non-interactive", interactive?: false), |
| build(:event, name: "interactive") |
| ]) |
|
|
| refute session.is_bounce |
| end |
| end |
|
|
| defp new_site_id() do |
| [[site_id]] = |
| Plausible.ClickhouseRepo.query!("select max(site_id) + rand() from sessions_v2 FINAL").rows |
|
|
| site_id |
| end |
|
|
| defp flush(events) do |
| for e <- events, do: CacheStore.on_event(e, %{}, nil) |
| Plausible.Session.WriteBuffer.flush() |
| end |
|
|
| defp create_session(events) do |
| event_params = %{site_id: new_site_id(), user_id: 1} |
|
|
| events |
| |> Enum.map(&Map.merge(&1, event_params)) |
| |> flush() |
|
|
| get_session(event_params.site_id, final: true) |
| end |
|
|
| defp get_session(site_id, opts \\ []) do |
| session_q = |
| from s in Plausible.ClickhouseSessionV2, |
| where: s.site_id == ^site_id, |
| order_by: [desc: :events], |
| limit: 1 |
|
|
| settings = [final: Keyword.get(opts, :final, true)] |
|
|
| Plausible.ClickhouseRepo.one!(session_q, settings: settings) |
| end |
| end |
|
|