defmodule Dialup.UserSessionProcess do @moduledoc false use GenServer @session_timeout :timer.minutes(5) # DynamicSupervisor から起動される(引数はタプル) # registry_key: タブごとの一意キー(tab_id || session_id) # session_id: Cookie由来のセッションID(ETS永続化に使用) def start_link({socket_pid, app_module, session_id, registry_key}) do GenServer.start_link(__MODULE__, %{ socket_pid: socket_pid, app_module: app_module, session_id: session_id, registry_key: registry_key }) end def init_session(pid, path), do: GenServer.cast(pid, {:init, path}) def navigate(pid, path), do: GenServer.cast(pid, {:navigate, path}) def event(pid, event, value), do: GenServer.cast(pid, {:event, event, value}) def session_id(pid), do: GenServer.call(pid, :get_session_id) def take_over(pid, new_socket_pid), do: GenServer.cast(pid, {:take_over, new_socket_pid}) def reconnect(pid, path), do: GenServer.cast(pid, {:reconnect, path}) def agent_describe(pid, token), do: GenServer.call(pid, {:agent_describe, token}) def agent_tools(pid, token), do: GenServer.call(pid, {:agent_tools, token}) def agent_call(pid, token, name, arguments), do: GenServer.call(pid, {:agent_call, token, name, arguments}) def revoke_agent(pid, token), do: GenServer.call(pid, {:agent_revoke, token}) def issue_agent_grant(pid, opts), do: GenServer.call(pid, {:agent_grant, opts}) def issue_browser_handoff(pid), do: GenServer.call(pid, :issue_browser_handoff) @impl GenServer def init(%{ socket_pid: socket_pid, app_module: app_module, session_id: session_id, registry_key: registry_key }) do {:ok, _} = Registry.register(Dialup.SessionRegistry, registry_key, nil) agent_token = :crypto.strong_rand_bytes(24) |> Base.url_encode64(padding: false) {:ok, _} = Registry.register(Dialup.SessionRegistry, {:agent_token, agent_token}, nil) {:ok, agent_proxy} = Dialup.Agent.RegistryProxy.start_link(self(), agent_token) ref = Process.monitor(socket_pid) base_state = %{ path: nil, socket_pid: socket_pid, app_module: app_module, session_id: session_id, session: %{}, session_keys: MapSet.new(), assigns: %{}, params: %{}, subscriptions: [], monitor_ref: ref, timeout_ref: nil, agent_token: agent_token, agent_grants: %{ agent_token => Dialup.Agent.Grant.new(agent_token, %{expires_in: :infinity}) }, agent_proxies: %{agent_token => agent_proxy}, version: 0, audit_log: [], ui_locked: false, lock_reason: nil } state = if uses_ets_store?(app_module) do case Dialup.SessionStore.restore(session_id) do {:ok, %{session: session, assigns: assigns, path: path}} -> session_keys = MapSet.new(Map.keys(session)) %{ base_state | session: session, session_keys: session_keys, assigns: assigns, path: path } :error -> base_state end else base_state end {:ok, state} end @impl GenServer def handle_call(:get_session_id, _from, state) do {:reply, state.session_id, state} end def handle_call({:agent_describe, token}, _from, state) do case fetch_grant(state, token) do {:ok, grant} -> page_module = current_page(state) assigns = merge_for_render(state) discovery = if page_module do Dialup.Agent.connected_discovery( page_module, assigns, state.path, state.version, grant, token, layout_actions(state) ) else nil end {:reply, {:ok, %{ "endpoint" => Dialup.Agent.endpoint(token), "path" => state.path, "version" => state.version, "grant" => Dialup.Agent.Grant.public(grant), "agentDiscovery" => discovery }}, state} error -> {:reply, error, state} end end def handle_call({:agent_tools, token}, _from, state) do result = with {:ok, grant} <- fetch_grant(state, token) do tools = case current_page(state) do nil -> [] page_module -> Dialup.Agent.tools( page_module, merge_for_render(state), grant, layout_actions(state) ) end {:ok, tools} end {:reply, result, state} end def handle_call({:agent_call, token, "read_scene", _arguments}, _from, state) do case fetch_grant(state, token) do {:ok, grant} -> {:reply, {:ok, current_scene(state, grant)}, audit(state, token, "read_scene", :ok)} error -> {:reply, error, state} end end def handle_call({:agent_call, token, "read_audit_log", _arguments}, _from, state) do case fetch_grant(state, token) do {:ok, grant} -> if Dialup.Agent.Grant.allows?(grant, "read_audit_log") do entries = state.audit_log |> Enum.reverse() |> Enum.map(&json_safe_audit/1) {:reply, {:ok, %{"entries" => entries}}, state} else {:reply, {:error, :forbidden}, state} end error -> {:reply, error, state} end end def handle_call({:agent_call, token, "lock_ui", arguments}, _from, state) do case fetch_grant(state, token) do {:ok, grant} -> if Dialup.Agent.Grant.allows?(grant, "lock_ui") do reason = arguments["reason"] || arguments[:reason] locked = %{state | ui_locked: true, lock_reason: reason} update_page(locked) locked = audit(locked, token, "lock_ui", :ok, %{"reason" => reason}) {:reply, {:ok, lock_result(locked, grant)}, locked} else {:reply, {:error, :forbidden}, state} end error -> {:reply, error, state} end end def handle_call({:agent_call, token, "unlock_ui", _arguments}, _from, state) do case fetch_grant(state, token) do {:ok, grant} -> if Dialup.Agent.Grant.allows?(grant, "lock_ui") do unlocked = %{state | ui_locked: false, lock_reason: nil} update_page(unlocked) unlocked = audit(unlocked, token, "unlock_ui", :ok) {:reply, {:ok, lock_result(unlocked, grant)}, unlocked} else {:reply, {:error, :forbidden}, state} end error -> {:reply, error, state} end end def handle_call({:agent_call, token, name, arguments}, _from, state) do {reply, new_state} = invoke_agent_action(state, token, name, arguments) {:reply, reply, new_state} end def handle_call({:agent_revoke, token}, _from, state) do case Map.fetch(state.agent_grants, token) do {:ok, grant} -> grants = Map.put(state.agent_grants, token, Dialup.Agent.Grant.revoke(grant)) {:reply, :ok, %{state | agent_grants: grants}} :error -> {:reply, {:error, :not_found}, state} end end def handle_call({:agent_grant, opts}, _from, state) do {reply, state} = create_agent_grant(state, opts) {:reply, reply, state} end def handle_call(:issue_browser_handoff, _from, state) do case current_page(state) do nil -> {:reply, {:error, :not_ready}, state} page_module -> opts = page_module.agent_grant(merge_for_render(state)) {reply, state} = create_agent_grant(state, opts) {:reply, reply, audit(state, :human, "issue_agent_handoff", :ok)} end end # 初回接続:layout.mount → session、page.mount → assigns @impl GenServer def handle_cast({:init, path}, state) do try do params = state.app_module.path_params(path) Process.put(:dialup_subscriptions, []) {session, session_keys} = mount_session(path, state.app_module) assigns = mount_page(path, params, session, session_keys, state.app_module) subs = Process.get(:dialup_subscriptions, []) new_state = %{ state | path: path, params: params, session: session, session_keys: session_keys, assigns: assigns, subscriptions: subs } |> configure_primary_grant() |> update_page() {:noreply, new_state} rescue e -> new_state = %{state | path: path} send_error(new_state, e, __STACKTRACE__) {:noreply, new_state} end end # 再接続:プロセスが生存していれば現在のstateで再描画、タイムアウト済みならフルmount @impl GenServer def handle_cast({:reconnect, path}, state) do if state.path == nil do try do Process.put(:dialup_subscriptions, []) params = state.app_module.path_params(path) {session, session_keys} = mount_session(path, state.app_module) assigns = mount_page(path, params, session, session_keys, state.app_module) subs = Process.get(:dialup_subscriptions, []) new_state = %{ state | path: path, params: params, session: session, session_keys: session_keys, assigns: assigns, subscriptions: subs } |> configure_primary_grant() |> update_page() {:noreply, new_state} rescue e -> new_state = %{state | path: path} send_error(new_state, e, __STACKTRACE__) {:noreply, new_state} end else {:noreply, update_page(state)} end end # 再接続時のsocket_pid引き継ぎ @impl GenServer def handle_cast({:take_over, new_socket_pid}, state) do if state.monitor_ref, do: Process.demonitor(state.monitor_ref, [:flush]) if state.timeout_ref, do: Process.cancel_timer(state.timeout_ref) ref = Process.monitor(new_socket_pid) {:noreply, %{state | socket_pid: new_socket_pid, monitor_ref: ref, timeout_ref: nil}} end @impl GenServer def handle_cast({:event, _event, _value}, %{ui_locked: true} = state) do # UI is locked by an agent; ignore human interactions and re-assert the # current view so any optimistic client state is corrected. {:noreply, update_page(state)} end def handle_cast({:event, event, value}, state) do case state.app_module.page_for(state.path) do nil -> {:noreply, state} page_module -> start_time = Dialup.Telemetry.event_start(event, state.path) try do resolved_event = resolve_event(page_module, event) result = case apply_event(page_module, resolved_event, value, state) do {:ok, new_state} -> {:noreply, audit(new_state, :human, to_string(resolved_event), :ok, value)} {:error, reason} -> raise "event failed: #{inspect(reason)}" end Dialup.Telemetry.event_stop(start_time, event, state.path) result rescue e -> Dialup.Telemetry.event_exception( start_time, event, state.path, :error, e, __STACKTRACE__ ) send_error(state, e, __STACKTRACE__) {:noreply, state} end end end # ページ遷移:session は保持、assigns をリセットして page.mount を呼ぶ @impl GenServer def handle_cast({:navigate, _path}, %{ui_locked: true} = state) do {:noreply, update_page(state)} end def handle_cast({:navigate, path}, state) do {:noreply, do_navigate(path, bump_version(state))} end # ホットリロード:ファイル変更時に現在の state で再描画 @impl GenServer def handle_info(:dialup_reload, state) do try do {:noreply, update_page(state)} rescue e -> send_error(state, e, __STACKTRACE__) {:noreply, state} end end # WebSocketプロセスが落ちたら即死せず、タイムアウトまで生存する @impl GenServer def handle_info({:DOWN, ref, :process, _pid, _reason}, %{monitor_ref: ref} = state) do timeout_ref = Process.send_after(self(), :session_timeout, @session_timeout) {:noreply, %{state | socket_pid: nil, monitor_ref: nil, timeout_ref: timeout_ref}} end def handle_info(:session_timeout, state) do if uses_ets_store?(state.app_module) and state.path do Dialup.SessionStore.save(state.session_id, state.session, state.assigns, state.path) end {:stop, :normal, state} end # その他のメッセージは現在のページモジュールの handle_info/2 に委譲する def handle_info(msg, state), do: delegate_info(msg, state) defp delegate_info(msg, state) do case state.app_module.page_for(state.path) do nil -> {:noreply, state} page_module -> merged = merge_for_render(state) try do case page_module.handle_info(msg, merged) do {:noreply, new_merged} -> {new_session, new_assigns} = split_assigns(new_merged, state.session_keys) new_state = bump_version(%{state | session: new_session, assigns: new_assigns}) {:noreply, new_state} {:update, new_merged} -> {new_session, new_assigns} = split_assigns(new_merged, state.session_keys) new_state = bump_version(%{state | session: new_session, assigns: new_assigns}) {:noreply, update_page(new_state)} {:patch, target, rendered, new_merged} -> {new_session, new_assigns} = split_assigns(new_merged, state.session_keys) new_state = bump_version(%{state | session: new_session, assigns: new_assigns}) html = to_html(rendered) payload = Jason.encode!(%{target: target, html: html}) if state.socket_pid, do: send(state.socket_pid, {:send_html, payload}) {:noreply, new_state} {:redirect, path, new_merged} -> {new_session, new_assigns} = split_assigns(new_merged, state.session_keys) new_state = bump_version(%{state | session: new_session, assigns: new_assigns}) {:noreply, do_navigate(path, new_state)} {:push_event, event_name, event_payload, new_merged} -> {new_session, new_assigns} = split_assigns(new_merged, state.session_keys) new_state = bump_version(%{state | session: new_session, assigns: new_assigns}) send_push_event(new_state, event_name, event_payload) {:noreply, new_state} end rescue e -> send_error(state, e, __STACKTRACE__) {:noreply, state} end end end # session を保持しつつ新しいパスに遷移する(navigate / redirect 共通) defp do_navigate(path, state) do start_time = Dialup.Telemetry.navigate_start(path) try do Enum.each(state.subscriptions, fn {pubsub, topic} -> Phoenix.PubSub.unsubscribe(pubsub, topic) end) Process.put(:dialup_subscriptions, []) params = state.app_module.path_params(path) assigns = mount_page(path, params, state.session, state.session_keys, state.app_module) subs = Process.get(:dialup_subscriptions, []) result = %{state | path: path, params: params, assigns: assigns, subscriptions: subs} |> configure_primary_grant() |> update_page() Dialup.Telemetry.navigate_stop(start_time, path) result rescue e -> Dialup.Telemetry.navigate_exception(start_time, path, :error, e, __STACKTRACE__) new_state = %{state | path: path} send_error(new_state, e, __STACKTRACE__) new_state end end defp send_error(state, exception, stacktrace) do if state.socket_pid do html = render_error_page(500, state, exception, stacktrace) payload = Jason.encode!(%{html: html, path: state.path || "/"}) send(state.socket_pid, {:send_html, payload}) end end defp render_error_page(status, state, exception \\ nil, stacktrace \\ nil) do case state.app_module.error_page_for(state.path) do nil -> default_error_html(status, exception, stacktrace) error_module -> error_assigns = %{ status: status, message: if(exception, do: Exception.message(exception), else: status_message(status)) } error_assigns = if exception && Mix.env() == :dev do error_assigns |> Map.put(:exception, exception) |> Map.put(:stacktrace, Exception.format(:error, exception, stacktrace || [])) else error_assigns end try do error_html = error_module.render(status, error_assigns) |> Phoenix.HTML.Safe.to_iodata() |> IO.iodata_to_binary() use_layout = if function_exported?(error_module, :__layout__, 0), do: error_module.__layout__(), else: true if use_layout do layouts = state.app_module.layouts_for(state.path || "/") render_assigns = merge_for_render(state) Dialup.Router.render_with_layouts_raw( error_html, error_module, layouts, render_assigns ) else wrap_error_with_scope(error_html, error_module) end rescue _ -> default_error_html(status, exception, stacktrace) end end end defp wrap_error_with_scope(html, error_module) do if function_exported?(error_module, :__css_scope__, 0) do case error_module.__css_scope__() do nil -> html scope -> css = error_module.__css__() || "" css_tag = if css != "", do: ~s(), else: "" css_tag <> ~s(
#{formatted_trace}
#{escaped}
) end end) formatted = lines |> Enum.map(fn line -> escaped = escape_html(line) cond do String.contains?(line, "(dialup") or String.contains?(line, "(elixir") or String.contains?(line, "(stdlib") -> ~s(#{escaped}) String.contains?(line, ".ex:") -> ~s(#{escaped}) String.starts_with?(String.trim(line), "**") -> ~s(#{escaped}) true -> escaped end end) |> Enum.join("\n") {file_line, formatted} end defp escape_html(str) do str |> String.replace("&", "&") |> String.replace("<", "<") |> String.replace(">", ">") end # layout.mount を順番に呼び、session と session_keys を構築する # 親レイアウトの結果が子レイアウトの mount に渡される defp mount_session(path, app_module) do layouts = app_module.layouts_for(path) Enum.reduce(layouts, {%{}, MapSet.new()}, fn layout_mod, {session, keys} -> {:ok, new_session} = layout_mod.mount(session) new_keys = MapSet.new(Map.keys(new_session)) {new_session, MapSet.union(keys, new_keys)} end) end # page.mount を呼び、session キーを除いた純粋な page assigns を返す defp mount_page(path, params, session, session_keys, app_module) do case app_module.page_for(path) do nil -> %{} page_module -> # page.mount は session を reads として受け取れる(current_user 等を参照可能) {:ok, result} = page_module.mount(params, session) # session キーを除いた部分だけが page assigns Map.drop(result, MapSet.to_list(session_keys)) end end # session と assigns を合成して render 用 assigns を作る defp merge_for_render(state) do state.session |> Map.merge(state.assigns) |> Map.put(:params, state.params) |> Map.put(:current_path, state.path) |> Map.put(:dialup_agent, %{version: state.version, ui_locked: state.ui_locked}) end # handle_event/handle_info の返り値を session と assigns に分割する defp split_assigns(merged, session_keys) do new_session = Map.take(merged, MapSet.to_list(session_keys)) new_assigns = Map.drop(merged, MapSet.to_list(session_keys) ++ [:params, :current_path, :dialup_agent]) {new_session, new_assigns} end defp render(state) do render_assigns = merge_for_render(state) case state.app_module.dispatch(state.path, render_assigns) do {:ok, html} -> html {:error, :not_found} -> render_error_page(404, state) end end defp put_title(payload, state) do case state.app_module.page_for(state.path) do nil -> payload page_module -> merged = merge_for_render(state) case page_module.page_title(merged) do nil -> payload title -> Map.put(payload, :title, title) end end end defp uses_ets_store?(app_module) do function_exported?(app_module, :__session_store__, 0) and app_module.__session_store__() == :ets end defp to_html(rendered) when is_binary(rendered), do: rendered defp to_html(rendered) do rendered |> Phoenix.HTML.Safe.to_iodata() |> IO.iodata_to_binary() end defp update_page(state) do if state.socket_pid do html = render(state) payload = %{html: html, path: state.path} payload = put_title(payload, state) payload = put_ui_lock(payload, state) send(state.socket_pid, {:send_html, Jason.encode!(payload)}) end state end defp put_ui_lock(payload, state) do payload |> Map.put(:ui_locked, state.ui_locked) |> Map.put(:lock_reason, state.lock_reason) end defp lock_result(state, grant) do state |> current_scene(grant) |> Map.put("uiLocked", state.ui_locked) |> Map.put("lockReason", state.lock_reason) end defp apply_event(page_module, event, value, state) do merged = merge_for_render(state) try do case page_module.handle_event(event, value, merged) do {:noreply, new_merged} -> {new_session, new_assigns} = split_assigns(new_merged, state.session_keys) new_state = bump_version(%{state | session: new_session, assigns: new_assigns}) {:ok, new_state} {:update, new_merged} -> {new_session, new_assigns} = split_assigns(new_merged, state.session_keys) new_state = bump_version(%{state | session: new_session, assigns: new_assigns}) {:ok, update_page(new_state)} {:patch, target, rendered, new_merged} -> {new_session, new_assigns} = split_assigns(new_merged, state.session_keys) new_state = bump_version(%{state | session: new_session, assigns: new_assigns}) html = to_html(rendered) payload = Jason.encode!(%{target: target, html: html}) if state.socket_pid, do: send(state.socket_pid, {:send_html, payload}) {:ok, new_state} {:redirect, path, new_merged} -> {new_session, new_assigns} = split_assigns(new_merged, state.session_keys) new_state = bump_version(%{state | session: new_session, assigns: new_assigns}) {:ok, do_navigate(path, new_state)} {:push_event, event_name, event_payload, new_merged} -> {new_session, new_assigns} = split_assigns(new_merged, state.session_keys) new_state = bump_version(%{state | session: new_session, assigns: new_assigns}) send_push_event(new_state, event_name, event_payload) {:ok, new_state} other -> {:error, {:invalid_event_return, other}} end rescue exception -> {:error, {exception, __STACKTRACE__}} end end defp current_page(state), do: state.app_module.page_for(state.path) defp create_agent_grant(state, opts) do token = :crypto.strong_rand_bytes(24) |> Base.url_encode64(padding: false) {:ok, _} = Registry.register(Dialup.SessionRegistry, {:agent_token, token}, nil) {:ok, proxy} = Dialup.Agent.RegistryProxy.start_link(self(), token) grant = Dialup.Agent.Grant.new(token, opts) grants = Map.put(state.agent_grants, token, grant) proxies = Map.put(state.agent_proxies, token, proxy) {{:ok, %{ "token" => token, "endpoint" => Dialup.Agent.endpoint(token), "grant" => Dialup.Agent.Grant.public(grant) }}, %{state | agent_grants: grants, agent_proxies: proxies}} end defp current_scene(state, grant) do scene = case current_page(state) do nil -> %{ "path" => state.path, "version" => state.version, "state" => %{}, "regions" => [], "actions" => [] } page_module -> Dialup.Agent.scene( page_module, merge_for_render(state), state.path, state.version, grant, layout_actions(state) ) end Map.put(scene, "uiLocked", state.ui_locked) end defp layout_actions(state), do: Dialup.Agent.layout_actions(state.app_module, state.path) defp resolve_event(page_module, event) do case Dialup.Agent.action(page_module, event) do nil -> event action -> action.name end end defp send_push_event(state, event_name, event_payload) do if state.socket_pid do html = render(state) payload = %{ html: html, path: state.path, push_event: event_name, payload: event_payload } payload = put_title(payload, state) payload = put_ui_lock(payload, state) send(state.socket_pid, {:send_html, Jason.encode!(payload)}) end end defp bump_version(state), do: %{state | version: state.version + 1} defp invoke_agent_action(state, token, name, arguments) do with {:ok, grant} <- fetch_grant(state, token), true <- Dialup.Agent.Grant.allows?(grant, name) || {:error, :forbidden}, page_module when not is_nil(page_module) <- current_page(state), action when not is_nil(action) <- Dialup.Agent.action(page_module, name, layout_actions(state)) do case Map.get(action, :navigate) do nil -> invoke_mutating_action(state, token, grant, page_module, action, name, arguments) path -> invoke_navigate_action(state, token, grant, action, name, path) end else nil -> {{:error, :unknown_action}, state} false -> {{:error, :forbidden}, state} {:error, reason} -> {{:error, reason}, audit(state, token, name, :error)} end end defp invoke_mutating_action(state, token, grant, page_module, action, name, arguments) do with :ok <- check_version(grant, arguments, state.version), clean_arguments = Map.drop(arguments, ["_version", :_version]), {:ok, clean_arguments} <- validate_agent_arguments(action, clean_arguments), true <- Dialup.Agent.available?( Map.get(action, :module, page_module), action.name, merge_for_render(state) ) || {:error, :unavailable} do if Map.get(action, :confirm) == :human do {{:error, :human_confirmation_required}, audit(state, token, name, :human_confirmation_required)} else clean_arguments = Map.put(clean_arguments, "_dialup_actor", "agent") case apply_event(page_module, action.name, clean_arguments, state) do {:ok, new_state} -> new_state = audit(new_state, token, name, :ok, clean_arguments) {{:ok, current_scene(new_state, grant)}, new_state} {:error, reason} -> {{:error, reason}, audit(state, token, name, :error)} end end else {:error, reason} -> {{:error, reason}, audit(state, token, name, :error)} end end defp invoke_navigate_action(state, token, grant, action, name, path) do module = Map.get(action, :module, current_page(state)) assigns = merge_for_render(state) cond do Map.get(action, :confirm) == :human -> {{:error, :human_confirmation_required}, audit(state, token, name, :human_confirmation_required)} not Dialup.Agent.available?(module, action.name, assigns) -> {{:error, :unavailable}, audit(state, token, name, :error)} is_nil(state.app_module.page_for(path)) -> {{:error, :unknown_target}, audit(state, token, name, :error)} true -> navigated = do_navigate(path, bump_version(state)) navigated = audit(navigated, token, name, :ok, %{"path" => path}) {{:ok, current_scene(navigated, grant)}, navigated} end end defp check_version(%{require_version: false}, _arguments, _current), do: :ok defp check_version(_grant, arguments, current) do expected = Map.get(arguments, "_version", Map.get(arguments, :_version)) if expected == current, do: :ok, else: {:error, {:stale, current}} end defp validate_agent_arguments(action, arguments) do case Dialup.Agent.Validator.validate(action, arguments) do {:ok, validated} -> {:ok, validated} {:error, errors} -> {:error, {:invalid_arguments, errors}} end end defp fetch_grant(state, token) do case Map.fetch(state.agent_grants, token) do {:ok, grant} -> if Dialup.Agent.Grant.active?(grant), do: {:ok, grant}, else: {:error, :grant_expired} :error -> {:error, :grant_expired} end end defp configure_primary_grant(state) do case current_page(state) do nil -> state page_module -> opts = page_module.agent_grant(merge_for_render(state)) grant = Dialup.Agent.Grant.new(state.agent_token, opts) %{state | agent_grants: Map.put(state.agent_grants, state.agent_token, grant)} end end defp audit(state, token, action, result, details \\ %{}) do entry = %{ id: System.unique_integer([:positive, :monotonic]), at: System.system_time(:millisecond), actor: if(token == :human, do: "human", else: "agent"), token: if(is_binary(token), do: String.slice(token, 0, 8), else: nil), action: action, result: result, version: state.version, details: details } %{state | audit_log: Enum.take([entry | state.audit_log], 100)} end defp json_safe_audit(entry) do Map.new(entry, fn {key, value} -> {to_string(key), json_safe_value(value)} end) end defp json_safe_value(value) when is_atom(value), do: to_string(value) defp json_safe_value(value) when is_map(value), do: Map.new(value, fn {k, v} -> {to_string(k), json_safe_value(v)} end) defp json_safe_value(value) when is_list(value), do: Enum.map(value, &json_safe_value/1) defp json_safe_value(value), do: value end