Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ jobs:
path: |
server/priv/plts

- name: Install dependencies
run: mix deps.get

# Create PLTs if no cache was found
- name: Create PLTs
if: steps.plt_cache.outputs.cache-hit != 'true'
Expand All @@ -69,8 +72,6 @@ jobs:
path: |
server/priv/plts

- name: Install dependencies
run: mix deps.get
- name: Compile
run: mix compile
- name: Create database
Expand Down
9 changes: 9 additions & 0 deletions compose.saas.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ services:
- 'traefik.http.routers.ethui-stacks-secure.tls.domains[0].main=stacks.ethui.dev'
- 'traefik.http.routers.ethui-stacks-secure.tls.domains[0].sans=*.stacks.ethui.dev'

# block external access to /metrics
- "traefik.http.routesr.ethui-stacks-secure.middlewares=block-metrics"
- "traefik.http.middlewares.block-metrics.replacepathregex.regex=^/metrics.*"
- "traefik.http.middlewares.block-metrics.replacepathregex.replacement=/404-not-found"

# enabling scraping from alloy/prometheus
- "prometheus.scrape=true"
- "prometheus.port=4000"

volumes:
data:

Expand Down
2 changes: 1 addition & 1 deletion server/config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ config :tailwind,
# Configures Elixir's Logger
config :logger, :console,
format: "$time $metadata[$level] $message\n",
metadata: [:request_id]
metadata: [:request_id, :remote_ip, :method, :path, :user_id, :stack_slug, :status, :duration]

# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason
Expand Down
25 changes: 25 additions & 0 deletions server/config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,31 @@ if config_env() == :prod do

config :ethui, :jwt_secret, jwt_secret

# JSON logging configuration for production
config :logger, :default_handler,
formatter:
{LoggerJSON.Formatters.Basic,
metadata: :all, exclude_metadata: [:domain, :erl_level, :gl, :time]}
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using metadata: :all in the logger configuration may capture excessive metadata and could lead to performance issues or large log sizes, especially in production. This setting captures all metadata fields from the Logger context, including internal Erlang/OTP fields and any custom metadata added throughout the application.

Consider explicitly listing the metadata fields you want to capture instead of using :all. The :console configuration already has an explicit list of metadata fields (lines 92-107), which is the recommended approach. Apply the same pattern to the :default_handler configuration.

Suggested change
metadata: :all, exclude_metadata: [:domain, :erl_level, :gl, :time]}
metadata: [
:request_id,
:user_id,
:stack_slug,
:remote_ip,
:method,
:path,
:status,
:duration,
:pid,
:application,
:module,
:function,
:file,
:line
]}

Copilot uses AI. Check for mistakes.

config :logger, :console,
format: {LoggerJSON.Formatters.Basic, :format},
metadata: [
:request_id,
:user_id,
:stack_slug,
:remote_ip,
:method,
:path,
:status,
:duration,
:pid,
:application,
:module,
:function,
:file,
:line
]

if is_saas? do
config :ethui, Ethui.Mailer,
adapter: Swoosh.Adapters.Mua,
Expand Down
10 changes: 10 additions & 0 deletions server/lib/ethui/telemetry.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
defmodule Ethui.Telemetry do
@moduledoc """
Helper module for executing telemetry events with the [:ethui] prefix.
"""

