Pro Feedback & Analytics
Viewing latest docs.
Switch version: v3

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:

elixir
# 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:

heex
<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:

elixir
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 message and category
  • 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:

elixir
|> 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:

elixir
# 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:

elixir
# 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:

elixir
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:

elixir
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:

elixir
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:

  1. The socket is connected (not the initial static render) — prevents double-counting on first load
  2. 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:

elixir
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:

elixir
# 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:

elixir
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.