diff --git a/lib/realtime/api/feature_flag.ex b/lib/realtime/api/feature_flag.ex new file mode 100644 index 000000000..b8a6d836d --- /dev/null +++ b/lib/realtime/api/feature_flag.ex @@ -0,0 +1,29 @@ +defmodule Realtime.Api.FeatureFlag do + @moduledoc """ + Ecto schema for a global feature flag. + + Flags have a name (unique) and a boolean enabled state. Per-tenant overrides + are stored separately on the `Realtime.Api.Tenant` schema as a JSONB map, + not as associations on this record. + """ + + use Ecto.Schema + import Ecto.Changeset + + @type t :: %__MODULE__{} + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "feature_flags" do + field :name, :string + field :enabled, :boolean, default: false + timestamps() + end + + def changeset(flag, attrs) do + flag + |> cast(attrs, [:name, :enabled]) + |> validate_required([:name]) + |> unique_constraint(:name) + end +end diff --git a/lib/realtime/api/tenant.ex b/lib/realtime/api/tenant.ex index c0abeb512..03755a1c5 100644 --- a/lib/realtime/api/tenant.ex +++ b/lib/realtime/api/tenant.ex @@ -33,6 +33,7 @@ defmodule Realtime.Api.Tenant do field(:max_client_presence_events_per_window, :integer) field(:client_presence_window_ms, :integer) field(:presence_enabled, :boolean, default: false) + field(:feature_flags, :map, default: %{}) has_many(:extensions, Realtime.Api.Extensions, foreign_key: :tenant_external_id, @@ -82,7 +83,8 @@ defmodule Realtime.Api.Tenant do :broadcast_adapter, :max_client_presence_events_per_window, :client_presence_window_ms, - :presence_enabled + :presence_enabled, + :feature_flags ]) |> validate_required([:external_id]) |> check_constraint(:jwt_secret, diff --git a/lib/realtime/application.ex b/lib/realtime/application.ex index 9ef0c4e9b..cf36f6af7 100644 --- a/lib/realtime/application.ex +++ b/lib/realtime/application.ex @@ -121,6 +121,7 @@ defmodule Realtime.Application do id: Realtime.LogThrottle ), Realtime.Tenants.Cache, + Realtime.FeatureFlags.Cache, Realtime.RateCounter.DynamicSupervisor, Realtime.Latency, {Registry, keys: :duplicate, name: Realtime.Registry}, diff --git a/lib/realtime/feature_flags.ex b/lib/realtime/feature_flags.ex new file mode 100644 index 000000000..7796e2d07 --- /dev/null +++ b/lib/realtime/feature_flags.ex @@ -0,0 +1,73 @@ +defmodule Realtime.FeatureFlags do + @moduledoc """ + Manages feature flags with optional per-tenant overrides. + + Each flag has a global enabled/disabled state. Tenants can override that state + via a JSONB map stored on the tenant record. + + Use `enabled?/1` to check the global flag value only. + Use `enabled?/2` when the flag supports per-tenant overrides. Resolution order: + 1. Tenant-specific override (if present) + 2. Global flag value + 3. `false` when the flag does not exist + """ + + import Ecto.Query + alias Realtime.Api + alias Realtime.FeatureFlags.Cache + alias Realtime.Repo + alias Realtime.Api.FeatureFlag + alias Realtime.Tenants.Cache, as: TenantsCache + + @spec list_flags() :: [FeatureFlag.t()] + def list_flags, do: Repo.all(from f in FeatureFlag, order_by: [asc: f.name]) + + @spec get_flag(String.t()) :: FeatureFlag.t() | nil + def get_flag(name) when is_binary(name), do: Repo.get_by(FeatureFlag, name: name) + + @spec upsert_flag(map()) :: {:ok, FeatureFlag.t()} | {:error, Ecto.Changeset.t()} + def upsert_flag(attrs) do + %FeatureFlag{} + |> FeatureFlag.changeset(attrs) + |> Repo.insert(on_conflict: {:replace, [:enabled, :updated_at]}, conflict_target: :name, returning: true) + end + + @spec delete_flag(FeatureFlag.t()) :: {:ok, FeatureFlag.t()} | {:error, Ecto.Changeset.t()} + def delete_flag(%FeatureFlag{} = flag), do: Repo.delete(flag) + + @spec set_tenant_flag(String.t(), String.t(), boolean()) :: + {:ok, Realtime.Api.Tenant.t()} | {:error, :not_found | Ecto.Changeset.t()} + def set_tenant_flag(flag_name, tenant_id, enabled) + when is_binary(flag_name) and is_binary(tenant_id) and is_boolean(enabled) do + case Api.get_tenant_by_external_id(tenant_id, use_replica?: false) do + nil -> + {:error, :not_found} + + tenant -> + updated_flags = Map.put(tenant.feature_flags, flag_name, enabled) + Api.update_tenant_by_external_id(tenant_id, %{feature_flags: updated_flags}) + end + end + + @spec enabled?(String.t()) :: boolean() + def enabled?(flag_name) when is_binary(flag_name) do + case Cache.get_flag(flag_name) do + nil -> false + %FeatureFlag{enabled: enabled} -> enabled + end + end + + @spec enabled?(String.t(), String.t()) :: boolean() + def enabled?(flag_name, tenant_id) when is_binary(flag_name) and is_binary(tenant_id) do + case Cache.get_flag(flag_name) do + nil -> + false + + %FeatureFlag{enabled: global_enabled} -> + case TenantsCache.get_tenant_by_external_id(tenant_id) do + nil -> global_enabled + %{feature_flags: flags} -> Map.get(flags, flag_name, global_enabled) + end + end + end +end diff --git a/lib/realtime/feature_flags/cache.ex b/lib/realtime/feature_flags/cache.ex new file mode 100644 index 000000000..1e4807fe0 --- /dev/null +++ b/lib/realtime/feature_flags/cache.ex @@ -0,0 +1,60 @@ +defmodule Realtime.FeatureFlags.Cache do + @moduledoc """ + In-process Cachex cache for `Realtime.Api.FeatureFlag` records. + + Cache misses fall through to the database automatically via `Cachex.fetch/3`. + Nil results (flag not found) are intentionally not cached so that newly + created flags become visible without requiring an explicit invalidation. + + Use `global_update_cache/1` after mutations to push the updated struct to all + cluster nodes. Use `global_invalidate_cache/1` after deletes. + """ + + require Cachex.Spec + alias Realtime.Api.FeatureFlag + alias Realtime.GenRpc + alias Realtime.FeatureFlags + + def child_spec(_) do + tenant_cache_expiration = Application.get_env(:realtime, :tenant_cache_expiration) + + %{ + id: __MODULE__, + start: {Cachex, :start_link, [__MODULE__, [expiration: Cachex.Spec.expiration(default: tenant_cache_expiration)]]} + } + end + + @spec get_flag(String.t()) :: FeatureFlag.t() | nil + def get_flag(name) do + with {_, value} <- + Cachex.fetch(__MODULE__, cache_key(name), fn _key -> + with %FeatureFlag{} = flag <- FeatureFlags.get_flag(name), + do: {:commit, flag}, + else: (_ -> {:ignore, nil}) + end) do + value + end + end + + @spec update_cache(FeatureFlag.t()) :: {:ok, boolean()} | {:error, boolean()} + def update_cache(%FeatureFlag{} = flag) do + Cachex.put(__MODULE__, cache_key(flag.name), flag) + end + + @spec invalidate_cache(String.t()) :: {:ok, boolean()} | {:error, boolean()} + def invalidate_cache(name) when is_binary(name) do + Cachex.del(__MODULE__, cache_key(name)) + end + + @spec global_update_cache(FeatureFlag.t()) :: :ok + def global_update_cache(%FeatureFlag{} = flag) do + GenRpc.multicast(__MODULE__, :update_cache, [flag]) + end + + @spec global_invalidate_cache(FeatureFlag.t()) :: :ok + def global_invalidate_cache(%FeatureFlag{} = flag) do + GenRpc.multicast(__MODULE__, :invalidate_cache, [flag.name]) + end + + defp cache_key(name), do: {:get_flag, name} +end diff --git a/lib/realtime_web/dashboard/feature_flags.ex b/lib/realtime_web/dashboard/feature_flags.ex new file mode 100644 index 000000000..1d5e51a70 --- /dev/null +++ b/lib/realtime_web/dashboard/feature_flags.ex @@ -0,0 +1,224 @@ +defmodule RealtimeWeb.Dashboard.FeatureFlags do + @moduledoc """ + Phoenix LiveDashboard page for managing feature flags. + + Provides a UI to create, toggle, and delete global feature flags, and to + search for a tenant and override the flag value for that specific tenant. + """ + + use Phoenix.LiveDashboard.PageBuilder + + alias Realtime.FeatureFlags + alias Realtime.FeatureFlags.Cache + alias Realtime.Tenants.Cache, as: TenantsCache + + @impl true + def menu_link(_, _), do: {:ok, "Feature Flags"} + + @impl true + def mount(_params, _, socket) do + {:ok, reset_tenant_state(assign(socket, flags: FeatureFlags.list_flags()))} + end + + @impl true + def handle_event("toggle", %{"id" => id}, socket) do + flag = Enum.find(socket.assigns.flags, &(&1.id == id)) + + case FeatureFlags.upsert_flag(%{name: flag.name, enabled: !flag.enabled}) do + {:ok, updated} -> + Cache.global_update_cache(updated) + flags = Enum.map(socket.assigns.flags, fn f -> if f.id == id, do: updated, else: f end) + {:noreply, assign(socket, flags: flags)} + + {:error, _} -> + {:noreply, socket} + end + end + + @impl true + def handle_event("create", %{"name" => name}, socket) when name != "" do + case FeatureFlags.upsert_flag(%{name: String.trim(name), enabled: false}) do + {:ok, flag} -> + Cache.global_update_cache(flag) + flags = Enum.sort_by([flag | socket.assigns.flags], & &1.name) + {:noreply, assign(socket, flags: flags)} + + {:error, _} -> + {:noreply, socket} + end + end + + @impl true + def handle_event("create", _params, socket), do: {:noreply, socket} + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + flag = Enum.find(socket.assigns.flags, &(&1.id == id)) + + case FeatureFlags.delete_flag(flag) do + {:ok, _} -> + Cache.global_invalidate_cache(flag) + {:noreply, assign(socket, flags: Enum.reject(socket.assigns.flags, &(&1.id == id)))} + + {:error, _} -> + {:noreply, socket} + end + end + + @impl true + def handle_event("open_tenant_manager", %{"id" => id}, socket) do + {:noreply, reset_tenant_state(socket, managing_id: id)} + end + + @impl true + def handle_event("close_tenant_manager", _params, socket) do + {:noreply, reset_tenant_state(socket)} + end + + @impl true + def handle_event("search_tenant", %{"tenant_id" => tenant_id}, socket) do + case TenantsCache.get_tenant_by_external_id(String.trim(tenant_id)) do + nil -> + {:noreply, assign(socket, found_tenant: nil, tenant_error: "Tenant not found", tenant_search: tenant_id)} + + tenant -> + {:noreply, assign(socket, found_tenant: tenant, tenant_error: nil, tenant_search: tenant_id)} + end + end + + @impl true + def handle_event("set_tenant_flag", %{"flag_name" => flag_name, "enabled" => enabled}, socket) do + tenant = socket.assigns.found_tenant + + case FeatureFlags.set_tenant_flag(flag_name, tenant.external_id, enabled == "true") do + {:ok, updated_tenant} -> + {:noreply, assign(socket, found_tenant: updated_tenant)} + + {:error, _} -> + {:noreply, assign(socket, tenant_error: "Failed to update tenant flag")} + end + end + + defp reset_tenant_state(socket, extra \\ []) do + assign(socket, [managing_id: nil, tenant_search: "", found_tenant: nil, tenant_error: nil] ++ extra) + end + + @impl true + def render(assigns) do + ~H""" +
+
Feature Flags
+ +
+ + +
+ + + + + + + + + + + <%= for flag <- @flags do %> + + + + + + <%= if @managing_id == flag.id do %> + + + + <% end %> + <% end %> + +
NameStatusActions
<%= flag.name %> +
+ + + <%= if flag.enabled, do: "Enabled", else: "Disabled" %> + +
+
+
+ + +
+
+
+ Tenant flag: <%= flag.name %> + +
+ +
+ + +
+ + <%= if @tenant_error do %> +

<%= @tenant_error %>

+ <% end %> + + <%= if @found_tenant do %> + <% flag_enabled = Map.get(@found_tenant.feature_flags, flag.name, flag.enabled) %> +
+ <%= @found_tenant.external_id %> + +
+ + + <%= if flag_enabled, do: "Enabled", else: "Disabled" %> + +
+
+ <% end %> +
+
+ """ + end +end diff --git a/lib/realtime_web/live/feature_flags_live/index.ex b/lib/realtime_web/live/feature_flags_live/index.ex new file mode 100644 index 000000000..9cd5cd83b --- /dev/null +++ b/lib/realtime_web/live/feature_flags_live/index.ex @@ -0,0 +1,85 @@ +defmodule RealtimeWeb.FeatureFlagsLive.Index do + use RealtimeWeb, :live_view + + alias Realtime.FeatureFlags + alias Realtime.FeatureFlags.Cache + alias RealtimeWeb.Endpoint + + @impl true + def mount(_params, _session, socket) do + if connected?(socket), do: Endpoint.subscribe("feature_flags") + + {:ok, assign(socket, flags: FeatureFlags.list_flags(), new_name: "")} + end + + @impl true + def handle_params(_params, _url, socket) do + {:noreply, assign(socket, :page_title, "Feature Flags")} + end + + @impl true + def handle_event("toggle", %{"id" => id}, socket) do + flag = Enum.find(socket.assigns.flags, &(&1.id == id)) + + case FeatureFlags.upsert_flag(%{name: flag.name, enabled: !flag.enabled}) do + {:ok, updated} -> + Cache.global_update_cache(updated) + Endpoint.broadcast_from(self(), "feature_flags", "updated", updated) + flags = Enum.map(socket.assigns.flags, fn f -> if f.id == id, do: updated, else: f end) + {:noreply, assign(socket, flags: flags)} + + {:error, _} -> + {:noreply, socket} + end + end + + @impl true + def handle_event("create", %{"name" => name}, socket) when name != "" do + case FeatureFlags.upsert_flag(%{name: String.trim(name), enabled: false}) do + {:ok, flag} -> + Cache.global_update_cache(flag) + Endpoint.broadcast_from(self(), "feature_flags", "updated", flag) + flags = Enum.sort_by([flag | socket.assigns.flags], & &1.name) + {:noreply, assign(socket, flags: flags, new_name: "")} + + {:error, _changeset} -> + {:noreply, socket} + end + end + + @impl true + def handle_event("create", _params, socket), do: {:noreply, socket} + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + flag = Enum.find(socket.assigns.flags, &(&1.id == id)) + + case FeatureFlags.delete_flag(flag) do + {:ok, _} -> + Cache.global_invalidate_cache(flag) + Endpoint.broadcast_from(self(), "feature_flags", "deleted", %{name: flag.name}) + {:noreply, assign(socket, flags: Enum.reject(socket.assigns.flags, &(&1.id == id)))} + + {:error, _} -> + {:noreply, socket} + end + end + + @impl true + def handle_info(%Phoenix.Socket.Broadcast{event: "updated", payload: updated}, socket) do + flags = + if Enum.any?(socket.assigns.flags, &(&1.id == updated.id)) do + Enum.map(socket.assigns.flags, fn f -> if f.id == updated.id, do: updated, else: f end) + else + Enum.sort_by([updated | socket.assigns.flags], & &1.name) + end + + {:noreply, assign(socket, flags: flags)} + end + + @impl true + def handle_info(%Phoenix.Socket.Broadcast{event: "deleted", payload: %{name: name}}, socket) do + flags = Enum.reject(socket.assigns.flags, &(&1.name == name)) + {:noreply, assign(socket, flags: flags)} + end +end diff --git a/lib/realtime_web/live/feature_flags_live/index.html.heex b/lib/realtime_web/live/feature_flags_live/index.html.heex new file mode 100644 index 000000000..0eb694a5e --- /dev/null +++ b/lib/realtime_web/live/feature_flags_live/index.html.heex @@ -0,0 +1,66 @@ +<.h1>Feature Flags + +
+
+ + +
+ + + + + + + + + + + <%= for flag <- @flags do %> + + + + + + <% end %> + +
NameStatusActions
<%= flag.name %> +
+ + + <%= if flag.enabled, do: "Enabled", else: "Disabled" %> + +
+
+ +
+
diff --git a/lib/realtime_web/router.ex b/lib/realtime_web/router.ex index bdb4836fc..a334142d1 100644 --- a/lib/realtime_web/router.ex +++ b/lib/realtime_web/router.ex @@ -69,6 +69,7 @@ defmodule RealtimeWeb.Router do scope "/admin", RealtimeWeb do pipe_through [:browser, :dashboard_admin] live("/tenants", TenantsLive.Index, :index) + live("/feature-flags", FeatureFlagsLive.Index, :index) end scope "/metrics", RealtimeWeb do @@ -130,7 +131,8 @@ defmodule RealtimeWeb.Router do tenant_info: RealtimeWeb.Dashboard.TenantInfo, recon_trace: RealtimeWeb.Dashboard.ReconTrace, node_info: RealtimeWeb.Dashboard.NodeInfo, - sql_inspector: RealtimeWeb.Dashboard.SqlInspector + sql_inspector: RealtimeWeb.Dashboard.SqlInspector, + feature_flags: RealtimeWeb.Dashboard.FeatureFlags ] ) end diff --git a/mise.toml b/mise.toml index 1efa32920..3a92bd5e1 100644 --- a/mise.toml +++ b/mise.toml @@ -4,8 +4,11 @@ erlang = "27" node = "24" [env] +API_JWT_SECRET = "dev" DB_ENC_KEY = "1234567890123456" METRICS_JWT_SECRET = "dev" +DASHBOARD_USER = "admin" +DASHBOARD_PASSWORD = "admin" [tasks.dev] description = "Start the dev server" diff --git a/priv/repo/migrations/20260422000000_create_feature_flags.exs b/priv/repo/migrations/20260422000000_create_feature_flags.exs new file mode 100644 index 000000000..3792025b4 --- /dev/null +++ b/priv/repo/migrations/20260422000000_create_feature_flags.exs @@ -0,0 +1,18 @@ +defmodule Realtime.Repo.Migrations.CreateFeatureFlags do + use Ecto.Migration + + def change do + create table(:feature_flags, primary_key: false) do + add :id, :binary_id, primary_key: true + add :name, :string, null: false + add :enabled, :boolean, null: false, default: false + timestamps() + end + + create unique_index(:feature_flags, [:name]) + + alter table(:tenants) do + add :feature_flags, :map, null: false, default: %{} + end + end +end diff --git a/test/realtime/feature_flags_test.exs b/test/realtime/feature_flags_test.exs new file mode 100644 index 000000000..d4c8c0e2a --- /dev/null +++ b/test/realtime/feature_flags_test.exs @@ -0,0 +1,121 @@ +defmodule Realtime.FeatureFlagsTest do + use Realtime.DataCase, async: false + + alias Realtime.Api.FeatureFlag + alias Realtime.FeatureFlags + alias Realtime.FeatureFlags.Cache + alias Realtime.Tenants.Cache, as: TenantsCache + + setup do + Cachex.clear(Cache) + Cachex.clear(TenantsCache) + :ok + end + + describe "list_flags/0" do + test "returns all flags ordered by name" do + {:ok, _} = FeatureFlags.upsert_flag(%{name: "zebra_flag", enabled: false}) + {:ok, _} = FeatureFlags.upsert_flag(%{name: "alpha_flag", enabled: true}) + + assert FeatureFlags.list_flags() |> Enum.map(& &1.name) |> Enum.sort() == ["alpha_flag", "zebra_flag"] + end + end + + describe "get_flag/1" do + test "returns the flag when it exists" do + {:ok, flag} = FeatureFlags.upsert_flag(%{name: "my_flag", enabled: true}) + assert %FeatureFlag{name: "my_flag"} = FeatureFlags.get_flag("my_flag") + assert FeatureFlags.get_flag("my_flag").id == flag.id + end + + test "returns nil when flag does not exist" do + refute FeatureFlags.get_flag("nonexistent") + end + end + + describe "upsert_flag/1" do + test "inserts a new flag" do + assert {:ok, %FeatureFlag{name: "new_flag", enabled: false}} = + FeatureFlags.upsert_flag(%{name: "new_flag", enabled: false}) + end + + test "updates an existing flag" do + {:ok, _} = FeatureFlags.upsert_flag(%{name: "existing", enabled: false}) + + assert {:ok, %FeatureFlag{name: "existing", enabled: true}} = + FeatureFlags.upsert_flag(%{name: "existing", enabled: true}) + + assert FeatureFlags.list_flags() |> Enum.count(&(&1.name == "existing")) == 1 + end + + test "returns error changeset when name is missing" do + assert {:error, changeset} = FeatureFlags.upsert_flag(%{enabled: false}) + assert "can't be blank" in errors_on(changeset).name + end + end + + describe "delete_flag/1" do + test "removes the flag" do + {:ok, flag} = FeatureFlags.upsert_flag(%{name: "to_delete", enabled: false}) + assert {:ok, _} = FeatureFlags.delete_flag(flag) + refute FeatureFlags.get_flag("to_delete") + end + end + + describe "enabled?/1" do + test "returns false when flag does not exist" do + refute FeatureFlags.enabled?("missing_flag") + end + + test "returns false when flag is disabled" do + {:ok, _} = FeatureFlags.upsert_flag(%{name: "off_flag", enabled: false}) + refute FeatureFlags.enabled?("off_flag") + end + + test "returns true when flag is enabled" do + {:ok, _} = FeatureFlags.upsert_flag(%{name: "on_flag", enabled: true}) + assert FeatureFlags.enabled?("on_flag") + end + end + + describe "enabled?/2" do + test "returns false when flag does not exist" do + refute FeatureFlags.enabled?("missing_flag", "tenant_1") + end + + test "returns false when flag is disabled and tenant has no entry (follows global)" do + {:ok, _} = FeatureFlags.upsert_flag(%{name: "off_flag", enabled: false}) + tenant = tenant_fixture(%{feature_flags: %{}}) + refute FeatureFlags.enabled?("off_flag", tenant.external_id) + end + + test "returns true when flag is disabled globally but tenant has it explicitly enabled" do + {:ok, _} = FeatureFlags.upsert_flag(%{name: "tenant_override_flag", enabled: false}) + tenant = tenant_fixture(%{feature_flags: %{"tenant_override_flag" => true}}) + assert FeatureFlags.enabled?("tenant_override_flag", tenant.external_id) + end + + test "returns global value when flag is enabled but tenant does not exist" do + {:ok, _} = FeatureFlags.upsert_flag(%{name: "enabled_flag", enabled: true}) + assert FeatureFlags.enabled?("enabled_flag", "nonexistent_tenant") + end + + test "returns true when flag is enabled and tenant has no entry (follows global)" do + {:ok, _} = FeatureFlags.upsert_flag(%{name: "partial_flag", enabled: true}) + tenant = tenant_fixture(%{feature_flags: %{}}) + assert FeatureFlags.enabled?("partial_flag", tenant.external_id) + end + + test "returns true when flag is enabled and tenant has it explicitly enabled" do + {:ok, _} = FeatureFlags.upsert_flag(%{name: "tenant_flag", enabled: true}) + tenant = tenant_fixture(%{feature_flags: %{"tenant_flag" => true}}) + assert FeatureFlags.enabled?("tenant_flag", tenant.external_id) + end + + test "returns false when flag is enabled but tenant has it explicitly disabled" do + {:ok, _} = FeatureFlags.upsert_flag(%{name: "disabled_for_tenant", enabled: true}) + tenant = tenant_fixture(%{feature_flags: %{"disabled_for_tenant" => false}}) + refute FeatureFlags.enabled?("disabled_for_tenant", tenant.external_id) + end + end +end