From 7a7ecf2b777706723221066a52c54ea467877381 Mon Sep 17 00:00:00 2001 From: sarahxsanders Date: Thu, 23 Jul 2026 15:22:08 -0400 Subject: [PATCH 1/2] feat(integration): make elixir a full example-based variant Replace the elixir docs-only variant with a full variant anchored on a real example app (example-apps/elixir): a minimal Phoenix app that reads PostHog config from the environment, installs the PostHog context Plug, identifies users, captures events, evaluates feature flags with the current evaluate_flags snapshot API, and relies on the SDK's Logger-based error tracking (documented honestly, since there is no manual capture_exception). Also skip Elixir build artifacts (_build/, deps/) when flattening the example so a local `mix compile` can't bloat the generated skill. Removes description-elixir-docs-only.md and points the variant at the example via example_paths + framework: elixir. Co-Authored-By: Claude Opus 4.8 --- context/skills/integration/config.yaml | 3 +- .../description-elixir-docs-only.md | 27 --- context/skip-patterns.yaml | 4 + example-apps/elixir/.env.example | 4 + example-apps/elixir/.gitignore | 6 + example-apps/elixir/README.md | 168 ++++++++++++++++++ example-apps/elixir/config/config.exs | 25 +++ example-apps/elixir/config/runtime.exs | 22 +++ .../elixir/lib/posthog_example/application.ex | 39 ++++ .../elixir/lib/posthog_example_web.ex | 52 ++++++ .../controllers/burrito_controller.ex | 166 +++++++++++++++++ .../controllers/burrito_html.ex | 52 ++++++ .../controllers/error_html.ex | 9 + .../controllers/layouts.ex | 32 ++++ .../lib/posthog_example_web/endpoint.ex | 32 ++++ .../elixir/lib/posthog_example_web/router.ex | 27 +++ example-apps/elixir/mix.exs | 39 ++++ example-apps/elixir/mix.lock | 26 +++ 18 files changed, 705 insertions(+), 28 deletions(-) delete mode 100644 context/skills/integration/description-elixir-docs-only.md create mode 100644 example-apps/elixir/.env.example create mode 100644 example-apps/elixir/.gitignore create mode 100644 example-apps/elixir/README.md create mode 100644 example-apps/elixir/config/config.exs create mode 100644 example-apps/elixir/config/runtime.exs create mode 100644 example-apps/elixir/lib/posthog_example/application.ex create mode 100644 example-apps/elixir/lib/posthog_example_web.ex create mode 100644 example-apps/elixir/lib/posthog_example_web/controllers/burrito_controller.ex create mode 100644 example-apps/elixir/lib/posthog_example_web/controllers/burrito_html.ex create mode 100644 example-apps/elixir/lib/posthog_example_web/controllers/error_html.ex create mode 100644 example-apps/elixir/lib/posthog_example_web/controllers/layouts.ex create mode 100644 example-apps/elixir/lib/posthog_example_web/endpoint.ex create mode 100644 example-apps/elixir/lib/posthog_example_web/router.ex create mode 100644 example-apps/elixir/mix.exs create mode 100644 example-apps/elixir/mix.lock diff --git a/context/skills/integration/config.yaml b/context/skills/integration/config.yaml index 2699fadf..636ffbe6 100644 --- a/context/skills/integration/config.yaml +++ b/context/skills/integration/config.yaml @@ -229,7 +229,8 @@ variants: - https://posthog.com/docs/libraries/ruby.md - id: elixir - template: description-elixir-docs-only.md + framework: elixir + example_paths: example-apps/elixir display_name: Elixir description: PostHog integration for Elixir and Phoenix applications using the posthog SDK tags: [elixir, phoenix, plug] diff --git a/context/skills/integration/description-elixir-docs-only.md b/context/skills/integration/description-elixir-docs-only.md deleted file mode 100644 index 812483ae..00000000 --- a/context/skills/integration/description-elixir-docs-only.md +++ /dev/null @@ -1,27 +0,0 @@ -# PostHog integration for {display_name} - -This skill helps you add PostHog analytics to {display_name} applications using the official PostHog Elixir SDK documentation. - -## Instructions - -1. Detect the existing Elixir app structure. Check `mix.exs`, `mix.lock`, `config/`, `lib/`, `application.ex`, Phoenix endpoint/router files, Plug pipelines, and existing Logger/error handling. -2. Read the reference files below before changing code. They are the source of truth for SDK installation, configuration, event capture, context, feature flags, group analytics, and error tracking. -3. Install the SDK by adding `{:posthog, "~> 2.0"}` to `mix.exs` and running `mix deps.get`, unless the project already uses a newer compatible version. -4. Configure PostHog through `config/config.exs` or runtime config using environment variables for `api_key` and `api_host`. Never hardcode secrets. -5. Add captures at meaningful request, job, or business-action boundaries. Use a stable `distinct_id` that matches the frontend/user identity. -6. Verify with the project's normal Mix commands, such as `mix test`, `mix format --check-formatted`, or the repository's existing checks. - -## Reference files - -{references} - -## Key principles - -- **Environment variables**: Always use environment variables for PostHog keys. Never hardcode them. -- **Minimal changes**: Add PostHog code alongside existing integrations. Don't replace or restructure existing code. -- **Match the docs**: Follow the Elixir reference's installation, configuration, capture, context, feature flag, and error tracking patterns exactly. -- **Analytics contract**: Treat event names, property names, and feature flag keys as part of an analytics contract. Reuse existing names and patterns found in the project. When introducing new ones, make them clear, descriptive, and consistent with existing conventions. - -## Framework guidelines - -{commandments} diff --git a/context/skip-patterns.yaml b/context/skip-patterns.yaml index 6a28b1f4..5fca7b40 100644 --- a/context/skip-patterns.yaml +++ b/context/skip-patterns.yaml @@ -144,6 +144,10 @@ global: # Angular - .angular/ + # Elixir + - _build/ + - deps/ + # Regex patterns - skip if path matches # Note: Patterns are JavaScript regex syntax regex: diff --git a/example-apps/elixir/.env.example b/example-apps/elixir/.env.example new file mode 100644 index 00000000..c25a5bd0 --- /dev/null +++ b/example-apps/elixir/.env.example @@ -0,0 +1,4 @@ +# Copy to .env and fill in, then export the values before running the app: +# export $(grep -v '^#' .env | xargs) +POSTHOG_PROJECT_TOKEN=your_posthog_project_token +POSTHOG_HOST=https://us.i.posthog.com diff --git a/example-apps/elixir/.gitignore b/example-apps/elixir/.gitignore new file mode 100644 index 00000000..0deb504e --- /dev/null +++ b/example-apps/elixir/.gitignore @@ -0,0 +1,6 @@ +# Build artifacts and dependencies +/_build/ +/deps/ + +# Secrets +.env diff --git a/example-apps/elixir/README.md b/example-apps/elixir/README.md new file mode 100644 index 00000000..31dbb6ee --- /dev/null +++ b/example-apps/elixir/README.md @@ -0,0 +1,168 @@ +# PostHog Phoenix (Elixir) example + +This is a [Phoenix](https://www.phoenixframework.org/) example demonstrating PostHog integration with product analytics, feature flags, user identification, and error tracking using the server-side [`posthog`](https://hex.pm/packages/posthog) Elixir SDK. + +## Features + +- **Product analytics**: Track user events and behaviors +- **User identification**: Associate events with users via the `distinct_id` on capture +- **Feature flags**: Control feature rollouts with PostHog feature flags +- **Error tracking**: Automatic exception + `Logger.error` capture (built into the SDK) +- **Server-side tracking**: All tracking happens server-side with the Elixir SDK +- **Single client per app**: The SDK starts one supervised client for the whole app +- **Context plug**: `PostHog.Integrations.Plug` tags every request automatically + +> **Note on error tracking:** unlike the Java/Python examples, the PostHog Elixir +> SDK captures errors **automatically** — it hooks into Elixir's `Logger`, so any +> `Logger.error` call and any uncaught exception is reported without an explicit +> call. There is no documented `capture_exception` function, so the profile page +> demonstrates the idiomatic path (`Logger.error`) plus an explicit +> `error_occurred` event. + +## Getting started + +### 1. Install dependencies + +```bash +mix deps.get +``` + +### 2. Configure environment variables + +Copy `.env.example` to `.env`, fill in your values, then export them: + +```bash +export POSTHOG_PROJECT_TOKEN=your_posthog_project_token +export POSTHOG_HOST=https://us.i.posthog.com +``` + +Get your PostHog project token from your [PostHog project settings](https://app.posthog.com/project/settings). + +### 3. Run the development server + +```bash +mix phx.server +``` + +Open [http://localhost:8000](http://localhost:8000) with your browser to see the app. + +## Project structure + +``` +elixir/ +├── mix.exs # Project + the posthog dependency +├── .env.example # Environment variable template +├── .gitignore +├── config/ +│ ├── config.exs # Endpoint + PostHog SDK config (enable, otp apps) +│ └── runtime.exs # Reads POSTHOG_* env vars at boot +└── lib/ + ├── posthog_example/ + │ └── application.ex # OTP app; warns loudly on a blank token + ├── posthog_example_web.ex # controller / html / router helpers + └── posthog_example_web/ + ├── endpoint.ex # Plug pipeline incl. PostHog.Integrations.Plug + ├── router.ex # Routes for the burrito app + └── controllers/ + ├── burrito_controller.ex # Events, identify, flags, errors + ├── burrito_html.ex # Tiny HEEx pages + ├── layouts.ex # Root layout + └── error_html.ex # Error pages +``` + +## Key integration points + +### Configuration from the environment (config/runtime.exs) + +`runtime.exs` runs at boot, so it can read environment variables. The token and host never live in source. + +```elixir +config :posthog, + api_key: System.get_env("POSTHOG_PROJECT_TOKEN"), + api_host: System.get_env("POSTHOG_HOST") || "https://us.i.posthog.com" +``` + +### SDK init — one client per app (config/config.exs) + +The SDK starts and supervises a single client for the whole app automatically. You only turn it on and tell it which OTP app is "in app" for error grouping: + +```elixir +config :posthog, + enable: true, + in_app_otp_apps: [:posthog_example] +``` + +A blank token doesn't crash the app — `application.ex` logs a clear warning and boots anyway. + +### Context plug (lib/posthog_example_web/endpoint.ex) + +Add the built-in plug before your router. It wraps each request in a PostHog context: it attaches request metadata (`$current_url`, `$host`, `$pathname`, …) and reads the `X-PostHog-Distinct-Id` / `X-PostHog-Session-Id` tracing headers to link backend and frontend events. + +```elixir +plug PostHog.Integrations.Plug +plug PostHogExampleWeb.Router +``` + +### User identification (lib/posthog_example_web/controllers/burrito_controller.ex) + +The Elixir SDK has no separate server-side `identify` step — you identify a user by passing their `distinct_id` on `capture`. It must match the id your frontend `posthog.identify(...)` uses so both sides attach to the same person. + +```elixir +PostHog.capture("user_logged_in", %{ + distinct_id: distinct_id, + login_method: "email" +}) +``` + +### Event tracking (lib/posthog_example_web/controllers/burrito_controller.ex) + +`distinct_id` is required; every other key in the map becomes an event property. + +```elixir +PostHog.capture("burrito_considered", %{ + distinct_id: distinct_id, + total_considerations: count +}) +``` + +### Feature flags (lib/posthog_example_web/controllers/burrito_controller.ex) + +Evaluate flags once per request, then read individual flags off the returned snapshot. + +```elixir +show_new_feature = + case PostHog.FeatureFlags.evaluate_flags(distinct_id) do + {:ok, snapshot} -> + PostHog.FeatureFlags.Evaluations.enabled?(snapshot, "new-dashboard-feature") + + _error -> + false + end +``` + +### Error tracking (lib/posthog_example_web/controllers/burrito_controller.ex) + +Error tracking is **automatic** — the SDK is built on Elixir's `Logger`, so uncaught exceptions and `Logger.error` calls are captured with no explicit call. There is no documented `capture_exception`, so we log the error (auto-captured) and also send an explicit analytics event: + +```elixir +try do + raise "Something went wrong while loading the burrito profile!" +rescue + error -> + Logger.error("Burrito profile error: #{Exception.message(error)}") + + PostHog.capture("error_occurred", %{ + distinct_id: distinct_id, + error_message: Exception.message(error) + }) +end +``` + +Disable automatic capture with `config :posthog, enable_error_tracking: false`. + +## Learn more + +- [PostHog Elixir SDK](https://posthog.com/docs/libraries/elixir) +- [PostHog feature flags](https://posthog.com/docs/feature-flags) +- [PostHog documentation](https://posthog.com/docs) +- [Phoenix framework](https://www.phoenixframework.org/) diff --git a/example-apps/elixir/config/config.exs b/example-apps/elixir/config/config.exs new file mode 100644 index 00000000..3f46c93c --- /dev/null +++ b/example-apps/elixir/config/config.exs @@ -0,0 +1,25 @@ +import Config + +# Endpoint configuration. The secret_key_base here is a throwaway dev value; +# override it in prod via runtime.exs / an environment variable. +config :posthog_example, PostHogExampleWeb.Endpoint, + url: [host: "localhost"], + adapter: Bandit.PhoenixAdapter, + http: [ip: {127, 0, 0, 1}, port: 8000], + render_errors: [formats: [html: PostHogExampleWeb.ErrorHTML], layout: false], + secret_key_base: + "dev_only_secret_key_base_replace_in_prod_0123456789abcdefghijklmnopqrstuvwxyz", + server: true + +# PostHog SDK — one client per app, started automatically by the SDK's +# own supervisor. The token and host are read from the environment in +# config/runtime.exs so secrets never live in source control. +# +# enable -> master on/off switch for the SDK +# in_app_otp_apps -> which OTP apps' stack frames count as "in app" for +# automatic error tracking (grouping / noise control) +config :posthog, + enable: true, + in_app_otp_apps: [:posthog_example] + +config :phoenix, :json_library, Jason diff --git a/example-apps/elixir/config/runtime.exs b/example-apps/elixir/config/runtime.exs new file mode 100644 index 00000000..557ca223 --- /dev/null +++ b/example-apps/elixir/config/runtime.exs @@ -0,0 +1,22 @@ +import Config + +# runtime.exs runs at boot in every environment, which makes it the right +# place to read environment variables (they are not available at compile time). + +# Configuration from the environment. Never hardcode the token. +# POSTHOG_PROJECT_TOKEN -> your project's write key +# POSTHOG_HOST -> defaults to PostHog US Cloud +config :posthog, + api_key: System.get_env("POSTHOG_PROJECT_TOKEN"), + api_host: System.get_env("POSTHOG_HOST") || "https://us.i.posthog.com" + +# In production, require a real secret_key_base from the environment. +if config_env() == :prod do + secret_key_base = + System.get_env("SECRET_KEY_BASE") || + raise "SECRET_KEY_BASE is missing. Generate one with `mix phx.gen.secret`." + + config :posthog_example, PostHogExampleWeb.Endpoint, + http: [ip: {0, 0, 0, 0}, port: String.to_integer(System.get_env("PORT") || "8000")], + secret_key_base: secret_key_base +end diff --git a/example-apps/elixir/lib/posthog_example/application.ex b/example-apps/elixir/lib/posthog_example/application.ex new file mode 100644 index 00000000..ea353118 --- /dev/null +++ b/example-apps/elixir/lib/posthog_example/application.ex @@ -0,0 +1,39 @@ +defmodule PostHogExample.Application do + @moduledoc false + + use Application + require Logger + + @impl true + def start(_type, _args) do + warn_if_token_missing() + + children = [ + PostHogExampleWeb.Endpoint + ] + + opts = [strategy: :one_for_one, name: PostHogExample.Supervisor] + Supervisor.start_link(children, opts) + end + + @impl true + def config_change(changed, _new, removed) do + PostHogExampleWeb.Endpoint.config_change(changed, removed) + :ok + end + + # Fail loudly, but don't break the app: with a blank token the SDK simply + # won't deliver events. We log a clear warning and let the app boot. + defp warn_if_token_missing do + case Application.get_env(:posthog, :api_key) do + token when token in [nil, ""] -> + Logger.warning( + "POSTHOG_PROJECT_TOKEN is not set — PostHog events will not be delivered. " <> + "The app will still run. Set it in your environment to enable analytics." + ) + + _token -> + :ok + end + end +end diff --git a/example-apps/elixir/lib/posthog_example_web.ex b/example-apps/elixir/lib/posthog_example_web.ex new file mode 100644 index 00000000..eba3d199 --- /dev/null +++ b/example-apps/elixir/lib/posthog_example_web.ex @@ -0,0 +1,52 @@ +defmodule PostHogExampleWeb do + @moduledoc """ + Entry points for the web layer: controllers, HTML views, and the router. + + Use it with `use PostHogExampleWeb, :controller`, `use PostHogExampleWeb, :html`, + or `use PostHogExampleWeb, :router`. + """ + + def router do + quote do + use Phoenix.Router, helpers: false + + import Plug.Conn + import Phoenix.Controller + end + end + + def controller do + quote do + use Phoenix.Controller, + formats: [:html], + layouts: [html: PostHogExampleWeb.Layouts] + + import Plug.Conn + unquote(verified_routes()) + end + end + + def html do + quote do + use Phoenix.Component + + import Phoenix.Controller, only: [get_csrf_token: 0] + unquote(verified_routes()) + end + end + + def verified_routes do + quote do + use Phoenix.VerifiedRoutes, + endpoint: PostHogExampleWeb.Endpoint, + router: PostHogExampleWeb.Router + end + end + + @doc """ + Dispatches to the appropriate helper (controller / html / router / ...). + """ + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end diff --git a/example-apps/elixir/lib/posthog_example_web/controllers/burrito_controller.ex b/example-apps/elixir/lib/posthog_example_web/controllers/burrito_controller.ex new file mode 100644 index 00000000..f6bd62eb --- /dev/null +++ b/example-apps/elixir/lib/posthog_example_web/controllers/burrito_controller.ex @@ -0,0 +1,166 @@ +defmodule PostHogExampleWeb.BurritoController do + @moduledoc """ + The whole burrito app, and every PostHog integration point: + + * user identification -> /login + * event tracking -> /burrito + * feature flags -> /dashboard + * error tracking -> /profile (see the note in `trigger_error/2`) + + All PostHog calls use the documented v2.0 SDK API: + https://posthog.com/docs/libraries/elixir + """ + + use PostHogExampleWeb, :controller + require Logger + + # --------------------------------------------------------------------------- + # Home / login + # --------------------------------------------------------------------------- + + def home(conn, _params) do + render(conn, :home, distinct_id: current_distinct_id(conn)) + end + + @doc """ + Identify the user. + + The PostHog Elixir SDK has no separate `identify` step for server events — + you identify a user by passing their `distinct_id` on `capture`. The + `distinct_id` must match the id your frontend `posthog.identify(...)` uses, + so events from both sides attach to the same person. + + We derive a stable distinct id from the username and remember it in the + session so every later request captures against the same person. + """ + def login(conn, params) do + username = params["username"] || "burrito_fan" + distinct_id = "user_" <> username + + # PostHog: identify + capture in one call. Extra keys in the map become + # event properties. + PostHog.capture("user_logged_in", %{ + distinct_id: distinct_id, + login_method: "email" + }) + + conn + |> put_session(:distinct_id, distinct_id) + |> redirect(to: ~p"/dashboard") + end + + def logout(conn, _params) do + # Capture before we drop the session so the event is still attributed. + PostHog.capture("user_logged_out", %{distinct_id: current_distinct_id(conn)}) + + conn + |> delete_session(:distinct_id) + |> redirect(to: ~p"/") + end + + # --------------------------------------------------------------------------- + # Event tracking + # --------------------------------------------------------------------------- + + def burrito(conn, _params) do + render(conn, :burrito, count: get_session(conn, :burrito_count) || 0) + end + + @doc "Track a custom `burrito_considered` event with properties." + def consider_burrito(conn, _params) do + count = (get_session(conn, :burrito_count) || 0) + 1 + + # PostHog: custom event. `distinct_id` is required; the rest are properties. + PostHog.capture("burrito_considered", %{ + distinct_id: current_distinct_id(conn), + total_considerations: count + }) + + conn + |> put_session(:burrito_count, count) + |> redirect(to: ~p"/burrito") + end + + # --------------------------------------------------------------------------- + # Feature flags + # --------------------------------------------------------------------------- + + @doc """ + Evaluate flags once per request, then read individual flags off the snapshot. + """ + def dashboard(conn, _params) do + distinct_id = current_distinct_id(conn) + + show_new_feature = + case PostHog.FeatureFlags.evaluate_flags(distinct_id) do + {:ok, snapshot} -> + PostHog.FeatureFlags.Evaluations.enabled?(snapshot, "new-dashboard-feature") + + _error -> + # Fail safe: if flag evaluation fails, fall back to "off". + false + end + + PostHog.capture("dashboard_viewed", %{distinct_id: distinct_id}) + + render(conn, :dashboard, show_new_feature: show_new_feature) + end + + # --------------------------------------------------------------------------- + # Error tracking + # --------------------------------------------------------------------------- + + def profile(conn, _params) do + PostHog.capture("profile_viewed", %{distinct_id: current_distinct_id(conn)}) + message = get_session(conn, :message) + + conn + |> delete_session(:message) + |> render(:profile, distinct_id: current_distinct_id(conn), message: message) + end + + @doc """ + Demonstrate error tracking. + + PostHog's Elixir SDK does error tracking *automatically*: it is built on top + of Elixir's `Logger`, so any `Logger.error/1` call and any uncaught exception + is captured — there is no documented explicit `capture_exception` function. + + So here we `rescue` a deliberate exception and report it two ways: + + 1. `Logger.error(...)` — which the SDK auto-captures as an exception, and + 2. an explicit `error_occurred` custom event for easy funnel/analytics use. + """ + def trigger_error(conn, _params) do + distinct_id = current_distinct_id(conn) + + try do + raise "Something went wrong while loading the burrito profile!" + rescue + error -> + # PostHog auto-captures this Logger.error as an exception. + Logger.error("Burrito profile error: #{Exception.message(error)}") + + # Plus an explicit analytics event, matching the other examples. + PostHog.capture("error_occurred", %{ + distinct_id: distinct_id, + error_type: error.__struct__ |> Module.split() |> List.last(), + error_message: Exception.message(error) + }) + end + + conn + |> put_flash_message("Error triggered and captured by PostHog.") + |> redirect(to: ~p"/profile") + end + + # --------------------------------------------------------------------------- + # Helpers + # --------------------------------------------------------------------------- + + # A stable distinct id for the session, or "anonymous" before login. + defp current_distinct_id(conn), do: get_session(conn, :distinct_id) || "anonymous" + + # Tiny session-backed flash (this app doesn't wire the full Phoenix flash). + defp put_flash_message(conn, message), do: put_session(conn, :message, message) +end diff --git a/example-apps/elixir/lib/posthog_example_web/controllers/burrito_html.ex b/example-apps/elixir/lib/posthog_example_web/controllers/burrito_html.ex new file mode 100644 index 00000000..40c88d83 --- /dev/null +++ b/example-apps/elixir/lib/posthog_example_web/controllers/burrito_html.ex @@ -0,0 +1,52 @@ +defmodule PostHogExampleWeb.BurritoHTML do + @moduledoc "Tiny HEEx pages for the burrito app." + use PostHogExampleWeb, :html + + def home(assigns) do + ~H""" +

