Feedback & Analytics
Petal Pro includes an in-app feedback system and a lightweight server-side page analytics system. Together they give you qualitative user input and quantitative usage data — all without external services.
Feedback System
Users submit feedback via a floating widget (available on any LiveView) or the dedicated feedback page at /app/feedback. Submissions are stored in the feedbacks table via PetalPro.Feedback.FeedbackItem. Admins review submissions at /admin/feedback. Both authenticated and anonymous submissions are supported.
Schema
PetalPro.Feedback.FeedbackItem has these fields:
| Field | Type | Notes |
|---|---|---|
message |
string | Required. The feedback text. |
category |
string |
One of general, bug, feature, question. Defaults to general. |
page_url |
string | The URL the user was on when they submitted. Optional. |
user_id |
uuid | Nullable. Nil for anonymous submissions. |
Context module
PetalPro.Feedback is the public interface:
# Create feedback for an authenticated user
Feedback.create_feedback(%{"message" => "Love it", "category" => "general"}, user)
# Create anonymous feedback (no user association)
Feedback.create_feedback_anonymous(%{"message" => "Found a bug", "category" => "bug"})
# Fetch for admin review
Feedback.list_feedbacks()
Feedback.get_feedback!(id)
Feedback.delete_feedback(feedback_item)
Adding the feedback widget
PetalProWeb.FeedbackComponent renders a floating button (bottom-right corner) that toggles a slide-up panel with the feedback form. Add it to any LiveView template:
<PetalProWeb.FeedbackComponent.feedback_widget current_user={@current_user} />
The widget works for both authenticated and anonymous users — pass current_user={nil} for public pages.
After a successful submission the LiveComponent sends {:feedback_submitted, feedback} to the parent LiveView. Handle it if you want to show a flash:
def handle_info({:feedback_submitted, _feedback}, socket) do
{:noreply, put_flash(socket, :info, "Thanks for your feedback!")}
end
Dedicated feedback page
There is also a standalone page at /app/feedback rendered by PetalProWeb.FeedbackLive. Users reach it via the sidebar menu. It uses the same FeedbackFormComponent as the widget, so behaviour is identical.
Admin dashboard
PetalProWeb.AdminFeedbackLive lives at /admin/feedback. It uses the DataTable component (Flop-backed) with:
- Sortable columns: category, submitted date
- Filterable columns: category (exact match)
-
Full-text search: across
messageandcategory - Per-row delete: confirmation dialog before removal
- User link: authenticated submissions link to the user’s admin profile; anonymous submissions show “Anonymous”
Categories are color-coded: bug → red, feature → blue, question → yellow, general → gray.
Extending the feedback system
Adding custom categories
The allowed categories are validated in FeedbackItem.changeset/2:
|> validate_inclusion(:category, ~w(general bug feature question))
Add your category to the validation list and update the options in FeedbackFormComponent and category_color/1 in AdminFeedbackLive:
# In FeedbackFormComponent render/1
options={[
{"General", "general"},
{"Bug Report", "bug"},
{"Feature Request", "feature"},
{"Question", "question"},
{"Praise", "praise"} # new
]}
# In AdminFeedbackLive
defp category_color("praise"), do: "success"
Webhook notifications on new feedback
The Feedback.create_feedback/2 and create_feedback_anonymous/1 functions return {:ok, feedback_item} on success. Wrap the call site in FeedbackFormComponent to trigger a side effect, or add an Oban worker that fires after insert:
# In FeedbackFormComponent handle_event "submit"
case Feedback.create_feedback(feedback_params, current_user) do
{:ok, feedback} ->
# Fire and forget
Task.start(fn ->
MyApp.Workers.FeedbackWebhookWorker.enqueue(feedback.id)
end)
{:noreply, assign(socket, submitted: true)}
{:error, changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
The worker can then POST the payload to a Slack webhook, Discord, or any HTTP endpoint using Req.
Page Analytics
Petal Pro ships with a lightweight, server-side page view tracking system. It records view counts per page per day using atomic Postgres upserts — no external service, no JavaScript, no cookies, no PII.
What it tracks
-
Page name — a string you define when instrumenting a LiveView (e.g.
"dashboard","pricing") - Date — UTC date of the view
- View count — atomically incremented integer per page per day
What it does not track
- Individual users or sessions
- IP addresses or user agents (used only for bot filtering, never stored)
- Referrers, UTMs, or any request metadata
- Clicks, scroll depth, or client-side events
This makes it safe to use without cookie consent banners. It complements — not replaces — external analytics tools like Fathom or PostHog.
How daily aggregation works
The schema is PetalPro.Analytics.DailyPageStat backed by the daily_page_stats table:
typed_schema "daily_page_stats" do
field :page_name, :string
field :date, :date
field :view_count, :integer, default: 0
timestamps(type: :utc_datetime)
end
There is a unique constraint on [:page_name, :date]. Every call to record_page_view/1 does a Postgres ON CONFLICT upsert that increments the counter atomically — safe to call concurrently from many LiveView processes:
Repo.insert(
%DailyPageStat{page_name: page_name, date: today, view_count: 1},
on_conflict: [inc: [view_count: 1], set: [updated_at: now]],
conflict_target: [:page_name, :date]
)
One row per page per day. No cleanup job needed.
Instrumenting a page
Use PetalProWeb.PageViewHelper.track_page_view/2 in your LiveView mount/3:
defmodule MyAppWeb.DashboardLive do
use MyAppWeb, :live_view
alias PetalProWeb.PageViewHelper
@impl true
def mount(_params, _session, socket) do
PageViewHelper.track_page_view(socket, "dashboard")
{:ok, socket}
end
end
track_page_view/2 only records a view when:
- The socket is connected (not the initial static render) — prevents double-counting on first load
- The user agent is not a bot — checked against a built-in list of ~30 known bot/crawler patterns including Googlebot, GPTBot, ClaudeBot, curl, and uptime monitors
The actual DB write runs in a Task.start/1 so it never blocks the LiveView response.
Tracking a custom event
You can use PetalPro.Analytics.record_page_view/1 directly to track anything, not just page loads:
PetalPro.Analytics.record_page_view("checkout_started")
PetalPro.Analytics.record_page_view("pricing_page")
The page_name is just a string — pick a naming convention and stick to it.
Querying analytics data
PetalPro.Analytics exposes three query functions:
# Top pages by views for a date range (default limit: 20)
Analytics.top_pages(~D[2025-01-01], ~D[2025-01-31])
# => [%{page_name: "dashboard", total_views: 4201}, ...]
# Daily breakdown for a specific page
Analytics.page_views_by_day("dashboard", ~D[2025-01-01], ~D[2025-01-31])
# => [%{date: ~D[2025-01-01], views: 142}, ...]
# Total views across all pages
Analytics.total_views(~D[2025-01-01], ~D[2025-01-31])
# => 18_432
Viewing analytics in the admin
Page analytics are surfaced in the admin dashboard alongside user acquisition and subscription charts. The dashboard queries top_pages/3 and renders a chart via the chart-js-hook.
Configuration
No configuration is required. The system is always active. If you don’t call track_page_view/2 on a LiveView, that LiveView simply isn’t tracked.
To disable tracking on a specific page, just don’t call the helper — or add a conditional:
def mount(_params, _session, socket) do
unless Application.get_env(:my_app, :disable_analytics) do
PageViewHelper.track_page_view(socket, "dashboard")
end
{:ok, socket}
end
Difference from Fathom / external analytics
| Internal page analytics | Fathom (or similar) | |
|---|---|---|
| Where it runs | Server-side Elixir | Client-side JavaScript |
| What it tracks | Named events you instrument | All pageloads automatically |
| PII stored | None | IP anonymized, device info |
| Bot filtering | Yes (server UA check) | Yes (client-side) |
| Real-time | No (daily buckets) | Yes |
| Bounce rate / session data | No | Yes |
| Cost | Free (your DB) | Paid subscription |
Use internal analytics for product-specific instrumentation (e.g. “how many users hit the upgrade screen”) and Fathom for traffic/SEO/marketing metrics.