Phoenix Setup
Create a new Phoenix app with esbuild, SCSS compilation, React, Jquery, Bootstrap, Bootstrap Icons, and a production-ready Render deployment.
Basic setup
mix phx.new myapp --no-tailwindcd myappgit init . && git add --all && git commit -m "Initial commit"mix ecto.create- Create
.tool-versionsand set the tool versions you’re using:
# tool version specification for asdf
elixir 1.17.2
erlang 27.0.1
nodejs 22.14.0
asdf installto install the matching elixir, erlang, and node versions- Install npm packages for SASS and React:
npm i --save sass@1.77.6 sane react react-dom bootstrap jquery --prefix assets/(Usesass@1.77.6because in later versions, Bootstrap causes noisy deprecation warnings.) - In
dev.exs, under theEndpointconfig, add a watcher script to rebuild any scss files:
watchers: [
esbuild: ...,
npm: ["run", "sass-watch", "--prefix", "assets/", into: IO.stream(:stdio, :line), stderr_to_stdout: true]
]
- Define the sass scripts in
assets/package.json:
"scripts": {
"sass-watch": "node build-sass.js --watch",
"sass-deploy": "node build-sass.js --deploy"
}
- Add
assets/build-sass.js:
// Called by dev.exs Endpoint config
// Thanks to https://mfeckie.dev/sass-in-phoenix/
const sass = require("sass");
const sane = require("sane");
const fs = require("fs");
const args = process.argv.slice(2);
function rebuildCss() {
const before = new Date();
const {css} = sass.renderSync({file: "css/app.scss", sourceMapEmbed: true});
const after = new Date();
const duration = after - before;
console.log(`✨ CSS rebuilt in ${duration}ms`);
fs.mkdirSync("../priv/static/assets", {recursive: true});
fs.writeFileSync("../priv/static/assets/app.css", css);
}
if (args.includes("--watch")) {
const styleWatcher = sane("css", { glob: ["**/*.scss"] });
styleWatcher.on("ready", rebuildCss);
styleWatcher.on("add", rebuildCss);
styleWatcher.on("delete", rebuildCss);
styleWatcher.on("change", rebuildCss);
// Keep the process running until explicitly killed
process.stdin.on("end", () => process.exit());
process.stdin.resume();
} else if (args.includes("--deploy")) {
rebuildCss();
} else {
console.log("Usage: node build-sass.js --watch|--deploy");
}
CSS & JS
- Create
assets/css/app.scssand import Bootstrap, Bootstrap Icons, and some custom css:
@import "node_modules/bootstrap/scss/bootstrap";
@import "vendor/bootstrap-icons";
@import "general";
@import "components";
- Add
assets/css/general.scss:
//
// Bootstrap overrides
//
a { text-decoration: none; }
a:not(.btn):not(.nav-link):hover { text-decoration: underline; }
h1, h2, h3, h4, h5, h6, h7 { margin-top: 0.5em; margin-bottom: 0.5em; }
// Darker warning text color (the default is very hard to read)
.text-warning { color: rgb(203, 152, 0) !important; }
.btn-outline-warning { color: rgb(203, 152, 0) !important; }
.btn-outline-warning:focus, .btn-outline-warning:hover { color: black !important; }
// Make the emphasis utilities less dark, more pronounced color
.text-primary-emphasis { color: darken($primary, 20%) !important; }
.text-secondary-emphasis { color: darken($secondary, 20%) !important; }
.text-success-emphasis { color: darken($success, 20%) !important; }
.text-info-emphasis { color: darken($info, 20%) !important; }
.text-warning-emphasis { color: darken($warning, 20%) !important; }
.text-danger-emphasis { color: darken($danger, 20%) !important; }
// I customized the `.rounded` utility to a 1rem border-radius. .rounded-md is for the few cases
// where we do NOT want that, instead we want the original medium rounding level.
.rounded-md { border-radius: 0.375rem; }
.form-check-input { border-color: #999; } // The Bootstrap default looks too dimmed / disabled
//
// Utilities
//
.em { font-style: italic; }
.strong { font-weight: bold; }
.u-hidden { display: none; }
.u-nowrap { white-space: nowrap; }
.u-ellipse { overflow: hidden; text-overflow: ellipsis; }
// Use in combination with .mx-auto (or alone)
.max-w-400 { max-width: 400px; }
.max-w-500 { max-width: 500px; }
.max-w-600 { max-width: 600px; }
.max-w-700 { max-width: 700px; }
.max-w-800 { max-width: 800px; }
.max-w-900 { max-width: 900px; }
.max-w-1000 { max-width: 1000px; }
.max-w-1100 { max-width: 1100px; }
.max-w-1200 { max-width: 1200px; }
.border-dashed { border-style: dashed !important; }
.border-subtle { border-color: #eff1f4 !important; }
.cursor-pointer { cursor: pointer; }
.cursor-not-allowed { cursor: not-allowed; }
.cursor-help { cursor: help; }
.mt-n1 { margin-top: -0.25rem; }
.mt-n2 { margin-top: -0.5rem; }
.mt-n3 { margin-top: -1rem; }
.mb-n1 { margin-bottom: -0.25rem; }
.mb-n2 { margin-bottom: -0.5rem; }
.mb-n3 { margin-bottom: -1rem; }
.ms-n1 { margin-left: -0.25rem; }
.ms-n2 { margin-left: -0.5rem; }
.ms-n3 { margin-left: -1rem; }
.me-n1 { margin-right: -0.25rem; }
.me-n2 { margin-right: -0.5rem; }
.me-n3 { margin-right: -1rem; }
// Useful in PDF printing
.avoid-page-break { break-inside: avoid; }
.page-break-after { break-after: always; page-break-after: always; }
- Add
assets/css/components.scss:
// CSS for special-purpose components (as opposed to utility classes) go here.
.u-tooltip-target {
// CSS-only tooltips compatible with LiveView.
//
// Example:
// <span class="u-tooltip-target">
// Hover on me
// <div class="u-tooltip">This tooltip will display when hovered</div>
// </span>
//
// Customizing:
// - Override the width on .u-tooltip to fit the context. (e.g. 10em may look better)
// - Use .u-tooltip.u-tooltip-oneline for short, one-line tooltips (default is multiline)
position: relative;
.u-tooltip {
position: absolute;
bottom: 100%;
left: 50%;
width: 20em;
margin-left: -3em;
margin-bottom: 5px;
text-wrap: wrap;
display: block;
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
padding: 0.3rem 0.3rem;
border-radius: 0.5rem;
text-align: center;
font-size: 0.8rem;
font-weight: normal;
line-height: 1rem;
background-color: #000;
color: #fff;
box-shadow: 1px 1px 5px 0px rgba(0, 0, 0, 0.6);
z-index: 9999;
pointer-events: none; // prevent clicks on tooltip from clicking the parent element
&.u-tooltip-oneline {
width: auto;
white-space: nowrap;
}
&.--bottom {
bottom: auto;
top: 100%;
margin-top: 5px;
}
}
// This bridges the gap so you can mouse into the tooltip without it disappearing
.u-tooltip::before {
bottom: -10px;
content: " ";
display: block;
height: 10px;
left: 0;
position: absolute;
width: 100%;
}
// CSS Triangles
.u-tooltip::after {
position: absolute;
left: 3em;
bottom: -6px;
margin-left: -13px;
height: 0;
width: 0;
content: " ";
border-left: solid transparent 10px;
border-right: solid transparent 10px;
border-top: solid #000 10px;
}
.u-tooltip.--bottom::before {
top: -10px;
bottom: auto;
}
.u-tooltip.--bottom::after {
top: -6px;
bottom: auto;
border-top: none;
border-bottom: solid #000 10px;
}
&:hover .u-tooltip {
visibility: visible;
opacity: 1;
}
}
.phx-modal-wrapper {
/* Simple modals, compatible with either LiveView or JS show/hide. Usage example:
<div class="phx-modal-wrapper">
<div class="phx-modal-backdrop" phx-click="hide-modal"></div>
<div class="phx-modal">
<a href="#" class="position-absolute top-0 end-0 p-2" phx-click="hide-modal">✖️</a>
<h3 class="mt-0">Modal Title</h3>
<p>Modal content</p>
</div>
</div> */
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
padding: 1rem;
z-index: 2;
.phx-modal-backdrop {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background-color: rgba(0, 0, 0, 0.3);
}
.phx-modal {
position: relative;
max-width: 600px;
margin-left: auto; margin-right: auto; margin-top: 4rem;
padding: 1rem;
background-color: white;
border: 1px solid #ddd;
border-radius: 0.5rem;
// Syntax: box-shadow(offset-x offset-y blur-radius spread-radius color)
box-shadow: 1px 3px 6px 0px rgba(0, 0, 0, 0.2);
max-height: calc(90vh - 6rem);
overflow-y: auto;
}
}
/* Scroll shadows — pure CSS using scroll-driven animations.
Inset box-shadows appear/disappear based on scroll position.
Works with transparent backgrounds. No JS required.
Usage: Add class="scroll-shadows" and a max-height + overflow:auto to your element. */
.scroll-shadows {
overflow: auto;
animation: scroll-shadows linear both;
animation-timeline: scroll(self);
}
@keyframes scroll-shadows {
from {
box-shadow: inset 0 8px 6px -6px rgba(0,0,0,0),
inset 0 -8px 6px -6px rgba(0,0,0,0.15);
}
1%, 99% {
box-shadow: inset 0 8px 6px -6px rgba(0,0,0,0.15),
inset 0 -8px 6px -6px rgba(0,0,0,0.15);
}
to {
box-shadow: inset 0 8px 6px -6px rgba(0,0,0,0.15),
inset 0 -8px 6px -6px rgba(0,0,0,0);
}
}
- Install Bootstrap Icons:
- Go to https://icons.getbootstrap.com/#install, download & unpack the Source Code .zip.
- Copy
font/bootstrap-icons.scsstoassets/vendor/, open it, adjust thefont-dirvariable to"../fonts", and delete references to the.wofffile (we’ll only use the.woff2). - Copy
font/fonts/bootstrap-icons.woff2topriv/static/fonts/.
- At the bottom of
app.js, add some imports:
//
// Non-Phoenix imports
//
// Import all of BS for simplicity. Could reduce bundle size by only importing specific components.
import "bootstrap"
import "./jquery_utilities"
import "./react/_init"
- Add
assets/js/jquery_utilities.js:
import $ from "jquery"
const raise = (msg) => { throw new Error(msg); }
$(function(){
// Generic listener which binds to an arbitrary event to perform action(s) on a target.
// Format: "event:action:target" — multiple actions separated by ", "
// Actions: show, hide, toggle (target is a CSS selector), swap-text (target is "a|b")
$('[data-js-on]').each(function(){
const el = $(this)
el.data('js-on').split(', ').forEach(actionStr => {
const [event, action, target] = actionStr.split(':')
const allowedActions = ["show", "hide", "toggle", "swap-text"]
if (!allowedActions.includes(action))
throw new Error(`Action '${action}' must be one of: ${allowedActions.join(', ')}`);
el.on(event, function(e){
e.preventDefault()
if (["show", "hide", "toggle"].includes(action)) {
$(target)[action](200);
} else if (action === 'swap-text') {
const [a, b] = target.split('|')
const text = el.text()
el.text(text.includes(a) ? text.replace(a, b) : text.replace(b, a))
} else {
throw new Error(`Don't know how to handle action: ${action}`);
}
})
})
});
// ==============================
// Batch Select System
// ==============================
// A reusable pattern for selecting multiple items in a list and performing batch actions.
//
// How to use it:
// 1. Add a "toggle all" checkbox in your table header with class `.js-batch-select-toggle-all`.
// 2. Add a checkbox to each row with class `.js-batch-select` and `data-id="{ITEM_ID}"`.
// 3. (Optional) Add `.js-batch-select-row` to each <tr>, so users can click anywhere on the row
// to toggle its checkbox.
// 4. Add `.js-batch-select-action` to any link that should trigger a batch action. The JS will
// collect all checked IDs and POST them to the link's href as `ids=1,2,3`.
// 5. (Optional) Add `data-confirmation="Delete {COUNT} items?"` to the link, to show a confirm dialog.
// Use {COUNT} as a placeholder for the number of selected items.
// (We don't use `data-confirm` because that would conflict with Rails UJS.)
// 6. Server-side: your POST endpoint receives `params[:ids]` as a comma-separated string, e.g. "1,2,3".
//
// Example table HTML:
// <table>
// <thead>
// <tr>
// <th><input type="checkbox" class="js-batch-select-toggle-all" /></th>
// <th>Name</th>
// ...
// </tr>
// </thead>
// <tbody>
// <tr class="js-batch-select-row">
// <td><input type="checkbox" class="js-batch-select" data-id="123" /></td>
// <td>Item Name</td>
// </tr>
// ...
// </tbody>
// </table>
//
// Example action link HTML:
// <a href="/admin/users/batch_delete" class="js-batch-select-action"
// data-confirmation="Delete {COUNT} users?">Delete Selected</a>
//
// Server-side: The controller receives params[:ids] as a comma-separated string (e.g., "1,2,3").
$('.js-batch-select-toggle-all').change(function(){
var checked = $(this).is(':checked');
$('.js-batch-select').prop('checked', checked);
});
$('.js-batch-select-row').click(function(e){
if (e.target.type === 'checkbox' || $(e.target).closest('a').length) return;
var checkbox = $(this).find('.js-batch-select');
checkbox.prop('checked', !checkbox.prop('checked'));
});
$('.js-batch-select-action').click(function(e) {
e.preventDefault();
var ids = $('.js-batch-select:checked').map((_, el) => $(el).data('id')).get();
if (ids.length === 0) { alert('No items selected.'); return; }
if ($(this).data('confirm')) { alert('data-confirm NOT supported on batch actions due to Rails conflict. Use `data-confirmation` instead.'); return; }
var confirmMsg = $(this).data('confirmation');
if (confirmMsg) {
confirmMsg = confirmMsg.replace('{COUNT}', ids.length);
if (!confirm(confirmMsg)) return;
}
var form = $('<form>', {
// Set `data-batch-method="get"` on the link to submit as GET instead of POST.
// (Can't use `data-method` because that conflicts with Rails UJS.)
method: $(this).data('batch-method') || 'POST',
// The form submits to the link's standard href.
action: $(this).attr('href')
});
form.append($('<input>', { type: 'hidden', name: 'ids', value: ids.join(',') }));
form.append($('<input>', { type: 'hidden', name: 'authenticity_token', value: $('meta[name="csrf-token"]').attr('content') }));
form.appendTo('body').submit();
});
})
- Add
assets/js/react/_init.jsx:
import React from 'react';
import ReactDOM from 'react-dom/client';
import {Counter} from "./Counter"
const renderRoot = (selector, component) => {
const container = document.getElementById(selector);
if (!container) return;
ReactDOM.createRoot(container).render(component);
}
renderRoot('counter-root', <Counter />)
- Add
assets/js/react/Counter.jsx:
import React, {useState} from 'react';
export const Counter = () => {
const [count, setCount] = useState(0);
return (
<div className="react-counter">
<h3>React Test</h3>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
Testing
Add a route /test_page & page test_page.html.heex to test it all:
# in router.ex:
get "/test-page", PageController, :test_page
# in page_controller.ex:
def test_page(conn, _params) do
# Run a simple SELECT 1 + 1 postgres query to test the DB connection.
[[two]] = MyApp.Repo.query!("SELECT 1 + 1") |> Map.fetch!(:rows)
render(conn, :test_page, query_result: two)
end
<h2>Bootstrap, icons, SCSS</h2>
<div class="alert alert-success">Success message</div>
<div>An icon: <i class="bi bi-person-circle" /></div>
<span class="u-tooltip-target">Hover to see a CSS tooltip.<div class="u-tooltip u-tooltip-oneline">Here's the tooltip!</div></span>
<div><a href="#" data-js-on="click:toggle:#my-modal" class="btn btn-primary">Show modal</a></div>
<div id="my-modal" class="phx-modal-wrapper u-hidden">
<div class="phx-modal-backdrop" data-js-on="click:toggle:#my-modal"></div>
<div class="phx-modal">
<a href="#" class="position-absolute top-0 end-0 p-2" data-js-on="click:toggle:#my-modal">✖️</a>
<h3 class="mt-0">Modal Title</h3>
<p>Modal content</p>
</div>
</div>
<h2 class="mt-3">React</h2>
<div id="counter-root"></div>
<h2>DB connection</h2>
<div>1 + 1 = <%= @query_result %></div>
- Search for any
MyAppinstances and replace them with your app name. - Now start the app and test it out!
mix phx.serverhttp://localhost:4000/test-page
LiveView
- NOTE: The user references assume you’ve done the auth setup. Omit otherwise.
- In
MyAppWeb, undercontroller, add:import Phoenix.LiveView.Controller, only: [live_render: 3] - In
MyAppWeb, underlive_view, replace theuseand add some aliases:
# EXCLUDING layout because liveviews are rendered from controller endpoints.
use Phoenix.LiveView
alias MyApp.Repo
- Add a route
/test_livetoPageController#test_live - Add an endpoint
PageController#test_live:
def test_live(conn, _params) do
live_render(conn, MyAppWeb.TestLive, session: %{"count" => 0})
end
- Add
lib/myapp_web/live/test_live.ex:
defmodule MyAppWeb.TestLive do
use MyAppWeb, :live_view
def mount(_params, %{"current_user_id" => user_id} = session, socket) do
if connected?(socket), do: schedule_tick()
current_user = nil # to prevent errors when auth system is built
socket = assign(socket, count: Map.fetch!(session, "count"), current_user: current_user)
{:ok, socket}
end
def handle_event("add", %{"amount" => amount}, socket) do
{:noreply, assign(socket, count: socket.assigns.count + String.to_integer(amount))}
end
def handle_event("subtract", %{"amount" => amount}, socket) do
{:noreply, assign(socket, count: socket.assigns.count - String.to_integer(amount))}
end
def handle_info(:tick, socket) do
schedule_tick()
{:noreply, assign(socket, count: socket.assigns.count + 1)}
end
defp schedule_tick, do: Process.send_after(self(), :tick, 1000)
end
- Add
lib/myapp_web/live/test_live.html.heex:
<h1>Test liveview</h1>
Seconds passed: <%= @count %>
<a href="#" class="btn btn-success" phx-click="add" phx-value-amount="5">+5</a>
<a href="#" class="btn btn-success" phx-click="subtract" phx-value-amount="5">-5</a>
Testing
- Start the dev server and go to
/test_live, confirm the counter increments once per second and the buttons react as expected.
Deploy to Render
- In
prod.exs, update the Endpoint config and add a Repo config:
config :myapp, MyAppWeb.Endpoint,
cache_static_manifest: "priv/static/cache_manifest.json",
http: [port: String.to_integer(System.get_env("PORT") || "4000")],
url: [host: System.get_env("PHX_HOST") || raise("missing PHX_HOST"), scheme: "https", port: 443],
secret_key_base: System.get_env("SECRET_KEY_BASE") || raise("missing env SECRET_KEY_BASE")
config :playbl, Playbl.Repo,
url: System.get_env("DATABASE_URL") || raise("missing env DATABASE_URL"),
# Trace where in my code a query was called when query errors occur.
# Minor performance hit, but more than worth it.
stacktrace: true,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")
- Delete
runtime.exs, we won’t use it. - In
endpoint.exunderplug Plug.Staticsetgzip: trueso Phoenix will compress assets. - Add
./build.sh:
#!/usr/bin/env bash
#
# Render's build script that compiles the app & assets on each deploy.
# Adapted from https://render.com/docs/deploy-phoenix-distillery, but
# I'm not using Distillery anymore, the build process was causing problems.
#
# You can test it locally:
# > export MIX_ENV=prod DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/playbl_phoenix_dev SECRET_KEY_BASE=$(mix phx.gen.secret) PHX_HOST=localhost MAILGUN_API_KEY=abc MAILGUN_DOMAIN=playbl.com
# > sh build.sh
# > mix phx.server
#
# Then run this to clear out all digested assets:
# > mix phx.digest.clean --all
#
echo "Build starting."
# exit on error
set -o errexit
# Remove existing builds first if a stale build is causing problems.
# rm -rf "_build"
# error and exit unless MIX_ENV == "prod"
if [ "$MIX_ENV" != "prod" ]; then
echo "MIX_ENV must be set to 'prod' to build the app."
exit 1
fi
mix deps.get --only prod
mix compile
npm install --prefix assets/
# Build app.css
npm run sass-deploy --prefix assets/
# Build app.js
NODE_PATH=./deps mix esbuild default assets/js/app.js --bundle --minify --target=es2017 --outdir=./priv/static/assets
# Create digested assets
mix phx.digest
echo "Build complete. Ready to start."
- Make it executable:
chmod a+x build.sh - Create a Github repo, push all changes.
- Log into Render. Add a Postgres DB in a new project + environment for this app.
- Then add a web service in the same project + environment, linked to your Github repo:
- Build command:
./build.sh - Pre-deploy command:
mix ecto.migrate - Start command:
mix phx.server - Set environment variables:
SECRET_KEY_BASE(runmix phx.gen.secret)DATABASE_URL(The internal database URL of the database you created above)- Also set
ELIXIR_VERSION,ERLANG_VERSION, andNODE_VERSIONvars to match.tool-versions. More info.
- Click Create and watch the build logs to ensure it builds and deploys successfully.
- Go to the web service’s internal URL to test that the site runs correctly.
- Build command:
- Optionally set up one-line HTTP request logging per these steps.
- Add Papertrail logging:
- Go to Papertrail.com → “Add systems”, copy the url (eg
logs.papertrailapp.com:12345) - In Render’s main dashboard, go to Integrations → Observability → Log Streams → Add Default. Paste in the Papertrail destination URL (no token needed) and click Save.
- Load pages in the app to generate some logs, wait a minute, then check Papertrail, you should see new systems appear for those log sources. Optionally create a Group so you can view these Render log sources separately from your other apps.
- Optionally set up a search alert for error logs.
- In
config/config.exs, alter the:loggerconfig to remove$timeand:request_idto make the logs less noisy.
- Go to Papertrail.com → “Add systems”, copy the url (eg
- Add Rollbar error reporting:
- Go to rollbar.com, log in, and create a new “account” (aka project)
- Click “Complete the setup” for the autocreated project, select Elixir, and copy the server-side access token
- Rename the Rollbar project to describe this app
heroku config:set ROLLBAR_SERVER_SIDE_TOKEN={paste in token}- In
mix.exsadd the{:rollbax, "~> 0.11"}dep, then runmix deps.get - In
dev.exs, add:config :rollbax, enabled: false - In
prod.exs, add:
config :rollbax,
enabled: true,
access_token: System.get_env("ROLLBAR_SERVER_SIDE_TOKEN"),
environment: "prod"
- In
router.ex, add:use Plug.ErrorHandler # see handle_errors() belowand at the bottom:defp handle_errors(conn, data), do: PolaritiesWeb.ErrorPlugs.handle_errors(conn, data) - Add
lib/myapp_web/plugs/error_plugs.ex:
defmodule PolaritiesWeb.ErrorPlugs do
@moduledoc """
Report controller request errors to Rollbar.
This does NOT auto-report errors in unlinked background jobs, liveviews, etc.
# See https://hexdocs.pm/rollbax/using-rollbax-in-plug-based-applications.html
"""
def handle_errors(conn, %{kind: kind, reason: reason, stack: stacktrace}) do
conn =
conn
|> Plug.Conn.fetch_cookies()
|> Plug.Conn.fetch_query_params()
user = conn.assigns[:current_user]
user_string = if user, do: "#{user.id}"
params =
case conn.params do
%Plug.Conn.Unfetched{aspect: :params} -> "unfetched"
other -> other
end
request_data = %{
"request" => %{
"cookies" => conn.req_cookies,
"url" => "#{conn.scheme}://#{conn.host}:#{conn.port}#{conn.request_path}",
"user_ip" => List.to_string(:inet.ntoa(conn.remote_ip)),
"headers" => Enum.into(conn.req_headers, %{}),
"method" => conn.method,
"params" => params,
"user" => user_string
}
}
Rollbax.report(kind, reason, stacktrace, _custom_data = %{}, request_data)
# Explicitly log certain types of errors that Phoenix hides from the logs by default.
if reason.__struct__ in [Phoenix.ActionClauseError] do
msg = Exception.message(reason) |> H.truncate(2000, " (exception message truncated)")
Logger.log(:error, "%#{reason.__struct__}{}: #{msg}\n\nStacktrace:\n#{format_stacktrace(stacktrace)}")
end
end
defp format_stacktrace(stacktrace) do
stacktrace |> Enum.map(& exclude_args(&1)) |> Exception.format_stacktrace()
end
# Skip logging the args for each stacktrace line to make it less verbose.
defp exclude_args({module, atom, _args, location}), do: {module, atom, [], location}
end
- Add
lib/myapp/helpers.ex(copy from your auth system setup doc). - Add a
/test-errorendpoint which raises an exception. - Commit & deploy changes
- Go to
/test-errorand confirm the error is reported. - Note: This doesn’t report errors in LiveView processes. See Rollbax docs for LiveView error reporting.
- Add TelemetryUI
- https://hexdocs.pm/telemetry_ui/4.4.0/readme.html
- TODO: Copy changes from the relevant commit — it’s super quick to throw together a basic dashboard with request count + latency over time.