Tracking inactivity in a Phoenix LiveView app

We’re trying to expire a user’s login session after a certain amount of time with no activity from the user. I’m planning to reset an inactivity timer every time handle_event/3 is called. However, I don’t want to have to do this within all handle_event/3 function clauses in all of our LiveViews.

From the LiveView guides, I can see that

it is possible to identify all LiveView sockets by setting a “live_socket_id” in the session.

Is there a way to subscribe to all of their handle_events, or maybe hook into all the handle_event/3 calls?

1 Like

I’m not sure what you can do with it, but it looks like handle_event invokes telemetry:

It’s going to invoke any telemetry handlers inline, which means it will be executed in the process of the LiveView. You can’t modify state from the telemetry handler, but you could send self a message to update some internal state (or update your external state if you’re tracking outside of the LiveView process).

Few things to be careful of:

  • The cost for updating your tracked state should be cheap, because it’s going to block the LiveView process from doing work
  • Telemetry handlers will detach if there’s an exception. If this happened, you would need to re-attach or all of your users would appear as inactive
2 Likes

Very interesting! It feels weird having to do this using telemetry and I’m still hoping there’s another way. But I’ll sure look into it. Thanks for the detailed response! :heart:

1 Like

Definitely don’t disagree with you. It feels a bit odd to do something like this with telemetry—although I’m still working out my feelings on whether telemetry should be used to extend library behavior in a simple way.

I’ll give my second thought here. I ran into a scenario where I wanted my LiveViews to all mount with the same authentication function, where I make sure that there’s a valid user and also do things such as handle initial user-flow and onboarding redirections. Since every LiveView in my system needs the same functionality, I wanted to do it in a way that would be impossible to forget.

I extended the use MyAppWeb, :live macro to include a macro that defines mount/3 with the authentication function in it. I call auth_mount/3 in the mount/3 function if everything is looking good and the LiveView should proceed, or I handle the error without ever mounting the LiveView.

I think you could do something similar here where handle_event is defined in a macro and it is proxied to something_handle_event. Or another riff on this would be to define handle_event_bump_session as a macro that builds up handle_event/3 with your session code.

Both approaches get into some light macro definition, but is the way I was thinking about solving this originally. The solutions aren’t easily composable though, which I dislike.

2 Likes

I find myself doing something very similar to what @sb8244 is describing.

In all of my LV applications so far, I create a generic LV template like:

defmodule AppWeb.LiveTemplate do
  defmacro __using__(_opts) do
    quote do
      use AppWeb, :live_view

      alias AppWeb.Router.Helpers, as: Routes
      alias App.{Contexts..}
      require Logger

      def handle_event(
            "user_inactive",
            target,
            socket
          ) do
          # Do Something
          {:noreply, socket}
        end
      end
    end
  end
end

You can then use this in your live views:

defmodule AppWeb.LivePage do
  use AppWeb.LiveTemplate

  def mount() do
  # etc...
  end
end

I’ve found this pattern to be super useful when defining events that all my live views need to respond to or like @sb8244 said, creating common authentication functionality.

I wouldn’t be surprised if there’s a better way to do this though. One thing that confuses me is having to set up the Routes alias again inside this macro even though it’s also in AppWeb.

Instead of using handle_event/3 to track user activity, we’ve ended up using the mousemove, scroll, keydown and resize DOM events as described in this CSS-Tricks article.

We used a LiveComponent, with a phx-hook, that is rendered within the live.html.leex layout template so that all live views are tracked.

lib/foo_web/templates/layout/live.html.leex

<%= live_component @socket, FooWeb.ActivityTracker, id: :activity_tracker, login_id: @login.id %>

lib/foo_web/live/activity_tracker.ex

defmodule FooWeb.ActivityTracker do
  use FooWeb, :live_component

  def render(assigns) do
    ~L"""
    <div phx-hook="ActivityTracker"></div>
    """
  end

  def handle_event("activity", _params, socket) do
    Foo.InactiveLoginExpirer.track_activity(socket.assigns.login_id)
    {:noreply, socket}
  end
end

assets/js/app.js

Hooks.ActivityTracker = {
  mounted() {
    let pushActivity = (() => {
      let throttled = false;
      const delay = 10 * 60 * 1000;

      return () => {
        if (!throttled) {
          this.pushEventTo("#" + this.el.id, "activity");
          throttled = true;

          setTimeout(() => {
            throttled = false;
          }, delay);
        }
      };
    })();

    pushActivity();
    window.addEventListener("mousemove", pushActivity);
    window.addEventListener("scroll", pushActivity);
    window.addEventListener("keydown", pushActivity);
    window.addEventListener("resize", pushActivity);
  },
};
4 Likes

Welcome to the community @davidmeh!

I considered defining a handle_event/3 like that in the FooWeb.live_view macro but we ended up not needing it after using a stateful LiveComponent since the events will all be handled by the live component.

I’ll be keeping that pattern in mind as it may come in handy in the future. Thanks!

One minor thing you may want to look at is ensuring that you are cleaning up the window listening on hook destroy callback. Otherwise, you will leak memory of the hook and possibly the element.

Thanks for the tip!