🌯 Burrito app

+

Signed in as: <%= @distinct_id %>

+
+ + + +
+ """ + end + + def burrito(assigns) do + ~H""" +

Consider a burrito

+

You have considered a burrito <%= @count %> time(s).

+
+ + +
+ """ + end + + def dashboard(assigns) do + ~H""" +

Dashboard

+

+ ✨ The new-dashboard-feature flag is ON. +

+

+ The new-dashboard-feature flag is off (default). +

+ """ + end + + def profile(assigns) do + ~H""" +

Profile

+

Distinct id: <%= @distinct_id %>

+

<%= @message %>

+
+ + +
+

Log out

+ """ + end +end diff --git a/example-apps/elixir/lib/posthog_example_web/controllers/error_html.ex b/example-apps/elixir/lib/posthog_example_web/controllers/error_html.ex new file mode 100644 index 00000000..58be5919 --- /dev/null +++ b/example-apps/elixir/lib/posthog_example_web/controllers/error_html.ex @@ -0,0 +1,9 @@ +defmodule PostHogExampleWeb.ErrorHTML do + @moduledoc "Renders plain-text status pages for errors (e.g. 404, 500)." + use PostHogExampleWeb, :html + + # e.g. render("404.html", _) -> "Not Found" + def render(template, _assigns) do + Phoenix.Controller.status_message_from_template(template) + end +end diff --git a/example-apps/elixir/lib/posthog_example_web/controllers/layouts.ex b/example-apps/elixir/lib/posthog_example_web/controllers/layouts.ex new file mode 100644 index 00000000..719f9ff7 --- /dev/null +++ b/example-apps/elixir/lib/posthog_example_web/controllers/layouts.ex @@ -0,0 +1,32 @@ +defmodule PostHogExampleWeb.Layouts do + @moduledoc "The root layout wrapping every page." + use PostHogExampleWeb, :html + + def root(assigns) do + ~H""" + + + + + + PostHog Elixir example + + + + +
+ <%= @inner_content %> + + + """ + end +end diff --git a/example-apps/elixir/lib/posthog_example_web/endpoint.ex b/example-apps/elixir/lib/posthog_example_web/endpoint.ex new file mode 100644 index 00000000..a139ba63 --- /dev/null +++ b/example-apps/elixir/lib/posthog_example_web/endpoint.ex @@ -0,0 +1,32 @@ +defmodule PostHogExampleWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :posthog_example + + # The session is stored in a signed cookie. We use it to remember the + # logged-in user's stable distinct id across requests. + @session_options [ + store: :cookie, + key: "_posthog_example_key", + signing_salt: "kE9xVq2p", + same_site: "Lax" + ] + + plug Plug.RequestId + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + + plug Plug.MethodOverride + plug Plug.Head + plug Plug.Session, @session_options + + # PostHog's built-in Plug. Placed before the router so every request is + # wrapped in a PostHog context: it attaches request metadata ($current_url, + # $host, $pathname, ...) and reads the X-PostHog-Distinct-Id / + # X-PostHog-Session-Id tracing headers to link backend and frontend events. + plug PostHog.Integrations.Plug + + plug PostHogExampleWeb.Router +end diff --git a/example-apps/elixir/lib/posthog_example_web/router.ex b/example-apps/elixir/lib/posthog_example_web/router.ex new file mode 100644 index 00000000..a1111c21 --- /dev/null +++ b/example-apps/elixir/lib/posthog_example_web/router.ex @@ -0,0 +1,27 @@ +defmodule PostHogExampleWeb.Router do + use PostHogExampleWeb, :router + + pipeline :browser do + plug :accepts, ["html"] + plug :fetch_session + plug :put_root_layout, html: {PostHogExampleWeb.Layouts, :root} + plug :protect_from_forgery + plug :put_secure_browser_headers + end + + scope "/", PostHogExampleWeb do + pipe_through :browser + + get "/", BurritoController, :home + post "/login", BurritoController, :login + get "/logout", BurritoController, :logout + + get "/burrito", BurritoController, :burrito + post "/burrito", BurritoController, :consider_burrito + + get "/dashboard", BurritoController, :dashboard + + get "/profile", BurritoController, :profile + post "/profile/error", BurritoController, :trigger_error + end +end diff --git a/example-apps/elixir/mix.exs b/example-apps/elixir/mix.exs new file mode 100644 index 00000000..d5fba3a2 --- /dev/null +++ b/example-apps/elixir/mix.exs @@ -0,0 +1,39 @@ +defmodule PostHogExample.MixProject do + use Mix.Project + + def project do + [ + app: :posthog_example, + version: "0.1.0", + elixir: "~> 1.15", + elixirc_paths: elixirc_paths(Mix.env()), + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + mod: {PostHogExample.Application, []}, + extra_applications: [:logger] + ] + end + + defp elixirc_paths(_), do: ["lib"] + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:phoenix, "~> 1.7"}, + {:phoenix_html, "~> 4.0"}, + # Phoenix.Component / the ~H sigil used by the HTML views ships in + # phoenix_live_view, even though this app does not use LiveView itself. + {:phoenix_live_view, "~> 1.0"}, + {:bandit, "~> 1.5"}, + {:jason, "~> 1.4"}, + # The PostHog server-side SDK. https://posthog.com/docs/libraries/elixir + {:posthog, "~> 2.0"} + ] + end +end diff --git a/example-apps/elixir/mix.lock b/example-apps/elixir/mix.lock new file mode 100644 index 00000000..f2dd6189 --- /dev/null +++ b/example-apps/elixir/mix.lock @@ -0,0 +1,26 @@ +%{ + "bandit": {:hex, :bandit, "1.12.0", "6c5214daa2469644ac4ab0113b98abc24f75e348378e6a974c6343b3e5da22ef", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "45dac82dc86f45cf4a196dee9cc5a8b791d9c9469d996055f055e6ee36c66e20"}, + "finch": {:hex, :finch, "0.23.0", "e3f9287ac25a8832f848b144c2b57346aac65b205e2e0629a52adfe6507fd837", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "80e58d3f936f57e3fdf404f83a3642897ae6d9fb642934e46da4d8fe761b99d5"}, + "hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"}, + "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, + "logger_json": {:hex, :logger_json, "7.0.4", "e315f2b9a755504658a745f3eab90d88d2cd7ac2ecfd08c8da94d8893965ab5c", [:mix], [{:decimal, ">= 0.0.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "d1369f8094e372db45d50672c3b91e8888bcd695fdc444a37a0734e96717c45c"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mint": {:hex, :mint, "1.9.3", "3337184d69179695c7a9f1714d92c11e629d36c8c037a21cf490131d3d150554", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5f7c9342480c069dbbc4eeac3490303c9e01870ff01a7f1d29b6107054fc1e74"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_ownership": {:hex, :nimble_ownership, "1.0.2", "fa8a6f2d8c592ad4d79b2ca617473c6aefd5869abfa02563a77682038bf916cf", [:mix], [], "hexpm", "098af64e1f6f8609c6672127cfe9e9590a5d3fcdd82bc17a377b8692fd81a879"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "phoenix": {:hex, :phoenix, "1.8.9", "a63ed0962ed5b903b146dab0ae8eb8387fe478f8171a5e26d56a165f35996fe1", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "3477e2dd5a4f61820341169031bdfe21275f659923bea9c5c0ea2aa1c3fcc046"}, + "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.2.7", "d0f20871681216598e78baccd4f66d8686fdb8007821989bab508d293a3ed9ad", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "61e97938a4fcca6d6f2c836925623abf2f52a572cc8c6085e4074f3f6337e0eb"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "posthog": {:hex, :posthog, "2.12.1", "c7c586f33b096bcb4f012971d5054ef9754930bdcb755fd12cce05fb921485f6", [:mix], [{:logger_json, "~> 7.0", [hex: :logger_json, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}, {:req, ">= 0.6.1 and < 1.0.0", [hex: :req, repo: "hexpm", optional: false]}, {:uuid_v7, "~> 0.6", [hex: :uuid_v7, repo: "hexpm", optional: false]}], "hexpm", "68083566d6d7c233b4c62d9a488345addc8ec5a35d2f5f0db850e361e41c3182"}, + "req": {:hex, :req, "0.6.3", "7fe5e68792ff0546e45d5919104fa1764a13694cfe3e48c8a0f32ad051ae77e4", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "e85b5c6c990e6c3f52bbba68e6f099118f2b8252825f96c7c3636b97a3de307d"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, + "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"}, + "uuid_v7": {:hex, :uuid_v7, "0.6.0", "1d65727ade8ca619ed40fdef90c4186b50c84657d2b412f7cb79777ab2d47559", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "1dc401134e61da847a7b2a3b28d2593893f457b9f2704893b1ba3ff7946ce91f"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, +} From 55a6d33401d8016e16bfc0dff9761b1647dc6cb9 Mon Sep 17 00:00:00 2001 From: sarahxsanders Date: Fri, 24 Jul 2026 09:49:17 -0400 Subject: [PATCH 2/2] fix(elixir example): add missing app layout; note identity source Every page 500'd with "no app html template defined": the router sets the :root layout but the controller also applies a :app layout, which Layouts never defined. Add a minimal app/1 pass-through so pages actually render. Verified: the app boots with no token (SDK goes no-op + warns) and GET /, /dashboard, /profile, /burrito all return 200, /login redirects. Also document that distinct_id must come from an authenticated principal, not an unverified request param. Co-Authored-By: Claude Opus 4.8 --- .../controllers/burrito_controller.ex | 5 +++++ .../lib/posthog_example_web/controllers/layouts.ex | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/example-apps/elixir/lib/posthog_example_web/controllers/burrito_controller.ex b/example-apps/elixir/lib/posthog_example_web/controllers/burrito_controller.ex index f6bd62eb..6dfcf80d 100644 --- a/example-apps/elixir/lib/posthog_example_web/controllers/burrito_controller.ex +++ b/example-apps/elixir/lib/posthog_example_web/controllers/burrito_controller.ex @@ -32,6 +32,11 @@ defmodule PostHogExampleWeb.BurritoController do We derive a stable distinct id from the username and remember it in the session so every later request captures against the same person. + + Identity note: this demo takes the username from an unverified form field for + simplicity. A real app must derive `distinct_id` from the authenticated + principal (session / token), never from an unverified request param — otherwise + a client could pick any id and overwrite another person's profile. """ def login(conn, params) do username = params["username"] || "burrito_fan" diff --git a/example-apps/elixir/lib/posthog_example_web/controllers/layouts.ex b/example-apps/elixir/lib/posthog_example_web/controllers/layouts.ex index 719f9ff7..f720ca03 100644 --- a/example-apps/elixir/lib/posthog_example_web/controllers/layouts.ex +++ b/example-apps/elixir/lib/posthog_example_web/controllers/layouts.ex @@ -2,6 +2,16 @@ defmodule PostHogExampleWeb.Layouts do @moduledoc "The root layout wrapping every page." use PostHogExampleWeb, :html + # The app layout wraps each page's content and is itself wrapped by root/1. + # Controllers apply it by default (see the `layouts:` option in + # posthog_example_web.ex); without it every render fails with + # "no app html template defined". + def app(assigns) do + ~H""" + {@inner_content} + """ + end + def root(assigns) do ~H"""