Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
Callum committed Jun 19, 2019
0 parents commit c01089b
Show file tree
Hide file tree
Showing 10 changed files with 612 additions and 0 deletions.
4 changes: 4 additions & 0 deletions .formatter.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
24 changes: 24 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# The directory Mix will write compiled artifacts to.
/_build/

# If you run "mix test --cover", coverage assets end up here.
/cover/

# The directory Mix downloads your dependencies sources to.
/deps/

# Where third-party dependencies like ExDoc output generated docs.
/doc/

# Ignore .fetch files in case you like to edit your project deps locally.
/.fetch

# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump

# Also ignore archive artifacts (built via "mix archive.build").
*.ez

# Ignore package tarball (built via "mix hex.build").
mixpanel_plug-*.tar

88 changes: 88 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# MixpanelPlug

A plug-based approach to Mixpanel tracking with Elixir. Use MixpanelPlug to:

- Track events with useful context like referrer, user agent information, and UTM properties
- Keep user profiles up to date on every request

MixpanelPlug respects the ‘Do Not Track’ request header. When this is set, no tracking calls will be made.

## Installation

If [available in Hex](https://hex.pm/docs/publish), the package can be installed
by adding `mixpanel_plug` to your list of dependencies in `mix.exs`:

```elixir
def deps do
[
{:mixpanel_plug, "~> 0.1.0"}
]
end
```

Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
be found at [https://hexdocs.pm/mixpanel_plug](https://hexdocs.pm/mixpanel_plug).

## Configuration

Add configuration for `mixpanel_api_ex` to your `config/config.exs` file:

```elixir
config :mixpanel_api_ex, config: [token: "your_mixpanel_token"]
```

## Usage

In a Phoenix application, register the MixpanelPlug plug in `router.ex`:

```diff
defmodule Example.Router do
use Example, :router

pipeline :browser do
plug :accepts, ["html", "json"]
plug :fetch_session
plug :fetch_flash
plug :protect_from_forgery
plug :put_secure_browser_headers
+ plug MixpanelPlug
end
end
```

If the ‘Do Not Track’ (`dnt`) has been set to `1`, the property `do_not_track: true` will be assigned to the connection. Additionally, a call to `MixpanelPlug.update_profile/2` will be made with the value of `current_user` from the connection, if ‘Do Not Track’ is not set. For more information, please see the module documentation.

For making tracking calls, use `MixpanelPlug.track_event`:

```elixir
defmodule Example.UserController do
use Example, :controller

import MixpanelPlug, only: [track_event: 3]

def create(conn, %{"email" => email}) do
conn
|> track_event("Example User Created", %{"email" => email})
|> render("user_created.html")
end
end
```

The properties added to the tracking call include the following, where appropriate:

```elixir
%{
"email" => "[email protected]",
"Current Path" => "/users",
"$browser" => "Mobile Safari",
"$browser_version" => "10.0",
"$device" => "iPhone",
"$os" => "iOS 10.3.1",
"utm_campaign" => "campaign",
"utm_content" => "content",
"utm_medium" => "medium",
"utm_source" => "source",
"utm_term" => "term"
}
```
31 changes: 31 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# This file is responsible for configuring your application
# and its dependencies with the aid of the Mix.Config module.
use Mix.Config

# This configuration is loaded before any dependency and is restricted
# to this project. If another project depends on this project, this
# file won't be loaded nor affect the parent project. For this reason,
# if you want to provide default values for your application for
# third-party users, it should be done in your "mix.exs" file.

# You can configure your application as:
#
# config :mixpanel_plug, key: :value
#
# and access this configuration in your application as:
#
# Application.get_env(:mixpanel_plug, :key)
#
# You can also configure a third-party app:
#
# config :logger, level: :info
#

# It is also possible to import configuration files, relative to this
# directory. For example, you can emulate configuration per environment
# by uncommenting the line below and defining dev.exs, test.exs and such.
# Configuration from the imported file will override the ones defined
# here (which is why it is important to import them last).
#

if Mix.env() === :test, do: import_config("test.exs")
3 changes: 3 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
use Mix.Config

config :mixpanel_api_ex, config: [token: "foobar", active: false]
206 changes: 206 additions & 0 deletions lib/mixpanel_plug.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
defmodule MixpanelPlug do
@moduledoc """
Mixpanel tracking as a plug.
- Track events with useful context like referrer, user agent information, and UTM properties
- Keep user profiles up to date on every request
- Respects ‘Do Not Track’ request headers
"""

import Plug.Conn

@type user :: %{id: number | String.t(), name: String.t(), email: String.t()}

@spec init(Plug.opts()) :: Plug.opts()
def init(opts), do: opts

@doc """
Checks for the ‘Do Not Track’ request header and if it exists, assigns its
value to the connection. In the case that tracking is permitted,
`update_profile` will be called with the value of an assign named
`current_user` from the connection. The value of `current_user` must pattern
match the type `t:user/0`. If this is not matched, the user’s profile will not
be updated.
"""
@spec call(Plug.Conn.t(%{assigns: %{current_user: user()}}), Plug.opts()) :: Plug.Conn.t()
def call(%{assigns: %{current_user: current_user}} = conn, _opts) do
conn
|> put_do_not_track()
|> update_profile(current_user)
end

def call(conn, _opts), do: put_do_not_track(conn)

@doc """
Checks whether the 'Do Not Track' header has been set on the connection
## Examples
iex> MixpanelPlug.tracking_disabled?(conn)
true
"""
@spec tracking_disabled?(Plug.Conn.t()) :: boolean
def tracking_disabled?(conn) do
conn
|> get_req_header("dnt")
|> Enum.member?("1")
end

@doc """
Updates a user profile in Mixpanel. This method pattern matches the value of
the `user` struct against the type `t:user/0`. If this is not matched, the
user’s profile will not be updated.
This is a noop if the ‘Do Not Track’ header is set.
## Examples
MixpanelPlug.update_profile(conn, %{id: 1, name: "Callum", email: "[email protected]"})
"""
@spec update_profile(Plug.Conn.t(), user :: user()) :: Plug.Conn.t()
def update_profile(%{assigns: %{do_not_track: true}} = conn, _user), do: conn

def update_profile(conn, %{id: id, name: name, email: email} = _user) do
properties = %{
"$name" => name,
"$email" => email,
"ID" => id
}

conn
|> engage(id, "$set", properties)
|> put_analytics(:profile, properties)
end

def update_profile(conn, _user), do: conn

@doc """
Tracks an event in Mixpanel.
This is a noop if the ‘Do Not Track’ header is set.
## Examples
MixpanelPlug.track(conn, "Added To Wishlist")
MixpanelPlug.track(conn, "Discount Code Used", %{"Value" => "10"})
"""
@spec track(Plug.Conn.t(), event :: String.t(), properties :: struct) :: Plug.Conn.t()
def track(conn, event, properties \\ %{})

def track(%{assigns: %{do_not_track: true}} = conn, _event, _properties), do: conn

def track(conn, event, properties) do
properties = get_properties(conn, properties)
opts = get_opts(conn)

Mixpanel.track(event, properties, opts)

conn
|> update_analytics(:tracked_events, &[{event, properties, opts} | &1 || []])
end

defp engage(conn, distinct_id, operation, properties) do
Mixpanel.engage(distinct_id, operation, properties, ip: conn.remote_ip)
conn
end

defp get_properties(conn, properties) do
properties
|> put_page_properties(conn)
|> put_referrer_properties(conn)
|> put_user_agent_properties(conn)
|> put_utm_properties(conn)
end

defp put_do_not_track(conn) do
if tracking_disabled?(conn) do
assign(conn, :do_not_track, true)
else
conn
end
end

defp put_page_properties(properties, conn) do
Map.put(properties, "Current Path", conn.request_path)
end

defp put_referrer_properties(properties, conn) do
case List.first(get_req_header(conn, "referer")) do
nil ->
properties

referrer ->
properties
|> Map.put_new("$referrer", referrer)
|> Map.put_new("$referring_domain", URI.parse(referrer).host)
end
end

defp put_user_agent_properties(properties, conn) do
with ua <- get_req_header(conn, "user-agent"),
ua when is_binary(ua) <- List.first(ua),
ua <- UAParser.parse(ua) do
properties
|> Map.put_new("$os", to_string(ua.os))
|> Map.put_new("$browser", to_string(ua.family))
|> Map.put_new("$browser_version", to_string(ua.version))
|> Map.put_new(
"$device",
case ua.device do
%{family: nil} -> nil
device -> to_string(device)
end
)
else
_ -> properties
end
end

defp put_utm_properties(properties, %{query_params: query_params}) do
properties
|> put_utm_property(query_params, "utm_source")
|> put_utm_property(query_params, "utm_medium")
|> put_utm_property(query_params, "utm_campaign")
|> put_utm_property(query_params, "utm_content")
|> put_utm_property(query_params, "utm_term")
end

defp put_utm_property(properties, query_params, property_key) do
case Map.get(query_params, property_key) do
nil ->
properties

property_value ->
Map.put_new(properties, property_key, property_value)
end
end

defp get_opts(%{assigns: %{current_user: %{id: id}}} = conn) do
[ip: conn.remote_ip, distinct_id: id]
end

defp get_opts(conn) do
[ip: conn.remote_ip]
end

defp put_analytics(%{assigns: assigns} = conn, key, value) do
assigns =
assigns
|> Map.put_new(:analytics, %{})
|> put_in([:analytics, key], value)

%{conn | assigns: assigns}
end

defp update_analytics(%{assigns: assigns} = conn, key, fun) do
assigns =
assigns
|> Map.put_new(:analytics, %{})
|> update_in([:analytics, key], fun)

%{conn | assigns: assigns}
end
end
Loading

0 comments on commit c01089b

Please sign in to comment.