defmodule Plausible.Session.CacheStore do @moduledoc """ Session management on the basis of incoming events. """ alias Plausible.Session.WriteBuffer @lock_timeout 1000 @lock_telemetry_event [:plausible, :sessions, :cache, :lock] def lock_telemetry_event, do: @lock_telemetry_event def on_event(event, session_attributes, prev_user_id, opts \\ []) do buffer_insert = Keyword.get(opts, :buffer_insert, &WriteBuffer.insert/1) skip_balancer? = Keyword.get(opts, :skip_balancer?, false) lock_requested_at = System.monotonic_time() try do response = Plausible.Session.Balancer.dispatch( event.user_id, fn -> lock_duration = System.monotonic_time() - lock_requested_at :telemetry.execute(@lock_telemetry_event, %{duration: lock_duration}, %{}) found_session = find_session(event, event.user_id) || find_session(event, prev_user_id) handle_event(event, found_session, session_attributes, buffer_insert) end, timeout: @lock_timeout, local?: skip_balancer? ) case response do {:error, e} -> raise e _ -> {:ok, response} end catch :exit, {:timeout, _} -> Sentry.capture_message("Timeout while handling session event") {:error, :timeout} end end defp handle_event(%{name: "engagement"} = event, found_session, _, _) do if found_session do # Make sure the session is kept active in the in-memory session cache refresh_session_cache(found_session, event.timestamp) found_session else :no_session_for_engagement end end defp handle_event(event, found_session, session_attributes, buffer_insert) do if found_session do updated_session = update_session(found_session, event) buffer_insert.([%{found_session | sign: -1}, %{updated_session | sign: 1}]) update_session_cache(updated_session) else new_session = new_session_from_event(event, session_attributes) buffer_insert.([new_session]) update_session_cache(new_session) end end defp find_session(_domain, nil), do: nil defp find_session(event, user_id) do from_cache = Plausible.Cache.Adapter.get(:sessions, {event.site_id, user_id}) case from_cache do nil -> nil session -> if NaiveDateTime.diff(event.timestamp, session.timestamp, :minute) <= 30 do session end end end defp update_session_cache(session) do key = {session.site_id, session.user_id} Plausible.Cache.Adapter.put(:sessions, key, session, dirty?: true) session end defp refresh_session_cache(session, timestamp) do session |> Map.put(:timestamp, timestamp) |> update_session_cache() end defp update_session(session, event) do pageview? = event.name == "pageview" pageviews = if(pageview?, do: session.pageviews + 1, else: session.pageviews) %{ session | timestamp: event.timestamp, entry_page: if(session.entry_page == "" and pageview?, do: event.pathname, else: session.entry_page ), hostname: if(pageview? and session.hostname == "", do: event.hostname, else: session.hostname ), exit_page: if(pageview?, do: event.pathname, else: session.exit_page), exit_page_hostname: if(pageview?, do: event.hostname, else: session.exit_page_hostname), is_bounce: if(session.is_bounce, do: not (pageviews >= 2 or (event.interactive? and not pageview?)), else: session.is_bounce ), duration: NaiveDateTime.diff(event.timestamp, session.start) |> abs, pageviews: pageviews, events: session.events + 1 } end defp new_session_from_event(event, session_attributes) do %Plausible.ClickhouseSessionV2{ sign: 1, session_id: Plausible.ClickhouseSessionV2.random_uint64(), hostname: if(event.name == "pageview", do: event.hostname, else: ""), site_id: event.site_id, user_id: event.user_id, entry_page: if(event.name == "pageview", do: event.pathname, else: ""), exit_page: if(event.name == "pageview", do: event.pathname, else: ""), exit_page_hostname: if(event.name == "pageview", do: event.hostname, else: ""), is_bounce: event.name == "pageview" or not event.interactive?, duration: 0, pageviews: if(event.name == "pageview", do: 1, else: 0), events: 1, referrer: Map.get(session_attributes, :referrer), click_id_param: Map.get(session_attributes, :click_id_param), referrer_source: Map.get(session_attributes, :referrer_source), utm_medium: Map.get(session_attributes, :utm_medium), utm_source: Map.get(session_attributes, :utm_source), utm_campaign: Map.get(session_attributes, :utm_campaign), utm_content: Map.get(session_attributes, :utm_content), utm_term: Map.get(session_attributes, :utm_term), country_code: Map.get(session_attributes, :country_code), subdivision1_code: Map.get(session_attributes, :subdivision1_code), subdivision2_code: Map.get(session_attributes, :subdivision2_code), city_geoname_id: Map.get(session_attributes, :city_geoname_id), screen_size: Map.get(session_attributes, :screen_size), operating_system: Map.get(session_attributes, :operating_system), operating_system_version: Map.get(session_attributes, :operating_system_version), browser: Map.get(session_attributes, :browser), browser_version: Map.get(session_attributes, :browser_version), timestamp: event.timestamp, start: event.timestamp, "entry_meta.key": Map.get(event, :"meta.key"), "entry_meta.value": Map.get(event, :"meta.value") } end end