Phoenix Telemetry module for TelemetryDeck

Phoenix Telemetry module for TelemetryDeck

The Telemetry library is a popular option to publish metrics and events in Elixir applications. Many libraries (including Phoenix) are already using the :telemetry package as a way to give users more insight into the behaviour of their applications and bring observability at key moments in the application lifecycle.

In this post I will show you how to implement server-side tracking for your Phoenix web app by reporting anonymous page visits to TelemetryDeck using the :telemetry package.

Telemetry in Phoenix

New Phoenix application projects are generated with the addition of a Telemetry supervisor. It is responsible for managing the lifecycle of Telemetry processes, setting up a telemetry poller, collecting measurements and dispatching telemetry events. If you're new to Phoenix or the Telemetry library, I suggest you check out the excellent getting started guide from the docs.

Telemetry Events

A Telemetry event consists of the 3 parts: A name, a map of measurements and a map of metadata.

Looking at the list of events being published by Phoenix, we will notice [:phoenix, :router_dispatch, :start] which is published by the router module whenever a route has been matched (e.g. someone visiting a page or navigating within a LiveView session).

The metadata of the event includes the Plug.Conn connection where we can find details about the request like request_path and any path or query string parameters which may be interesting to record. With this, we can imagine a handler that captures this information, so it can be reported to TelemetryDeck:

def handle_event(
      [:phoenix, :router_dispatch, :start],
      _,
      %{conn: %{request_path: request_path} = _conn},
      _
    ) do
  report_value(request_path)
end

TelemetryDeck

TelemetryDeck is my preferred analytics service which helps me gather anonymous insights into how my apps are being used. They have a fantastic dashboard where one can get a very detailed view of app usage by creating custom visualizations, funnels, and others.

To report route visits, we will take advantage of TelemetryDeck's REST API which allows us to submit signals on demand.

The function to report a value will need to create the body structure of the ingest endpoint including the appID, sessionID, clientUser, type, and payload.

The module we're building is anonymous so for now we will generate and reuse a random UUID for the session and user identifiers.

@td_url "https://nom.telemetrydeck.com/v1/"

def report_value(path, key) do
    payload = [
      %{
        appID: get_app_id(),
        sessionID: key,
        type: "visit",
        payload: ["url:#{path}"],
        clientUser: key
      }
    ]

    %{status: 200} = Req.post!(@td_url, json: payload)
  end

The payload is a list of signals in the key:value format expected by TelemetryDeck. In this case, we're submitting a signal type called visit with metadata about the current url path e.g. / for the homepage or /docs/introduction for a deep path within the application.

The get_app_id/0  function here reads the TelemetryDeck app key from the environment configuration:

def get_app_id do
  Application.get_env(:<your app>, :config_key)
end

To submit the request, we can use the Req library, for example.

If we put it all together, we end up with the following reporter module:

defmodule MyApp.Telemetry.TelemetryDeck do
  @moduledoc """
  A TelemetryDeck reporter for Phoenix
  """

  @td_url "https://nom.telemetrydeck.com/v1/"

  def handle_event(
        [:phoenix, :router_dispatch, :start],
        _,
        %{conn: %{request_path: request_path} = _conn},
        _
      ) do
    report_value(request_path)
  end

  def report_value(path, key) do
    payload = [
      %{
        appID: get_app_id(),
        sessionID: key,
        type: "visit",
        payload: ["url:#{path}"],
        clientUser: key
      }
    ]

    %{status: 200} = Req.post!(@td_url, json: payload)
  end

  def report_value(path) do
    report_value(path, UUID.uuid4())
  end

  def get_app_id do
    Application.get_env(:<your app>, :config_key)
  end
end

Attaching the handler

The last step to make this work is to tell Phoenix to use our new telemetry module.

To do this, we can attach the reporter at the application start, for example adding the following in start/2 in application.ex:

:telemetry.attach(
  "telemetry-route-visit",
  [:phoenix, :router_dispatch, :start],
  &MyApp.Telemetry.TelemetryDeck.handle_event/4,
  nil
)

That's all! ūüéČ

You should now be able to see your signals arriving in the TelemetryDeck dashboards. As a next step, you may want to also include test mode support (so events your app is sending during development are separate from production data).

In addition, you can also explore ways to detect events that belong to the same session. For example, since Phoenix telemetry events include the Plug.Conn.t, we could try to pattern match the current session identifier and use it to create an anonymous session identifier for TelemetryDeck:

def handle_event(
      [:phoenix, :router_dispatch, :start],
      _,
      %{conn: %{request_path: request_path, req_cookies: %{"_myapp_key" => key}} = _conn},
      _
    ) do
  session_key = :erlang.phash2(key) |> Integer.to_string()

  report_value(request_path, session_key)
end
Konstantin

Konstantin

Melle, Belgium