feat(portal): Track page views and sign ups using Mixpanel and HubSpot on public pages (#5050)

Fixes firezone/gtm#253
Fixes firezone/gtm#278
This commit is contained in:
Andrew Dryga
2024-05-21 10:34:56 -06:00
committed by GitHub
parent 4e6bcef6e5
commit a7e54686b0
16 changed files with 202 additions and 43 deletions

View File

@@ -0,0 +1,15 @@
defmodule Domain.Analytics do
def get_mixpanel_token do
config!()
|> Keyword.get(:mixpanel_token)
end
def get_hubspot_workspace_id do
config!()
|> Keyword.get(:hubspot_workspace_id)
end
defp config! do
Application.fetch_env!(:domain, __MODULE__)
end
end

View File

@@ -125,6 +125,11 @@ defmodule Domain.Config.Definitions do
:telemetry_metrics_reporter_opts,
:logger_formatter,
:logger_formatter_opts
]},
{"Analytics",
[
:mixpanel_token,
:hubspot_workspace_id
]}
]
end
@@ -649,4 +654,18 @@ defmodule Domain.Config.Definitions do
Boolean flag to turn API Client UI functionality on/off for all accounts.
"""
defconfig(:feature_rest_api_enabled, :boolean, default: false)
##############################################
## Analytics
##############################################
@doc """
Mixpanel token to use for tracking analytics.
"""
defconfig(:mixpanel_token, :string, default: nil)
@doc """
HubSpot account ID to use for user tracking.
"""
defconfig(:hubspot_workspace_id, :string, default: nil)
end

View File

@@ -14,6 +14,39 @@ Hooks.Tabs = {
},
};
Hooks.Analytics = {
mounted() {
this.handleEvent("identify", ({ id, account_id, name, email }) => {
var mixpanel = window.mixpanel || null;
if (mixpanel) {
mixpanel.identify(id);
mixpanel.people.set({ $name: name, $email: email, account_id: account_id });
mixpanel.set_group("account", account_id);
}
var _hsq = window._hsq || null;
if (_hsq) {
_hsq.push(["identify", { id: id, email: email }]);
}
});
this.handleEvent("track_event", ({ name, properties }) => {
var mixpanel = window.mixpanel || null;
if (mixpanel) {
mixpanel.track(name, properties);
}
var _hsq = window._hsq || null;
if (_hsq) {
_hsq.push(["trackCustomBehavioralEvent", {
name: name,
properties: properties
}]);
}
});
}
}
Hooks.Refocus = {
mounted() {
this.el.addEventListener("click", (ev) => {

View File

@@ -156,6 +156,7 @@ defmodule Web do
import Web.FormComponents
import Web.TableComponents
import Web.PageComponents
import Web.AnalyticsComponents
import Web.Gettext
end
end

View File

@@ -0,0 +1,51 @@
defmodule Web.AnalyticsComponents do
@moduledoc """
The components that are responsible for embedding tracking codes into Firezone.
"""
use Phoenix.Component
alias Domain.Analytics
def trackers(assigns) do
assigns =
assigns
|> assign_new(:mixpanel_token, &Analytics.get_mixpanel_token/0)
|> assign_new(:hubspot_workspace_id, &Analytics.get_hubspot_workspace_id/0)
~H"""
<div id="analytics" class="hidden" phx-hook="Analytics">
<.mixpanel_tracker token={@mixpanel_token} />
<.hubspot_tracker hubspot_workspace_id={@hubspot_workspace_id} />
</div>
"""
end
def hubspot_tracker(assigns) do
~H"""
<script
:if={not is_nil(@hubspot_workspace_id)}
type="text/javascript"
id="hs-script-loader"
async
defer
src={"//js.hs-analytics.net/analytics/1716219600000/#{@hubspot_workspace_id}.js"}
>
</script>
<script :if={not is_nil(@hubspot_workspace_id)} type="text/javascript">
var _hsq = window._hsq = window._hsq || [];
_hsq.push(["setPath", window.location.pathname + window.location.search]);
</script>
"""
end
def mixpanel_tracker(assigns) do
~H"""
<script :if={not is_nil(@token)} type="text/javascript">
(function (f, b) { if (!b.__SV) { var e, g, i, h; window.mixpanel = b; b._i = []; b.init = function (e, f, c) { function g(a, d) { var b = d.split("."); 2 == b.length && ((a = a[b[0]]), (d = b[1])); a[d] = function () { a.push([d].concat(Array.prototype.slice.call(arguments, 0))); }; } var a = b; "undefined" !== typeof c ? (a = b[c] = []) : (c = "mixpanel"); a.people = a.people || []; a.toString = function (a) { var d = "mixpanel"; "mixpanel" !== c && (d += "." + c); a || (d += " (stub)"); return d; }; a.people.toString = function () { return a.toString(1) + ".people (stub)"; }; i = "disable time_event track track_pageview track_links track_forms track_with_groups add_group set_group remove_group register register_once alias unregister identify name_tag set_config reset opt_in_tracking opt_out_tracking has_opted_in_tracking has_opted_out_tracking clear_opt_in_out_tracking start_batch_senders people.set people.set_once people.unset people.increment people.append people.union people.track_charge people.clear_charges people.delete_user people.remove".split( " "); for (h = 0; h < i.length; h++) g(a, i[h]); var j = "set set_once union unset remove delete".split(" "); a.get_group = function () { function b(c) { d[c] = function () { call2_args = arguments; call2 = [c].concat(Array.prototype.slice.call(call2_args, 0)); a.push([e, call2]); }; } for ( var d = {}, e = ["get_group"].concat( Array.prototype.slice.call(arguments, 0)), c = 0; c < j.length; c++) b(j[c]); return d; }; b._i.push([e, f, c]); }; b.__SV = 1.2; e = f.createElement("script"); e.type = "text/javascript"; e.async = !0; e.src = "undefined" !== typeof MIXPANEL_CUSTOM_LIB_URL ? MIXPANEL_CUSTOM_LIB_URL : "file:" === f.location.protocol && "//cdn.mxpnl.com/libs/mixpanel-2-latest.min.js".match(/^\/\//) ? "https://cdn.mxpnl.com/libs/mixpanel-2-latest.min.js" : "//cdn.mxpnl.com/libs/mixpanel-2-latest.min.js"; g = f.getElementsByTagName("script")[0]; g.parentNode.insertBefore(e, g); } })(document, window.mixpanel || []);
</script>
<script :if={not is_nil(@token)} type="text/javascript">
mixpanel.init("<%= @token %>", {track_pageview: "url-with-path-and-query-string"});
mixpanel.set_config({debug: true});
</script>
"""
end
end

View File

@@ -527,7 +527,7 @@ defmodule Web.FormComponents do
"""
end
defp button_style do
def button_style do
[
"flex items-center justify-center",
"rounded",
@@ -535,7 +535,7 @@ defmodule Web.FormComponents do
]
end
defp button_style("warning") do
def button_style("warning") do
button_style() ++
[
"text-primary-500",
@@ -544,7 +544,7 @@ defmodule Web.FormComponents do
]
end
defp button_style("danger") do
def button_style("danger") do
button_style() ++
[
"text-red-600",
@@ -553,7 +553,7 @@ defmodule Web.FormComponents do
]
end
defp button_style("info") do
def button_style("info") do
button_style() ++
[
"text-neutral-900",
@@ -562,7 +562,7 @@ defmodule Web.FormComponents do
]
end
defp button_style(_style) do
def button_style(_style) do
button_style() ++
[
"text-white",
@@ -571,7 +571,7 @@ defmodule Web.FormComponents do
]
end
defp button_size(size) do
def button_size(size) do
text = %{
"xs" => "text-xs",
"sm" => "text-sm",

View File

@@ -1,3 +1,4 @@
<.trackers />
<main class="h-auto pt-16">
<%= @inner_content %>
</main>

View File

@@ -225,14 +225,13 @@ defmodule Web.SignIn do
def openid_connect_button(assigns) do
~H"""
<.button
navigate={~p"/#{@account}/sign_in/providers/#{@provider}/redirect?#{@params}"}
class="w-full space-x-1"
style="info"
<a
class={[button_style("info"), button_size("md"), "w-full space-x-1"]}
href={~p"/#{@account}/sign_in/providers/#{@provider}/redirect?#{@params}"}
>
<.provider_icon adapter={@provider.adapter} class="w-5 h-5 mr-2" /> Sign in with
<strong><%= @provider.name %></strong>
</.button>
</a>
"""
end

View File

@@ -327,7 +327,7 @@ defmodule Web.SignUp do
registration = Ecto.Changeset.apply_changes(changeset)
case register_account(registration) do
{:ok, %{account: account, provider: provider, identity: identity}} ->
{:ok, %{account: account, provider: provider, identity: identity, actor: actor}} ->
{:ok, account} = Domain.Billing.provision_account(account)
{:ok, _} =
@@ -339,7 +339,30 @@ defmodule Web.SignUp do
)
|> Web.Mailer.deliver()
socket = assign(socket, account: account, provider: provider, identity: identity)
socket =
assign(socket,
account: account,
provider: provider,
identity: identity
)
socket =
push_event(socket, "identify", %{
id: actor.id,
account_id: account.id,
name: actor.name,
email: identity.provider_identifier
})
socket =
push_event(socket, "track_event", %{
name: "Sign Up",
properties: %{
account_id: account.id,
identity_id: identity.id
}
})
{:noreply, socket}
{:error, :account, err_changeset, _effects_so_far} ->

View File

@@ -2,16 +2,12 @@ defmodule Web.Router do
use Web, :router
import Web.Auth
pipeline :browser do
plug :accepts, ["html"]
pipeline :public do
plug :accepts, ["html", "xml"]
plug :fetch_session
plug :protect_from_forgery
plug :fetch_live_flash
plug :put_root_layout, {Web.Layouts, :root}
end
pipeline :public do
plug :accepts, ["html", "xml"]
plug :put_root_layout, html: {Web.Layouts, :root}
end
pipeline :account do
@@ -19,12 +15,12 @@ defmodule Web.Router do
plug :fetch_subject
end
pipeline :home do
plug :accepts, ["html", "xml"]
pipeline :control_plane do
plug :accepts, ["html"]
plug :fetch_session
plug :protect_from_forgery
plug :fetch_live_flash
plug :put_root_layout, {Web.Layouts, :root}
plug :put_root_layout, html: {Web.Layouts, :root}
end
pipeline :ensure_authenticated_admin do
@@ -39,7 +35,7 @@ defmodule Web.Router do
end
scope "/", Web do
pipe_through :home
pipe_through :public
get "/", HomeController, :home
post "/", HomeController, :redirect_to_sign_in
@@ -65,13 +61,13 @@ defmodule Web.Router do
end
scope "/sign_up", Web do
pipe_through :browser
pipe_through :public
live "/", SignUp
end
scope "/:account_id_or_slug", Web do
pipe_through [:browser, :account, :redirect_if_user_is_authenticated]
pipe_through [:public, :account, :redirect_if_user_is_authenticated]
live_session :redirect_if_user_is_authenticated,
on_mount: [
@@ -87,7 +83,7 @@ defmodule Web.Router do
end
scope "/:account_id_or_slug", Web do
pipe_through [:browser, :account]
pipe_through [:control_plane, :account]
get "/sign_in/client_redirect", SignInController, :client_redirect
get "/sign_in/client_auth_error", SignInController, :client_auth_error
@@ -107,13 +103,13 @@ defmodule Web.Router do
end
scope "/:account_id_or_slug", Web do
pipe_through [:browser, :account]
pipe_through [:control_plane, :account]
get "/sign_out", AuthController, :sign_out
end
scope "/:account_id_or_slug", Web do
pipe_through [:browser, :account, :ensure_authenticated_admin]
pipe_through [:control_plane, :account, :ensure_authenticated_admin]
live_session :ensure_authenticated,
on_mount: [

View File

@@ -39,6 +39,10 @@ config :domain, Domain.Gateways,
config :domain, Domain.Telemetry, metrics_reporter: nil
config :domain, Domain.Analytics,
mixpanel_token: nil,
hubspot_workspace_id: nil
config :domain, Domain.Auth.Adapters.GoogleWorkspace.APIClient,
endpoint: "https://admin.googleapis.com",
finch_transport_opts: []
@@ -133,11 +137,10 @@ config :web,
config :web, Web.Plugs.SecureHeaders,
csp_policy: [
"default-src 'self' 'nonce-${nonce}'",
"frame-src 'self' https://js.stripe.com",
"script-src 'self' https://js.stripe.com",
"img-src 'self' data: https://www.gravatar.com",
"style-src 'self' 'unsafe-inline'"
"default-src 'self' 'nonce-${nonce}' https://api-js.mixpanel.com",
"img-src 'self' data: https://www.gravatar.com https://track.hubspot.com",
"style-src 'self' 'unsafe-inline'",
"script-src 'self' 'unsafe-inline' https://cdn.mxpnl.com https://*.hs-analytics.net"
]
config :web, api_url_override: "ws://localhost:13001/"

View File

@@ -64,11 +64,10 @@ config :phoenix_live_reload, :dirs, [
config :web, Web.Plugs.SecureHeaders,
csp_policy: [
"default-src 'self' 'nonce-${nonce}' https://cdn.tailwindcss.com/",
"img-src 'self' data: https://www.gravatar.com",
"default-src 'self' 'nonce-${nonce}' https://api-js.mixpanel.com",
"img-src 'self' data: https://www.gravatar.com https://track.hubspot.com",
"style-src 'self' 'unsafe-inline'",
"frame-src 'self' https://js.stripe.com",
"script-src 'self' 'unsafe-inline' https://js.stripe.com https://cdn.tailwindcss.com/"
"script-src 'self' 'unsafe-inline' http://cdn.mxpnl.com http://*.hs-analytics.net"
]
# Note: on Linux you may need to add `--add-host=host.docker.internal:host-gateway`

View File

@@ -53,6 +53,10 @@ if config_env() == :prod do
client_logs_enabled: compile_config!(:instrumentation_client_logs_enabled),
client_logs_bucket: compile_config!(:instrumentation_client_logs_bucket)
config :domain, Domain.Analytics,
mixpanel_token: compile_config!(:mixpanel_token),
hubspot_workspace_id: compile_config!(:hubspot_workspace_id)
config :domain, :enabled_features,
idp_sync: compile_config!(:feature_idp_sync_enabled),
sign_up: compile_config!(:feature_sign_up_enabled),

View File

@@ -39,11 +39,10 @@ config :web, Web.Endpoint,
config :web, Web.Plugs.SecureHeaders,
csp_policy: [
"default-src 'self' 'nonce-${nonce}' https://cdn.tailwindcss.com/",
"img-src 'self' data: https://www.gravatar.com",
"default-src 'self' 'nonce-${nonce}' https://api-js.mixpanel.com",
"img-src 'self' data: https://www.gravatar.com https://track.hubspot.com",
"style-src 'self' 'unsafe-inline'",
"frame-src 'self' https://js.stripe.com",
"script-src 'self' 'unsafe-inline' https://js.stripe.com https://cdn.tailwindcss.com/"
"script-src 'self' 'unsafe-inline' https://cdn.mxpnl.com https://*.hs-analytics.net"
]
###############################

View File

@@ -313,6 +313,16 @@ locals {
name = "INSTRUMENTATION_CLIENT_LOGS_BUCKET"
value = google_storage_bucket.client-logs.name
},
# Analytics
{
name = "MIXPANEL_TOKEN"
# Note: this token is public
value = "b0ab1d66424a27555ed45a27a4fd0cd2"
},
{
name = "HUBSPOT_WORKSPACE_ID"
value = "23723443"
},
# Emails
{
name = "OUTBOUND_EMAIL_ADAPTER"
@@ -485,7 +495,7 @@ module "web" {
{
name = "BACKGROUND_JOBS_ENABLED"
value = "false"
},
}
], local.shared_application_environment_variables)
application_labels = {

View File

@@ -296,6 +296,12 @@ locals {
name = "INSTRUMENTATION_CLIENT_LOGS_BUCKET"
value = google_storage_bucket.client-logs.name
},
# Analytics
{
name = "MIXPANEL_TOKEN"
# Note: this token is public
value = "313bdddc66b911f4afeb2c3242a78113"
},
# Emails
{
name = "OUTBOUND_EMAIL_ADAPTER"