From 79c410fe4714e28cc85dc9a1b1718ffab80d812e Mon Sep 17 00:00:00 2001 From: Kevin Webster Date: Mon, 27 Oct 2025 09:58:00 -0700 Subject: [PATCH 1/2] Ash module WIP --- lib/honeybadger.ex | 3 +- lib/honeybadger/insights/ash.ex | 189 +++++++++++++++++++++++ lib/honeybadger/insights/live_view.ex | 4 + lib/honeybadger/insights/oban.ex | 2 +- test/honeybadger/insights/ash_test.exs | 204 +++++++++++++++++++++++++ 5 files changed, 400 insertions(+), 2 deletions(-) create mode 100644 lib/honeybadger/insights/ash.ex create mode 100644 test/honeybadger/insights/ash_test.exs diff --git a/lib/honeybadger.ex b/lib/honeybadger.ex index ea4630a..03dbbca 100644 --- a/lib/honeybadger.ex +++ b/lib/honeybadger.ex @@ -216,7 +216,8 @@ defmodule Honeybadger do Honeybadger.Insights.Oban, Honeybadger.Insights.Absinthe, Honeybadger.Insights.Finch, - Honeybadger.Insights.Tesla + Honeybadger.Insights.Tesla, + Honeybadger.Insights.Ash ] |> Enum.each(& &1.attach()) end diff --git a/lib/honeybadger/insights/ash.ex b/lib/honeybadger/insights/ash.ex new file mode 100644 index 0000000..6cb0a1e --- /dev/null +++ b/lib/honeybadger/insights/ash.ex @@ -0,0 +1,189 @@ +defmodule Honeybadger.Insights.Ash do + @moduledoc """ + Captures telemetry events from Ash Framework domain actions. + + ## Default Configuration + + By default, this module listens for telemetry events from all configured + Ash domains. It reads the `:ash_domains` configuration to identify + domains and their telemetry events. + + ## Setup + + Configure your Ash domains in your application config: + + config :honeybadger, + ash_domains: [MyApp.Accounts, MyApp.Posts] + + ## Custom Configuration + + You can customize this module's behavior with the following configuration options: + + config :honeybadger, insights_config: %{ + ash: %{ + # Additional custom telemetry events to listen for alongside auto-discovered ones + telemetry_events: [ + [:ash, :my_app, :create, :stop], + [:ash, :my_app, :read, :stop] + ] + } + } + + ## Additional Telemetry Events + + By default, this module captures domain-level action events (create, read, update, + destroy, and generic action). Ash also emits lower-level telemetry events that you + can capture for more detailed monitoring: + + - `[:ash, :changeset, :stop]` - Changeset processing + - `[:ash, :query, :stop]` - Query processing + - `[:ash, :validation, :stop]` - Changeset validation + - `[:ash, :change, :stop]` - Changeset modification + - `[:ash, :calculation, :stop]` - Calculation computation + - `[:ash, :before_action, :stop]` - Before action hook execution + - `[:ash, :after_action, :stop]` - After action hook execution + - `[:ash, :preparation, :stop]` - Query preparation + + To capture these events, add them to your `telemetry_events` configuration. + These will be captured in addition to your configured domains: + + config :honeybadger, insights_config: %{ + ash: %{ + telemetry_events: [ + [:ash, :validation, :stop], + [:ash, :calculation, :stop] + ] + } + } + + Note: These lower-level events can generate high volumes of telemetry data. Use them + selectively based on your monitoring needs. + """ + + use Honeybadger.Insights.Base + + @required_dependencies [Ash] + @telemetry_events [] + require IEx + + def get_telemetry_events do + custom_events = get_insights_config(:telemetry_events, []) + + domain_events = + case Application.fetch_env(:honeybadger, :ash_domains) do + {:ok, domains} when is_list(domains) -> Enum.flat_map(domains, &get_domain_events/1) + _ -> [] + end + + stop_events = (custom_events ++ domain_events) |> Enum.uniq() + + # Add :start events for internal context propagation + start_events = + Enum.reduce(stop_events, [], fn event, acc -> + if List.last(event) == :stop do + [Enum.drop(event, -1) ++ [:start] | acc] + else + acc + end + end) + + stop_events ++ start_events + end + + defp get_domain_events(domain) do + if Code.ensure_loaded?(Ash.Domain.Info) do + short_name = apply(Ash.Domain.Info, :short_name, [domain]) + + [ + [:ash, short_name, :create, :stop], + [:ash, short_name, :read, :stop], + [:ash, short_name, :update, :stop], + [:ash, short_name, :destroy, :stop], + [:ash, short_name, :action, :stop] + ] + else + [] + end + end + + def handle_telemetry(event_name, measurements, metadata, opts) do + # Inherit Honeybadger event context on :start events to ensure + # spawned processes (like Oban jobs) get the request_id and other context + if List.last(event_name) == :start do + if map_size(Honeybadger.EventContext.get()) == 0 do + Honeybadger.inherit_event_context() + end + end + + # Only generate insights events if this event is in the configured list + # By default, :start events are for internal processing only + configured_events = get_insights_config(:telemetry_events, []) + + domain_events = + case Application.fetch_env(:honeybadger, :ash_domains) do + {:ok, domains} when is_list(domains) -> Enum.flat_map(domains, &get_domain_events/1) + _ -> [] + end + + if event_name in (configured_events ++ domain_events) do + handle_event_impl(event_name, measurements, metadata, opts) + end + + :ok + end + + def extract_metadata(meta, _event) do + IEx.pry() + # %{ + # resource_short_name: Map.get(meta, :resource_short_name), + # action: Map.get(meta, :action), + # system_time: nil + # } + meta + end + + defmodule AshOban do + @moduledoc """ + Helpers for integrating Honeybadger context with AshOban triggers. + + ## Usage with AshOban Triggers + + Use `capture_event_context/1` with the `extra_args` option in your AshOban + trigger to automatically pass the current Honeybadger event context to the + Oban job: + + oban do + triggers do + trigger :my_trigger do + action :my_action + extra_args(&Honeybadger.Insights.Ash.AshOban.capture_event_context/1) + end + end + end + + The Honeybadger Oban integration will automatically restore this context when + the job runs, ensuring request IDs and other context are preserved across + async boundaries. + """ + + @doc """ + Captures the current Honeybadger event context for use with AshOban triggers. + + This function is designed to be used with the `extra_args` option in AshOban + triggers. It returns a map containing the current Honeybadger event context, + which will be merged into the Oban job's arguments. + + ## Parameters + + - `_record_or_changeset` - The record or changeset (ignored, as we only need + the current process's context) + + ## Returns + + A map with the key `"hb_event_context"` containing the current event context. + """ + def capture_event_context(_record_or_changeset) do + %{"hb_event_context" => Honeybadger.event_context()} + end + end +end diff --git a/lib/honeybadger/insights/live_view.ex b/lib/honeybadger/insights/live_view.ex index f44e185..afafa3d 100644 --- a/lib/honeybadger/insights/live_view.ex +++ b/lib/honeybadger/insights/live_view.ex @@ -61,6 +61,10 @@ defmodule Honeybadger.Insights.LiveView do Honeybadger.Utils.rand_id() end) + Honeybadger.EventContext.put_new(:socket_id, fn -> + extract_socket_id(metadata) + end) + if event in get_insights_config(:telemetry_events, @telemetry_events) do handle_event_impl(event, measurements, metadata, opts) end diff --git a/lib/honeybadger/insights/oban.ex b/lib/honeybadger/insights/oban.ex index a147805..2c36e86 100644 --- a/lib/honeybadger/insights/oban.ex +++ b/lib/honeybadger/insights/oban.ex @@ -71,7 +71,7 @@ defmodule Honeybadger.Insights.Oban do @doc false def handle_telemetry([:oban, :job, :start] = event, measurements, metadata, opts) do - if event_context = metadata.job.meta["hb_event_context"] do + if event_context = metadata.job.meta["hb_event_context"] || metadata.args["hb_event_context"] do Honeybadger.event_context(event_context) else Honeybadger.inherit_event_context() diff --git a/test/honeybadger/insights/ash_test.exs b/test/honeybadger/insights/ash_test.exs new file mode 100644 index 0000000..6f36a22 --- /dev/null +++ b/test/honeybadger/insights/ash_test.exs @@ -0,0 +1,204 @@ +defmodule Ash.Domain.Info do + def short_name(domain) do + domain + |> Module.split() + |> List.last() + |> Macro.underscore() + |> String.to_atom() + end +end + +defmodule Honeybadger.Insights.AshTest do + use Honeybadger.Case, async: false + use Honeybadger.InsightsCase + + # Define mock modules for testing + defmodule Ash do + end + + defmodule Test.Accounts do + end + + defmodule Test.Posts do + end + + setup do + restart_with_config(ash_domains: [Test.Accounts, Test.Posts]) + end + + describe "Ash instrumentation" do + test "extracts metadata from create action event" do + event = + send_and_receive( + [:ash, :accounts, :create, :stop], + %{duration: System.convert_time_unit(15, :microsecond, :native)}, + %{ + resource_short_name: :user, + action: :register + } + ) + + assert event["resource_short_name"] == "user" + assert event["action"] == "register" + assert event["duration"] == 15 + end + + test "extracts metadata from read action event" do + event = + send_and_receive( + [:ash, :posts, :read, :stop], + %{duration: System.convert_time_unit(8, :microsecond, :native)}, + %{ + resource_short_name: :post, + action: :list + } + ) + + assert event["resource_short_name"] == "post" + assert event["action"] == "list" + assert event["duration"] == 8 + end + + test "extracts metadata from update action event" do + event = + send_and_receive( + [:ash, :accounts, :update, :stop], + %{duration: System.convert_time_unit(12, :microsecond, :native)}, + %{ + resource_short_name: :user, + action: :change_password + } + ) + + assert event["resource_short_name"] == "user" + assert event["action"] == "change_password" + assert event["duration"] == 12 + end + + test "extracts metadata from destroy action event" do + event = + send_and_receive( + [:ash, :posts, :destroy, :stop], + %{duration: System.convert_time_unit(10, :microsecond, :native)}, + %{ + resource_short_name: :post, + action: :delete + } + ) + + assert event["resource_short_name"] == "post" + assert event["action"] == "delete" + assert event["duration"] == 10 + end + + test "extracts metadata from generic action event" do + event = + send_and_receive( + [:ash, :accounts, :action, :stop], + %{duration: System.convert_time_unit(20, :microsecond, :native)}, + %{ + resource_short_name: :user, + action: :custom_action + } + ) + + assert event["resource_short_name"] == "user" + assert event["action"] == "custom_action" + assert event["duration"] == 20 + end + + test "handles missing metadata gracefully" do + event = + send_and_receive( + [:ash, :accounts, :create, :stop], + %{duration: System.convert_time_unit(5, :microsecond, :native)}, + %{} + ) + + assert event["resource_short_name"] == nil + assert event["action"] == nil + assert event["duration"] == 5 + end + end + + describe "get_telemetry_events/0" do + test "returns events for configured domains" do + events = Honeybadger.Insights.Ash.get_telemetry_events() + + # Should include events for both test domains + assert [:ash, :accounts, :create, :stop] in events + assert [:ash, :accounts, :read, :stop] in events + assert [:ash, :accounts, :update, :stop] in events + assert [:ash, :accounts, :destroy, :stop] in events + assert [:ash, :accounts, :action, :stop] in events + + assert [:ash, :posts, :create, :stop] in events + assert [:ash, :posts, :read, :stop] in events + assert [:ash, :posts, :update, :stop] in events + assert [:ash, :posts, :destroy, :stop] in events + assert [:ash, :posts, :action, :stop] in events + end + + test "merges custom events with domain events" do + with_config( + [ + ash_domains: [Test.Accounts], + insights_config: %{ + ash: %{ + telemetry_events: [ + [:ash, :validation, :stop], + [:ash, :calculation, :stop] + ] + } + } + ], + fn -> + events = Honeybadger.Insights.Ash.get_telemetry_events() + + # Should include domain events + assert [:ash, :accounts, :create, :stop] in events + assert [:ash, :accounts, :read, :stop] in events + + # Should also include custom events + assert [:ash, :validation, :stop] in events + assert [:ash, :calculation, :stop] in events + end + ) + end + + test "handles missing ash_domains configuration gracefully" do + with_config([ash_domains: nil], fn -> + # Should not raise an error and return only custom events if any + events = Honeybadger.Insights.Ash.get_telemetry_events() + assert is_list(events) + end) + end + + test "deduplicates events when custom events overlap with domain events" do + with_config( + [ + ash_domains: [Test.Accounts], + insights_config: %{ + ash: %{ + telemetry_events: [ + [:ash, :accounts, :create, :stop], + [:ash, :accounts, :create, :stop] + ] + } + } + ], + fn -> + events = Honeybadger.Insights.Ash.get_telemetry_events() + + # Should only have one instance of the create event + create_events = + Enum.filter(events, fn event -> + event == [:ash, :accounts, :create, :stop] + end) + + assert length(create_events) == 1 + end + ) + end + end +end From e7486a84f114a61b7418d83add57a66d2bc8ca75 Mon Sep 17 00:00:00 2001 From: Kevin Webster Date: Mon, 27 Oct 2025 10:56:27 -0700 Subject: [PATCH 2/2] whoops --- lib/honeybadger/insights/ash.ex | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/lib/honeybadger/insights/ash.ex b/lib/honeybadger/insights/ash.ex index 6cb0a1e..a03d072 100644 --- a/lib/honeybadger/insights/ash.ex +++ b/lib/honeybadger/insights/ash.ex @@ -64,7 +64,6 @@ defmodule Honeybadger.Insights.Ash do @required_dependencies [Ash] @telemetry_events [] - require IEx def get_telemetry_events do custom_events = get_insights_config(:telemetry_events, []) @@ -133,13 +132,11 @@ defmodule Honeybadger.Insights.Ash do end def extract_metadata(meta, _event) do - IEx.pry() - # %{ - # resource_short_name: Map.get(meta, :resource_short_name), - # action: Map.get(meta, :action), - # system_time: nil - # } - meta + %{ + resource_short_name: Map.get(meta, :resource_short_name), + action: Map.get(meta, :action), + system_time: nil + } end defmodule AshOban do