@spec exec(event :: [atom()], metadata :: map()) :: :ok
def exec(event, metadata \\ %{}) when is_list(event) do
:telemetry.execute([:ethui | event], %{count: 1}, metadata)
end
end
8 changes: 8 additions & 0 deletions server/lib/ethui_web/controllers/api/auth_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ defmodule EthuiWeb.Api.AuthController do
"""
def send_code(conn, %{"email" => email}) do
with {:ok, _user} <- Accounts.send_verification_code(email) do
Ethui.Telemetry.exec([:auth, :code_sent])

render(conn, :send_code, message: "Verification code sent")
end
end
Expand All @@ -24,12 +26,18 @@ defmodule EthuiWeb.Api.AuthController do
def verify_code(conn, %{"email" => email, "code" => code}) do
case Accounts.verify_code_and_generate_token(email, code) do
{:ok, token} ->
Ethui.Telemetry.exec([:auth, :code_verified], %{status: :success})

render(conn, :verify_code, token: token)

{:error, :invalid_code} ->
Ethui.Telemetry.exec([:auth, :code_verified], %{status: :invalid_code})

{:error, "Invalid or expired verification code"}

{:error, _reason} ->
Ethui.Telemetry.exec([:auth, :code_verified], %{status: :error})

{:error, "Verification failed"}
end
end
Expand Down
4 changes: 4 additions & 0 deletions server/lib/ethui_web/controllers/api/stack_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ defmodule EthuiWeb.Api.StackController do

with {:ok, stack} <- Stacks.create_stack(user, params),
_ <- Server.create(stack) do
Ethui.Telemetry.exec([:stacks, :created], %{user_id: user && user.id, stack_slug: stack.slug})

conn
|> put_status(:created)
|> render(:create, stack: stack)
Expand All @@ -45,6 +47,8 @@ defmodule EthuiWeb.Api.StackController do
:ok <- authorize_user_access(user, stack),
_ <- Server.destroy(stack),
{:ok, _} <- Stacks.delete_stack(stack) do
Ethui.Telemetry.exec([:stacks, :deleted], %{user_id: user && user.id, stack_slug: slug})

send_resp(conn, :no_content, "")
else
nil ->
Expand Down
16 changes: 16 additions & 0 deletions server/lib/ethui_web/controllers/fallback_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@ defmodule EthuiWeb.FallbackController do

use EthuiWeb, :controller

alias Ethui.Telemetry

# Handles Ecto changeset errors.
def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
Telemetry.exec([:errors], %{type: :changeset_error})

conn
|> put_status(:unprocessable_entity)
|> put_view(json: EthuiWeb.ChangesetJSON)
Expand All @@ -17,6 +21,8 @@ defmodule EthuiWeb.FallbackController do

# Handles not found errors.
def call(conn, {:error, :not_found}) do
Telemetry.exec([:errors], %{type: :not_found})

conn
|> put_status(:not_found)
|> put_view(json: EthuiWeb.ErrorJSON)
Expand All @@ -25,13 +31,17 @@ defmodule EthuiWeb.FallbackController do

# Handles unauthorized access errors.
def call(conn, {:error, :unauthorized}) do
Telemetry.exec([:errors], %{type: :unauthorized})

conn
|> put_status(:forbidden)
|> put_view(json: EthuiWeb.ErrorJSON)
|> render(:"403")
end

def call(conn, {:error, {:user_limit_exceeded, limit}}) do
Telemetry.exec([:errors], %{type: :user_limit_exceeded})

conn
|> put_status(:forbidden)
|> put_view(json: EthuiWeb.ErrorJSON)
Expand All @@ -41,6 +51,8 @@ defmodule EthuiWeb.FallbackController do
end

def call(conn, {:error, {:global_limit_exceeded, limit}}) do
Telemetry.exec([:errors], %{type: :global_limit_exceeded})

conn
|> put_status(:service_unavailable)
|> put_view(json: EthuiWeb.ErrorJSON)
Expand All @@ -51,6 +63,8 @@ defmodule EthuiWeb.FallbackController do

# Handles generic errors.
def call(conn, {:error, reason}) when is_binary(reason) do
Telemetry.exec([:errors], %{type: :validation_error})

conn
|> put_status(:unprocessable_entity)
|> put_view(json: EthuiWeb.ErrorJSON)
Expand All @@ -59,6 +73,8 @@ defmodule EthuiWeb.FallbackController do

# Handles unexpected errors.
def call(conn, _error) do
Telemetry.exec([:errors], %{type: :internal_server_error})

conn
|> put_status(:internal_server_error)
|> put_view(json: EthuiWeb.ErrorJSON)
Expand Down
15 changes: 15 additions & 0 deletions server/lib/ethui_web/controllers/metrics_controller.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
defmodule EthuiWeb.MetricsController do
use EthuiWeb, :controller

@moduledoc """
Exposes Prometheus metrics for scraping.
"""

def index(conn, _params) do
metrics = TelemetryMetricsPrometheus.Core.scrape(EthuiWeb.Telemetry.Prometheus)

conn
|> put_resp_content_type("text/plain")
|> send_resp(200, metrics)
end
end
1 change: 1 addition & 0 deletions server/lib/ethui_web/endpoint.ex
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ defmodule EthuiWeb.Endpoint do
cookie_key: "request_logger"

plug Plug.RequestId
plug EthuiWeb.Plugs.LogMetadata
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]

plug CORSPlug, origin: ["*"]
Expand Down
79 changes: 79 additions & 0 deletions server/lib/ethui_web/plugs/log_metadata.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
defmodule EthuiWeb.Plugs.LogMetadata do
@moduledoc """
Adds request-specific metadata to logs for structured JSON logging.

This plug enriches logs with contextual information including:
- Request ID, remote IP, HTTP method, and path
- User ID (if authenticated)
- Stack slug (if available from subdomain routing)
- Response status and duration (added before sending response)
"""

import Plug.Conn
require Logger

def init(opts), do: opts

def call(conn, _opts) do
Logger.metadata(
request_id: get_request_id(conn),
remote_ip: format_ip(conn.remote_ip),
method: conn.method,
path: conn.request_path
)

# Add user_id if authenticated
case conn.assigns[:current_user] do
%{id: user_id} -> Logger.metadata(user_id: user_id)
_ -> :ok
end

# Add stack_slug if available (from subdomain routing)
case conn.assigns[:stack] do
%{slug: slug} -> Logger.metadata(stack_slug: slug)
_ -> :ok
end

register_before_send(conn, fn conn ->
# Add response metadata before sending
Logger.metadata(
status: conn.status,
duration: calculate_duration(conn)
)

# Emit API request telemetry event
Ethui.Telemetry.exec(
[:api, :requests],
%{method: conn.method, path: conn.request_path, status: conn.status}
)

conn
end)
end

defp get_request_id(conn) do
case get_resp_header(conn, "x-request-id") do
[request_id] -> request_id
_ -> Logger.metadata()[:request_id]
end
end

defp format_ip({a, b, c, d}), do: "#{a}.#{b}.#{c}.#{d}"

defp format_ip({a, b, c, d, e, f, g, h}) do
# IPv6 address - convert to standard colon-separated hex notation
:inet.ntoa({a, b, c, d, e, f, g, h}) |> to_string()
end

defp calculate_duration(conn) do
case conn.private[:phoenix_endpoint_start] do
%{system: start} ->
System.monotonic_time()
|> Kernel.-(start)
|> System.convert_time_unit(:native, :microsecond)

_ ->
nil
end
end
end
5 changes: 5 additions & 0 deletions server/lib/ethui_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ defmodule EthuiWeb.Router do
end
end

# Metrics endpoint - accessible only within internal Docker network
scope "/" do
get "/metrics", EthuiWeb.MetricsController, :index
end

scope "/", EthuiWeb do
pipe_through :proxy

Expand Down
Loading
Loading