Current section
Files
Jump to
Current section
Files
lib/phoenix_kit/modules/emails/metrics.ex
defmodule PhoenixKit.Modules.Emails.Metrics do
@moduledoc """
Local metrics and analytics for PhoenixKit email tracking.
This module provides comprehensive metrics collection and analysis capabilities
for email performance, deliverability, and engagement tracking using the local database.
## Features
- **Engagement Analysis**: Open rates, click rates, and engagement trends
- **Geographic Analytics**: Performance by region and country
- **Provider Analysis**: Deliverability by email provider (Gmail, Outlook, etc.)
- **Campaign Performance**: Top performing campaigns and templates
- **Real-time Dashboards**: Data for live monitoring dashboards
- **Time Series Data**: Historical trends and patterns
## Usage Examples
# Get engagement metrics
engagement = PhoenixKit.Modules.Emails.Metrics.get_engagement_metrics(:last_7_days)
# Get geographic distribution
geo = PhoenixKit.Modules.Emails.Metrics.get_geographic_metrics(:last_30_days)
# Get dashboard data
dashboard = PhoenixKit.Modules.Emails.Metrics.get_dashboard_data(:last_30_days)
"""
require Logger
alias PhoenixKit.Modules.Emails
alias PhoenixKit.Modules.Emails.Event
alias PhoenixKit.Modules.Emails.Log
alias PhoenixKit.Utils.Date, as: UtilsDate
# Get the configured repo
defp repo do
PhoenixKit.RepoHelper.repo()
end
@doc """
Gets engagement metrics with trend analysis.
## Examples
iex> PhoenixKit.Modules.Emails.Metrics.get_engagement_metrics(:last_7_days)
%{
open_rate: 24.5,
click_rate: 4.2,
engagement_score: 28.7,
trend: :improving,
daily_breakdown: [...]
}
"""
def get_engagement_metrics(period \\ :last_7_days) do
# Get engagement data from local database
local_data = get_local_engagement_data(period)
# Add trend analysis
Map.put(local_data, :trend, calculate_engagement_trend(local_data))
end
@doc """
Gets geographic distribution of email engagement.
## Examples
iex> PhoenixKit.Modules.Emails.Metrics.get_geographic_metrics("open", :last_30_days)
%{
"US" => %{count: 500, percentage: 45.5},
"CA" => %{count: 200, percentage: 18.2},
"UK" => %{count: 150, percentage: 13.6}
}
"""
def get_geographic_metrics(event_type, period \\ :last_30_days) do
{start_time, end_time} = get_time_range(period)
# Get geo data from local events database
geo_data = Event.get_geo_distribution(event_type, start_time, end_time)
total_count = Enum.reduce(geo_data, 0, fn {_country, count}, acc -> acc + count end)
# Add percentages
geo_data
|> Enum.into(%{}, fn {country, count} ->
percentage =
if total_count > 0, do: (count / total_count * 100) |> Float.round(1), else: 0.0
{country, %{count: count, percentage: percentage}}
end)
end
## --- Dashboard Data ---
@doc """
Gets comprehensive dashboard data combining multiple metric sources.
Returns data optimized for dashboard visualization with time series,
percentages, trends, and alerts.
## Examples
iex> PhoenixKit.Modules.Emails.Metrics.get_dashboard_data(:last_7_days)
%{
overview: %{
total_sent: 5000,
delivery_rate: 98.2,
bounce_rate: 1.8,
open_rate: 24.5,
click_rate: 4.2
},
time_series: [...],
alerts: [...],
top_performers: [...]
}
"""
def get_dashboard_data(period \\ :last_7_days) do
# Get overview metrics
overview_task = Task.async(fn -> get_overview_metrics(period) end)
# Get time series data
time_series_task = Task.async(fn -> get_time_series_data(period) end)
# Get geographic data
geo_task = Task.async(fn -> get_geographic_metrics("open", period) end)
# Get alerts and issues
alerts_task = Task.async(fn -> get_metric_alerts(period) end)
# Get top performing campaigns/templates
top_performers_task = Task.async(fn -> get_top_performers(period) end)
# Get provider performance
provider_task = Task.async(fn -> get_provider_performance(period) end)
# Wait for all results
[overview, time_series, geographic, alerts, top_performers, provider_performance] =
Task.await_many(
[
overview_task,
time_series_task,
geo_task,
alerts_task,
top_performers_task,
provider_task
],
30_000
)
%{
overview: overview,
time_series: time_series,
geographic: geographic,
alerts: alerts,
top_performers: top_performers,
provider_performance: provider_performance,
generated_at: UtilsDate.utc_now()
}
end
## --- Alerting ---
@doc """
Checks metrics against thresholds and returns alerts.
## Examples
iex> PhoenixKit.Modules.Emails.Metrics.get_metric_alerts(:last_24_hours)
[
%{type: :high_bounce_rate, severity: :warning, value: 5.2, threshold: 5.0},
%{type: :low_open_rate, severity: :info, value: 15.1, threshold: 20.0}
]
"""
def get_metric_alerts(period \\ :last_24_hours) do
# Get metrics from local database
stats = Emails.get_system_stats(period)
alerts = []
# Check for high bounce rate
alerts =
if stats.bounce_rate > 5.0 do
[
%{
type: :high_bounce_rate,
severity: :warning,
value: stats.bounce_rate,
threshold: 5.0,
message: "Bounce rate exceeds recommended threshold"
}
| alerts
]
else
alerts
end
# Check for low delivery rate
alerts =
if stats.delivery_rate < 95.0 do
[
%{
type: :low_delivery_rate,
severity: :warning,
value: stats.delivery_rate,
threshold: 95.0,
message: "Delivery rate below recommended threshold"
}
| alerts
]
else
alerts
end
alerts
end
## --- Private Helper Functions ---
# Get time range for period
defp get_time_range(period) do
end_time = UtilsDate.utc_now()
start_time =
case period do
:last_hour -> DateTime.add(end_time, -1, :hour)
:last_24_hours -> DateTime.add(end_time, -1, :day)
:last_7_days -> DateTime.add(end_time, -7, :day)
:last_30_days -> DateTime.add(end_time, -30, :day)
:last_90_days -> DateTime.add(end_time, -90, :day)
end
{start_time, end_time}
end
# Calculate percentage safely
defp calculate_percentage(numerator, denominator) when denominator > 0 do
(numerator / denominator * 100) |> Float.round(1)
end
defp calculate_percentage(_, _), do: 0.0
# Get local engagement data from database
defp get_local_engagement_data(period) do
{_start_time, _end_time} = get_time_range(period)
Log.get_engagement_metrics(period)
end
# Calculate engagement trend
defp calculate_engagement_trend(%{daily_stats: daily_stats})
when is_list(daily_stats) and length(daily_stats) > 3 do
# Simple trend calculation
recent_avg = daily_stats |> Enum.take(-3) |> calculate_avg_engagement()
earlier_avg = daily_stats |> Enum.take(3) |> calculate_avg_engagement()
cond do
recent_avg > earlier_avg + 2 -> :improving
recent_avg < earlier_avg - 2 -> :declining
true -> :stable
end
end
defp calculate_engagement_trend(_), do: :stable
# Calculate average engagement from daily stats
defp calculate_avg_engagement(daily_stats) do
if Enum.empty?(daily_stats) do
0.0
else
total_opened = Enum.sum(Enum.map(daily_stats, & &1.opened))
total_delivered = Enum.sum(Enum.map(daily_stats, & &1.delivered))
calculate_percentage(total_opened, total_delivered)
end
end
# Get overview metrics
defp get_overview_metrics(period) do
Emails.get_system_stats(period)
end
# Get time series data for charts
defp get_time_series_data(period) do
# Use the existing daily delivery trends function from Log module
trends = Log.get_daily_delivery_trends(period)
# Transform the data into chart-compatible format
Enum.zip([trends.labels, trends.delivered, trends.bounced, trends.total_sent])
|> Enum.map(fn {date, delivered, bounced, total} ->
%{
date: date,
sent: total,
delivered: delivered,
bounced: bounced,
# Calculate rates
delivery_rate: if(total > 0, do: Float.round(delivered / total * 100, 2), else: 0),
bounce_rate: if(total > 0, do: Float.round(bounced / total * 100, 2), else: 0)
}
end)
end
# Get top performing campaigns/templates
defp get_top_performers(period) do
{start_date, end_date} = get_time_range(period)
# Get top campaigns by engagement score
top_campaigns = get_top_campaigns(start_date, end_date, 10)
# Get top templates by usage and performance
top_templates = get_top_templates(start_date, end_date, 10)
%{
campaigns: top_campaigns,
templates: top_templates
}
end
defp get_top_campaigns(start_date, end_date, limit) do
import Ecto.Query
# Query for campaigns with calculated engagement metrics
query =
from(l in Log,
where: l.sent_at >= ^start_date and l.sent_at <= ^end_date,
where: not is_nil(l.campaign_id),
group_by: l.campaign_id,
select: %{
campaign_id: l.campaign_id,
total_sent: count(l.uuid),
delivered:
sum(
fragment(
"CASE WHEN ? IN ('delivered', 'opened', 'clicked') THEN 1 ELSE 0 END",
l.status
)
),
opened:
sum(fragment("CASE WHEN ? IN ('opened', 'clicked') THEN 1 ELSE 0 END", l.status)),
clicked: sum(fragment("CASE WHEN ? = 'clicked' THEN 1 ELSE 0 END", l.status))
},
having: count(l.uuid) > 0,
limit: ^limit
)
repo().all(query)
|> Enum.map(fn stats ->
delivered = stats.delivered || 0
opened = stats.opened || 0
clicked = stats.clicked || 0
total = stats.total_sent || 1
# Calculate engagement score (30% open rate + 70% click rate)
open_rate = if delivered > 0, do: opened / delivered, else: 0
click_rate = if opened > 0, do: clicked / opened, else: 0
engagement_score = (open_rate * 0.3 + click_rate * 0.7) * 100
%{
campaign_id: stats.campaign_id,
total_sent: total,
delivered: delivered,
opened: opened,
clicked: clicked,
open_rate: Float.round(open_rate * 100, 2),
click_rate: Float.round(click_rate * 100, 2),
engagement_score: Float.round(engagement_score, 2)
}
end)
|> Enum.sort_by(& &1.engagement_score, :desc)
|> Enum.take(limit)
end
defp get_top_templates(start_date, end_date, limit) do
import Ecto.Query
# Query for templates with usage and performance metrics
query =
from(l in Log,
where: l.sent_at >= ^start_date and l.sent_at <= ^end_date,
where: not is_nil(l.template_name),
group_by: l.template_name,
select: %{
template_name: l.template_name,
usage_count: count(l.uuid),
delivered:
sum(
fragment(
"CASE WHEN ? IN ('delivered', 'opened', 'clicked') THEN 1 ELSE 0 END",
l.status
)
),
opened:
sum(fragment("CASE WHEN ? IN ('opened', 'clicked') THEN 1 ELSE 0 END", l.status)),
clicked: sum(fragment("CASE WHEN ? = 'clicked' THEN 1 ELSE 0 END", l.status))
},
having: count(l.uuid) > 0,
order_by: [desc: count(l.uuid)],
limit: ^limit
)
repo().all(query)
|> Enum.map(fn stats ->
delivered = stats.delivered || 0
opened = stats.opened || 0
clicked = stats.clicked || 0
# Calculate performance metrics
open_rate = if delivered > 0, do: Float.round(opened / delivered * 100, 2), else: 0
click_rate = if opened > 0, do: Float.round(clicked / opened * 100, 2), else: 0
%{
template_name: stats.template_name,
usage_count: stats.usage_count,
delivered: delivered,
opened: opened,
clicked: clicked,
open_rate: open_rate,
click_rate: click_rate
}
end)
end
# Get provider performance
defp get_provider_performance(period) do
Log.get_provider_performance(period)
end
end