Repository: honeybadger-io/honeybadger-elixir
Pull request #591 (2025-02-28 21:11:57)

Insights instrumentation
This still needs documentation, but I wanted to get some feedback on the general direction. I added a set of modules focused on automatic instrumentation which shares some properties with our Ruby plugins. Here is the Oban one:

https://github.com/honeybadger-io/honeybadger-elixir/blob/3cfda1707553faf86f6ce3812c1fec446be07a2e/lib/honeybadger/insights/oban.ex#L1-L28

This is disabled by default via the `insights_enabled` config option.

If enabled, all modules are attempted to be attached, but will skip loading if any of the `@requred_dependencies` return false from `Code.ensure_loaded?/1`. Each module specifies which events they want to subscribe to via `@telemetry_events` list and then an `extract_metadata/2` callback must be implemented to, well, extract metadata for the event.

There are currently 2 ways to customize the instrumented events. You can add your own `event_filter` by implementing the `Honeybadger.EventFilter.filter/3` callback and overriding it in the config. All instrumented events will funnel through this. The telemetry event name is passed as the third argument so users can just filter on a specific event type:

```elixir
defmodule MyEventFilter do
  def filter(event, raw, "oban.job.stop") do
    Map.put(event, :thing, raw.thing)
  end

  def filter(event, _, _), do: event
end
```

I also added a way to customize each of these "plugins". There is a `insights_config` option that accepts a map. Users can set the telemetry events per "plugin". Say you only wanted one live_view event to be logged to Insights. Instead of adding a filter module, you could:

```elixir
config :honeybadger,
  insights_config: %{
    live_view: %{
      telemetry_events: [
        "phoenix.live_component.handle_event.stop"
      ]
    }
  }
```

Some of the plugins include their own options. For example, this can be used to customize the excluded queries from Ecto:

```elixir
config :honeybadger,
  insights_config: %{
    ecto: %{
      excluded_queries: [
        ~r/SELECT 1/
      ]
    }
  }
```

There is no spec around any of these options, so it's pretty loose. I chose to go this way instead of adding each possible option. I wasn't sure how idiomatic of a pattern this is, so any feedback about this is welcome.


Patch:
diff --git a/README.md b/README.md
index 2708470..249cc2f 100644
--- a/README.md
+++ b/README.md
@@ -150,6 +150,118 @@ rescue
 end
 ```
 
+## Insights Automatic Instrumentation
+
+Honeybadger Insights allows you to automatically track various events in your
+application. To enable Insights automatic instrumentation, add the following to
+your configuration:
+
+```elixir
+config :honeybadger,
+  insights_enabled: true
+```
+
+### Supported Libraries
+
+Honeybadger automatically instruments the following libraries when they are available:
+
+* Absinthe: GraphQL query execution
+* Ecto: Database queries
+* Finch: HTTP client requests, often used by other libraries like Req
+* Tesla: HTTP client requests
+* LiveView: Phoenix LiveView lifecycle events
+* Oban: Background job processing
+* Plug/Phoenix: HTTP requests
+
+### Custom Configuration
+
+Each library can be configured individually using the `insights_config` option:
+
+```elixir
+config :honeybadger, insights_config: %{
+  # Ecto configuration
+  ecto: %{
+    # A list of strings or regex patterns of queries to exclude
+    excluded_queries: [~r/^(begin|commit)/i],
+
+    # A list of table/source names to exclude
+    excluded_sources: ["schema_migrations"]
+  },
+
+  # Finch/Tesla configuration
+  finch: %{
+    # Include full URLs instead of just hostnames
+    full_url: false
+  },
+
+  # Plug configuration
+  plug: %{
+    telemetry_events: [[:phoenix, :endpoint, :stop]]
+  },
+
+  # LiveView configuration
+  live_view: %{
+    telemetry_events: [
+      [:phoenix, :live_view, :mount, :stop],
+      [:phoenix, :live_view, :update, :stop]
+    ]
+  },
+
+  # Absinthe configuration
+  absinthe: %{
+    telemetry_events: [
+      [:absinthe, :execute, :operation, :start],
+      [:absinthe, :execute, :operation, :stop],
+      [:absinthe, :execute, :operation, :exception]
+    ]
+  },
+
+  # Oban configuration
+  oban: %{
+    telemetry_events: [
+      [:oban, :job, :stop],
+      [:oban, :job, :exception]
+    ]
+  }
+}
+```
+
+### Event Filtering
+
+You can filter out or customize automatic events sent to Honeybadger Insights
+by implementing the `Honeybadger.EventFilter` behaviour:
+
+```elixir
+defmodule MyApp.EventFilter do
+  @behaviour Honeybadger.EventFilter
+
+  @default_filter &Honeybadger.EventFilter.Default.filter/3
+
+  @impl Honeybadger.EventFilter
+  # Pattern match on the event type name
+  def filter_telemetry_event(data, raw, [:phoenix, :live_view, :update, :stop] = event) do
+    if data.duration < 100 do
+      data
+      |> Map.put(:slow, true)
+      # You might want to run the event through the default filter to remove
+      # any sensitive data
+      |> @default_filter.(raw, event)
+    else
+      # Ignore events that are fast
+      nil
+    end
+  end
+
+  def filter(data, raw, event), do: @default_filter.(data, raw, event)
+end
+```
+
+Then configure the filter in your application's configuration:
+
+```elixir
+config :honeybadger, event_filter: MyApp.EventFilter
+```
+
 ## Breadcrumbs
 
 Breadcrumbs allow you to record events along a processes execution path. If
@@ -311,19 +423,20 @@ Here are all of the options you can pass in the keyword list:
 | ------------------------ | --------------------------------------------------------------------------------------------- | ---------------------------------------- |
 | `app`                    | Name of your app's OTP Application as an atom                                                 | `Mix.Project.config[:app]`               |
 | `api_key`                | Your application's Honeybadger API key                                                        | `System.get_env("HONEYBADGER_API_KEY"))` |
-| `environment_name`       | (required) The name of the environment your app is running in.                                | `:prod`                                    |
-|`exclude_errors`          |Filters out errors from being sent to Honeybadger        | `[]`|
+| `environment_name`       | (required) The name of the environment your app is running in.                                | `:prod`                                  |
+| `exclude_errors`         | Filters out errors from being sent to Honeybadger                                             | `[]`                                     |
 | `exclude_envs`           | Environments that you want to disable Honeybadger notifications                               | `[:dev, :test]`                          |
 | `hostname`               | Hostname of the system your application is running on                                         | `:inet.gethostname`                      |
 | `origin`                 | URL for the Honeybadger API                                                                   | `"https://api.honeybadger.io"`           |
 | `project_root`           | Directory root for where your application is running                                          | `System.cwd/0`                           |
 | `revision`               | The project's git revision                                                                    | `nil`                                    |
 | `filter`                 | Module implementing `Honeybadger.Filter` to filter data before sending to Honeybadger.io      | `Honeybadger.Filter.Default`             |
-| `filter_keys`            | A list of keywords (atoms) to filter.  Only valid if `filter` is `Honeybadger.Filter.Default` | `[:password, :credit_card]`              |
+| `filter_keys`            | A list of keywords (atoms) to filter.  Only valid if `filter` is `Honeybadger.Filter.Default` | `[:password, :credit_card, :__changed__, :flash, :_csrf_token]` |
 | `filter_args`            | If true, will remove function arguments in backtraces                                         | `true`                                   |
 | `filter_disable_url`     | If true, will remove the request url                                                          | `false`                                  |
 | `filter_disable_session` | If true, will remove the request session                                                      | `false`                                  |
 | `filter_disable_params`  | If true, will remove the request params                                                       | `false`                                  |
+| `filter_disable_assigns` | If true, will remove the live_view event assigns                                              | `false`                                  |
 | `fingerprint_adapter`    | Implementation of FingerprintAdapter behaviour                                                |                                          |
 | `notice_filter`          | Module implementing `Honeybadger.NoticeFilter`. If `nil`, no filtering is done.               | `Honeybadger.NoticeFilter.Default`       |
 | `sasl_logging_only`      | If true, will notifiy for SASL errors but not Logger calls                                    | `true`                                   |
@@ -331,6 +444,9 @@ Here are all of the options you can pass in the keyword list:
 | `ignored_domains`        | Add domains to ignore Error events in `Honeybadger.Logger`.                                   | `[:cowboy]`                              |
 | `breadcrumbs_enabled`    | Enable breadcrumb event tracking                                                              | `false`                                  |
 | `ecto_repos`             | Modules with implemented Ecto.Repo behaviour for tracking SQL breadcrumb events               | `[]`                                     |
+| `event_filter`           | Module implementing `Honeybadger.EventFilter`. If `nil`, no filtering is done.                | `Honeybadger.EventFilter.Default`        |
+| `insights_enabled`       | Enable sending automatic events to Honeybadger Insights                                       | `false`                                  |
+| `insights_config`        | Specific library Configuration for Honeybadger Insights.                                      | `%{}`                                    |
 | `http_adapter`           | Module implementing `Honeybadger.HttpAdapter` to send data to Honeybadger.io                  | Any available adapter (`Req`, `hackney`) |
 
 ### HTTP Adapters
diff --git a/lib/honeybadger.ex b/lib/honeybadger.ex
index fb42477..c006530 100644
--- a/lib/honeybadger.ex
+++ b/lib/honeybadger.ex
@@ -163,6 +163,8 @@ defmodule Honeybadger do
 
       config :honeybadger,
         ecto_repos: [MyApp.Repo]
+
+  #### Insights
   """
 
   use Application
@@ -206,6 +208,19 @@ defmodule Honeybadger do
       Honeybadger.Breadcrumbs.Telemetry.attach()
     end
 
+    if config[:insights_enabled] do
+      [
+        Honeybadger.Insights.Plug,
+        Honeybadger.Insights.Ecto,
+        Honeybadger.Insights.LiveView,
+        Honeybadger.Insights.Oban,
+        Honeybadger.Insights.Absinthe,
+        Honeybadger.Insights.Finch,
+        Honeybadger.Insights.Tesla
+      ]
+      |> Enum.each(& &1.attach())
+    end
+
     children = [{Client, [config]}, EventsWorker]
 
     Supervisor.start_link(children, strategy: :one_for_one)
diff --git a/lib/honeybadger/event_filter.ex b/lib/honeybadger/event_filter.ex
new file mode 100644
index 0000000..d1e6198
--- /dev/null
+++ b/lib/honeybadger/event_filter.ex
@@ -0,0 +1,28 @@
+defmodule Honeybadger.EventFilter do
+  @moduledoc """
+  Specification for filtering instrumented events.
+
+  Most users won't need this, but if you need complete control over
+  filtering, implement this behaviour and configure like:
+
+      config :honeybadger,
+        event_filter: MyApp.MyEventFilter
+  """
+
+  @doc """
+  Filters an instrumented telemetry event.
+
+  ## Parameters
+
+    * `data` - The current data for the event
+    * `raw_event` - The raw event metadata
+    * `event` - The telemetry event being processed, e.g. [:phoenix, :endpoint, :start]
+
+  ## Returns
+
+    The filtered metadata map that will be sent to Honeybadger or `nil` to skip
+    the event.
+  """
+  @callback filter_telemetry_event(data :: map(), raw_event :: map(), event :: [atom(), ...]) ::
+              map() | nil
+end
diff --git a/lib/honeybadger/event_filter/default.ex b/lib/honeybadger/event_filter/default.ex
new file mode 100644
index 0000000..285520c
--- /dev/null
+++ b/lib/honeybadger/event_filter/default.ex
@@ -0,0 +1,22 @@
+defmodule Honeybadger.EventFilter.Default do
+  @behaviour Honeybadger.EventFilter
+
+  alias Honeybadger.Utils
+
+  def filter_telemetry_event(data, _raw, _event) do
+    data
+    |> disable(:filter_disable_url, :url)
+    |> disable(:filter_disable_session, :session)
+    |> disable(:filter_disable_assigns, :assigns)
+    |> disable(:filter_disable_params, :params)
+    |> Utils.sanitize(remove_filtered: true)
+  end
+
+  defp disable(meta, config_key, map_key) do
+    if Honeybadger.get_env(config_key) do
+      Map.drop(meta, [map_key])
+    else
+      meta
+    end
+  end
+end
diff --git a/lib/honeybadger/insights/absinthe.ex b/lib/honeybadger/insights/absinthe.ex
new file mode 100644
index 0000000..f135ba7
--- /dev/null
+++ b/lib/honeybadger/insights/absinthe.ex
@@ -0,0 +1,125 @@
+defmodule Honeybadger.Insights.Absinthe do
+  @moduledoc """
+  Captures telemetry events from GraphQL operations executed via Absinthe.
+
+  ## Default Configuration
+
+  By default, this module listens for the following Absinthe telemetry events:
+
+      "absinthe.execute.operation.stop"
+      "absinthe.execute.operation.exception"
+
+  ## Custom Configuration
+
+  You can customize the telemetry events to listen for by updating the insights_config:
+
+      config :honeybadger, insights_config: %{
+        absinthe: %{
+          telemetry_events: [
+            [:absinthe, :execute, :operation, :stop],
+            [:absinthe, :execute, :operation, :exception],
+            [:absinthe, :resolve, :field, :stop]
+          ]
+        }
+      }
+
+  Note that adding field-level events like "absinthe.resolve.field.stop" can
+  significantly increase the number of telemetry events generated.
+  """
+
+  use Honeybadger.Insights.Base
+
+  @required_dependencies [Absinthe]
+
+  @telemetry_events [
+    [:absinthe, :execute, :operation, :stop],
+    [:absinthe, :execute, :operation, :exception]
+  ]
+
+  # This is not loaded by default since it can add a ton of events, but is here
+  # in case it is added to the insights_config.
+  def extract_metadata(%{resolution: resolution}, [:absinthe, :resolve, :field, :stop]) do
+    %{
+      field_name: resolution.definition.name,
+      parent_type: resolution.parent_type.name,
+      state: resolution.state
+    }
+  end
+
+  def extract_metadata(meta, _name) do
+    %{
+      operation_name: get_operation_name(meta),
+      operation_type: get_operation_type(meta),
+      selections: get_graphql_selections(meta),
+      schema: get_schema(meta),
+      errors: get_errors(meta)
+    }
+  end
+
+  defp get_schema(%{blueprint: blueprint}) when is_map(blueprint), do: Map.get(blueprint, :schema)
+  defp get_schema(_), do: nil
+
+  defp get_errors(%{blueprint: blueprint}) when is_map(blueprint) do
+    case Map.get(blueprint, :result) do
+      result when is_map(result) -> Map.get(result, :errors)
+      _ -> nil
+    end
+  end
+
+  defp get_errors(_), do: nil
+
+  defp get_graphql_selections(%{blueprint: blueprint}) when is_map(blueprint) do
+    operation = current_operation(blueprint)
+
+    case operation do
+      nil ->
+        []
+
+      operation ->
+        case Map.get(operation, :selections) do
+          selections when is_list(selections) ->
+            selections
+            |> Enum.map(fn selection -> Map.get(selection, :name) end)
+            |> Enum.uniq()
+
+          _ ->
+            []
+        end
+    end
+  end
+
+  defp get_graphql_selections(_), do: []
+
+  defp get_operation_type(%{blueprint: blueprint}) when is_map(blueprint) do
+    operation = current_operation(blueprint)
+
+    case operation do
+      nil -> nil
+      operation -> Map.get(operation, :type)
+    end
+  end
+
+  defp get_operation_type(_), do: nil
+
+  defp get_operation_name(%{blueprint: blueprint}) when is_map(blueprint) do
+    operation = current_operation(blueprint)
+
+    case operation do
+      nil -> nil
+      operation -> Map.get(operation, :name)
+    end
+  end
+
+  defp get_operation_name(_), do: nil
+
+  # Replace Absinthe.Blueprint.current_operation/1
+  defp current_operation(blueprint) do
+    case Map.get(blueprint, :operations) do
+      operations when is_list(operations) ->
+        Enum.find(operations, fn op -> Map.get(op, :current) == true end)
+
+      _ ->
+        nil
+    end
+  end
+end
diff --git a/lib/honeybadger/insights/base.ex b/lib/honeybadger/insights/base.ex
new file mode 100644
index 0000000..ab14832
--- /dev/null
+++ b/lib/honeybadger/insights/base.ex
@@ -0,0 +1,159 @@
+defmodule Honeybadger.Insights.Base do
+  @moduledoc """
+  Base module providing common telemetry attachment functionality.
+  """
+
+  defmacro __using__(_opts) do
+    quote do
+      require Logger
+
+      # Define empty defaults for the attributes
+      Module.register_attribute(__MODULE__, :required_dependencies, [])
+      @required_dependencies []
+      Module.register_attribute(__MODULE__, :telemetry_events, [])
+      @time_keys ~w(duration total_time decode_time query_time queue_time idle_time)a
+
+      @before_compile Honeybadger.Insights.Base
+
+      def dependencies_available? do
+        Honeybadger.Insights.Base.dependencies_available?(@required_dependencies)
+      end
+
+      def get_insights_config(key, default) do
+        Honeybadger.Insights.Base.get_insights_config(key, default, __MODULE__)
+      end
+    end
+  end
+
+  @doc """
+  Checks if all required dependencies are available.
+  """
+  def dependencies_available?(deps) do
+    if Enum.empty?(deps), do: true, else: Enum.all?(deps, &Code.ensure_loaded?/1)
+  end
+
+  @doc """
+  Retrieves a configuration value from the insights configuration.
+  """
+  def get_insights_config(key, default, mod) do
+    config_namespace =
+      mod
+      |> Atom.to_string()
+      |> String.split(".")
+      |> List.last()
+      |> Macro.underscore()
+      |> String.to_atom()
+
+    insights_config = Application.get_env(:honeybadger, :insights_config, %{})
+    module_config = Map.get(insights_config, config_namespace, %{})
+    Map.get(module_config, key, default)
+  end
+
+  defmacro __before_compile__(_env) do
+    quote do
+      def event_filter(map, meta, event) do
+        if Application.get_env(:honeybadger, :event_filter) do
+          Application.get_env(:honeybadger, :event_filter).filter_telemetry_event(
+            map,
+            meta,
+            event
+          )
+        else
+          map
+        end
+      end
+
+      def get_telemetry_events() do
+        get_insights_config(:telemetry_events, @telemetry_events)
+      end
+
+      @doc """
+      Attaches telemetry handlers if all required dependencies are available.
+      """
+      def attach do
+        if dependencies_available?() do
+          Enum.each(get_telemetry_events(), &attach_event/1)
+
+          :ok
+        else
+          Logger.debug(
+            "[Honeybadger] Missing Insights dependencies for #{inspect(@required_dependencies)}"
+          )
+
+          {:error, :missing_dependencies}
+        end
+      end
+
+      @doc """
+      Attaches a telemetry handler for a specific event.
+      """
+      def attach_event(event) do
+        event_name = Honeybadger.Utils.dotify(event)
+
+        :telemetry.attach(
+          # Use the event name as the handler ID
+          event_name,
+          event,
+          &__MODULE__.handle_telemetry/4,
+          nil
+        )
+      end
+
+      defp process_measurements(measurements) do
+        Enum.reduce(measurements, %{}, fn
+          {key, _vl}, acc when key in ~w(monotonic_time end_time_mono)a ->
+            acc
+
+          {key, val}, acc when key in @time_keys ->
+            Map.put(acc, key, System.convert_time_unit(val, :native, :millisecond))
+
+          {key, val}, acc ->
+            Map.put(acc, key, val)
+        end)
+      end
+
+      @doc """
+      Handles telemetry events and processes the data.
+      """
+      def handle_telemetry(event, measurements, metadata, _opts) do
+        name = Honeybadger.Utils.dotify(event)
+
+        unless ignore?(metadata) do
+          %{event_type: name}
+          |> Map.merge(process_measurements(measurements))
+          |> Map.merge(
+            metadata
+            |> extract_metadata(event)
+            |> Map.reject(fn {_, v} -> is_nil(v) end)
+          )
+          |> event_filter(metadata, event)
+          |> process_event()
+        end
+
+        :ok
+      end
+
+      @doc """
+      Determines if an event should be ignored based on its metadata.
+      Child modules should override this for specific filtering logic.
+      Note: this is the metadata before any transformations.
+      """
+      def ignore?(_metadata), do: false
+
+      @doc """
+      Extracts metadata from the telemetry event.
+      Child modules should override this for specific events.
+      """
+      def extract_metadata(meta, _event), do: meta
+
+      @doc """
+      Process the event data. Child modules can override this for custom
+      processing.
+      """
+      def process_event(event_data) when is_map(event_data), do: Honeybadger.event(event_data)
+      def process_event(_event_data), do: nil
+
+      defoverridable extract_metadata: 2, process_event: 1, get_telemetry_events: 0, ignore?: 1
+    end
+  end
+end
diff --git a/lib/honeybadger/insights/ecto.ex b/lib/honeybadger/insights/ecto.ex
new file mode 100644
index 0000000..88cecc9
--- /dev/null
+++ b/lib/honeybadger/insights/ecto.ex
@@ -0,0 +1,108 @@
+defmodule Honeybadger.Insights.Ecto do
+  @moduledoc """
+  Captures database query telemetry events from Ecto repositories.
+
+  ## Default Configuration
+
+  By default, this module listens for telemetry events from all configured
+  Ecto repositories. It reads the `:ecto_repos` configuration to identify
+  repositories and their telemetry prefixes.
+
+  ## Custom Configuration
+
+  You can customize this module's behavior with the following configuration options:
+
+      config :honeybadger, insights_config: %{
+        ecto: %{
+          # A list of strings or regex patterns of queries to exclude
+          excluded_queries: [
+            ~r/^(begin|commit)( immediate)?( transaction)?$/i,
+            ~r/SELECT pg_notify/,
+            ~r/schema_migrations/
+          ],
+
+          # A list of table/source names to exclude
+          excluded_sources: [
+            "schema_migrations",
+            "oban_jobs",
+            "oban_peers"
+          ]
+        }
+      }
+
+  By default, transaction bookkeeping queries and schema migration checks are excluded,
+  as well as queries to common background job tables.
+  """
+
+  use Honeybadger.Insights.Base
+
+  @required_dependencies [Ecto.Repo]
+  @telemetry_events []
+
+  @excluded_queries [
+    ~r/^(begin|commit)( immediate)?( transaction)?$/i,
+    # Also exclude pg_notify which is often used with Oban
+    ~r/SELECT pg_notify/,
+    ~r/schema_migrations/
+  ]
+
+  @excluded_sources [
+    "schema_migrations",
+    "oban_jobs",
+    "oban_peers"
+  ]
+
+  def get_telemetry_events do
+    :ecto_repos
+    |> Honeybadger.get_env()
+    |> Enum.map(&get_telemetry_prefix/1)
+  end
+
+  def extract_metadata(meta, _name) do
+    meta
+    |> Map.take([:query, :decode_time, :query_time, :queue_time, :source])
+    |> Map.update!(:query, &obfuscate(&1, meta.repo.__adapter__()))
+  end
+
+  def ignore?(%{query: query, source: source}) do
+    if source in get_insights_config(:excluded_sources, @excluded_sources) do
+      true
+    else
+      :excluded_queries
+      |> get_insights_config(@excluded_queries)
+      |> Enum.any?(fn
+        pattern when is_binary(pattern) -> query == pattern
+        %Regex{} = pattern -> Regex.match?(pattern, query)
+        _pattern -> false
+      end)
+    end
+  end
+
+  defp get_telemetry_prefix(repo) do
+    Keyword.get(repo.config(), :telemetry_prefix, []) ++ [:query]
+  end
+
+  @escape_quotes ~r/(\\\"|\\')/
+  @squote_data ~r/'(?:[^']|'')*'/
+  @dquote_data ~r/"(?:[^"]|"")*"/
+  @number_data ~r/\b\d+\b/
+  @double_quoters ~r/(postgres|sqlite3|postgis)/i
+
+  def obfuscate(sql, adapter) when is_binary(sql) do
+    sql
+    |> String.replace(~r/\s+/, " ")
+    |> String.replace(@escape_quotes, "")
+    |> String.replace(@squote_data, "'?'")
+    |> maybe_replace_dquote(adapter)
+    |> String.replace(@number_data, "?")
+    |> String.trim()
+  end
+
+  defp maybe_replace_dquote(sql, adapter) do
+    if Regex.match?(@double_quoters, to_string(adapter)) do
+      sql
+    else
+      String.replace(sql, @dquote_data, "\"?\"")
+    end
+  end
+end
diff --git a/lib/honeybadger/insights/finch.ex b/lib/honeybadger/insights/finch.ex
new file mode 100644
index 0000000..b0cdf47
--- /dev/null
+++ b/lib/honeybadger/insights/finch.ex
@@ -0,0 +1,85 @@
+defmodule Honeybadger.Insights.Finch do
+  @moduledoc """
+  Captures telemetry events from HTTP requests made using Finch.
+
+  ## Default Configuration
+
+  By default, this module listens for the standard Finch request telemetry event:
+
+      "finch.request.stop"
+
+  ## Custom Configuration
+
+  You can customize the telemetry events to listen for by updating the insights_config:
+
+      config :honeybadger, insights_config: %{
+        finch: %{
+          telemetry_events: ["finch.request.stop", "finch.request.exception"],
+
+          # Include full URLs in telemetry events (default: false - only hostname is included)
+          full_url: false
+        }
+      }
+
+  By default, only the hostname from URLs is captured for security and privacy reasons.
+  If you need to capture the full URL including paths (but not query parameters),
+  you can enable the `full_url` option.
+  """
+
+  use Honeybadger.Insights.Base
+
+  @required_dependencies [Finch]
+
+  @telemetry_events [
+    [:finch, :request, :stop]
+  ]
+
+  def extract_metadata(meta, _) do
+    metadata = %{
+      name: meta.name,
+      method: meta.request.method,
+      host: meta.request.host
+    }
+
+    metadata =
+      if get_insights_config(:full_url, false) do
+        Map.put(metadata, :url, reconstruct_url(meta.request))
+      else
+        metadata
+      end
+
+    case meta.result do
+      {:ok, response} when is_struct(response, Finch.Response) ->
+        Map.merge(metadata, %{status: response.status})
+
+      {:ok, _acc} ->
+        # For streaming requests
+        Map.put(metadata, :streaming, true)
+
+      {:error, error} ->
+        Map.put(metadata, :error, Exception.message(error))
+    end
+  end
+
+  def ignore?(%{request: %{headers: headers}}) when is_list(headers) do
+    Enum.any?(headers, fn
+      {"user-agent", value} -> String.contains?(value, "Honeybadger Elixir")
+      _ -> false
+    end)
+  end
+
+  defp reconstruct_url(request) do
+    # Exclude query parameters for security reasons
+    port_string = get_port_string(request.scheme, request.port)
+
+    "#{request.scheme}://#{request.host}#{port_string}#{request.path}"
+  end
+
+  defp get_port_string(scheme, port) do
+    cond do
+      is_nil(port) -> ""
+      (scheme == :http and port == 80) or (scheme == :https and port == 443) -> ""
+      true -> ":#{port}"
+    end
+  end
+end
diff --git a/lib/honeybadger/insights/live_view.ex b/lib/honeybadger/insights/live_view.ex
new file mode 100644
index 0000000..f8cf346
--- /dev/null
+++ b/lib/honeybadger/insights/live_view.ex
@@ -0,0 +1,68 @@
+defmodule Honeybadger.Insights.LiveView do
+  @moduledoc """
+  Captures telemetry events from Phoenix LiveView.
+
+  ## Default Configuration
+
+  By default, this module listens for the following LiveView telemetry events:
+
+     "phoenix.live_component.handle_event.stop"
+     "phoenix.live_view.mount.stop"
+     "phoenix.live_view.update.stop"
+
+  ## Custom Configuration
+
+  You can customize the telemetry events to listen for by updating the insights_config:
+
+     config :honeybadger, insights_config: %{
+       live_view: %{
+         telemetry_events: [
+           [:phoenix, :live_component, :handle_event, :stop],
+           [:phoenix, :live_view, :mount, :stop],
+           [:phoenix, :live_view, :update, :stop]
+         ]
+       }
+     }
+  """
+
+  use Honeybadger.Insights.Base
+
+  @required_dependencies [Phoenix.LiveView]
+
+  @telemetry_events [
+    [:phoenix, :live_component, :handle_event, :stop],
+    [:phoenix, :live_view, :mount, :stop],
+    [:phoenix, :live_view, :update, :stop]
+  ]
+
+  def extract_metadata(meta, _) do
+    %{
+      url: Map.get(meta, :uri),
+      socket_id: Map.get(meta, :socket_id),
+      view: extract_view(meta),
+      assigns: extract_assigns(meta),
+      params: Map.get(meta, :params),
+      event: Map.get(meta, :event)
+    }
+  end
+
+  defp extract_view(%{socket: socket}) do
+    socket.view |> get_module_name()
+  rescue
+    _ -> nil
+  end
+
+  defp extract_view(_), do: nil
+
+  defp extract_assigns(%{socket: socket}) do
+    socket.assigns
+  rescue
+    _ -> nil
+  end
+
+  defp extract_assigns(_), do: nil
+
+  # Helper to get module name as string
+  defp get_module_name(module) when is_atom(module), do: inspect(module)
+  defp get_module_name(_), do: nil
+end
diff --git a/lib/honeybadger/insights/oban.ex b/lib/honeybadger/insights/oban.ex
new file mode 100644
index 0000000..7de59cb
--- /dev/null
+++ b/lib/honeybadger/insights/oban.ex
@@ -0,0 +1,42 @@
+defmodule Honeybadger.Insights.Oban do
+  @moduledoc """
+  Captures telemetry events from Oban job processing.
+
+  ## Default Configuration
+
+  By default, this module listens for the following Oban telemetry events:
+
+     "oban.job.stop"
+     "oban.job.exception"
+
+  ## Custom Configuration
+
+  You can customize the telemetry events to listen for by updating the insights_config:
+
+     config :honeybadger, insights_config: %{
+       oban: %{
+         telemetry_events: [
+           [:oban, :job, :stop],
+           [:oban, :job, :exception]
+           [:oban, :engine, :start]
+         ]
+       }
+     }
+  """
+
+  use Honeybadger.Insights.Base
+
+  @required_dependencies [Oban]
+
+  @telemetry_events [
+    [:oban, :job, :stop],
+    [:oban, :job, :exception]
+  ]
+
+  def extract_metadata(%{conf: conf, job: job, state: state}, _event) do
+    job
+    |> Map.take(~w(args attempt id queue tags worker)a)
+    |> Map.put(:prefix, conf.prefix)
+    |> Map.put(:state, state)
+  end
+end
diff --git a/lib/honeybadger/insights/plug.ex b/lib/honeybadger/insights/plug.ex
new file mode 100644
index 0000000..b6f8eb7
--- /dev/null
+++ b/lib/honeybadger/insights/plug.ex
@@ -0,0 +1,58 @@
+defmodule Honeybadger.Insights.Plug do
+  @moduledoc """
+  Captures telemetry events from HTTP requests processed by Plug and Phoenix.
+
+  ## Default Configuration
+
+  By default, this module listens for the standard Phoenix endpoint telemetry event:
+
+      "phoenix.endpoint.stop"
+
+  This is compatible with the default Phoenix configuration that adds telemetry
+  via `Plug.Telemetry`:
+
+      plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
+
+  ## Custom Configuration
+
+  You can customize the telemetry events to listen for by updating the insights_config:
+
+      config :honeybadger, insights_config: %{
+        plug: %{
+          telemetry_events: [[:my, :prefix, :stop]]
+        }
+      }
+  """
+
+  use Honeybadger.Insights.Base
+
+  @required_dependencies [Plug]
+
+  @telemetry_events [
+    [:phoenix, :endpoint, :stop]
+  ]
+
+  def extract_metadata(meta, _) do
+    conn = meta.conn
+
+    %{
+      params: conn.params,
+      method: conn.method,
+      request_path: conn.request_path,
+      request_id: get_request_id(conn),
+      status: conn.status
+    }
+  end
+
+  defp get_request_id(conn) do
+    case conn.assigns[:request_id] do
+      nil ->
+        conn
+        |> Plug.Conn.get_resp_header("x-request-id")
+        |> List.first()
+
+      id ->
+        id
+    end
+  end
+end
diff --git a/lib/honeybadger/insights/tesla.ex b/lib/honeybadger/insights/tesla.ex
new file mode 100644
index 0000000..aa5a58e
--- /dev/null
+++ b/lib/honeybadger/insights/tesla.ex
@@ -0,0 +1,69 @@
+defmodule Honeybadger.Insights.Tesla do
+  @moduledoc """
+  Captures telemetry events from Tesla HTTP requests.
+
+  ## Configuration
+
+  This module can be configured in the application config:
+
+  ```elixir
+  config :honeybadger, insights_config: %{
+    tesla: %{
+      # Include full URLs in telemetry events (default: false - only hostname is included)
+      full_url: false,
+
+      # Custom telemetry event patterns to listen for (optional)
+      telemetry_events: [[:tesla, :request, :stop], [:tesla, :request, :exception]]
+    }
+  }
+  """
+
+  use Honeybadger.Insights.Base
+
+  @required_dependencies [Tesla]
+
+  @telemetry_events [
+    [:tesla, :request, :stop],
+    [:tesla, :request, :exception]
+  ]
+
+  def extract_metadata(meta, _name) do
+    env = Map.get(meta, :env, %{})
+
+    base = %{
+      method: env.method |> to_string() |> String.upcase(),
+      host: extract_host(env.url),
+      status_code: env.status
+    }
+
+    if get_insights_config(:full_url, false) do
+      Map.put(base, :url, env.url)
+    else
+      base
+    end
+  end
+
+  # Ignore telemetry events from Finch adapters, since we are already
+  # instrumenting Finch requests in the Finch adapter module.
+  def ignore?(meta) do
+    adapter =
+      meta
+      |> Map.get(:env, %{})
+      |> Map.get(:__client__, %{})
+      |> Map.get(:adapter)
+
+    case adapter do
+      {Tesla.Adapter.Finch, _, _} -> true
+      _ -> false
+    end
+  end
+
+  defp extract_host(url) when is_binary(url) do
+    case URI.parse(url) do
+      %URI{host: host} when is_binary(host) and host != "" -> host
+      _ -> nil
+    end
+  end
+
+  defp extract_host(_), do: nil
+end
diff --git a/lib/honeybadger/utils.ex b/lib/honeybadger/utils.ex
index 89b13a8..f1914a5 100644
--- a/lib/honeybadger/utils.ex
+++ b/lib/honeybadger/utils.ex
@@ -18,6 +18,18 @@ defmodule Honeybadger.Utils do
     |> Enum.join(".")
   end
 
+  @doc """
+  Concatenate a list of items with a dot separator.
+
+  # Example
+
+      iex> Honeybadger.Utils.dotify([:Honeybadger, :Utils])
+      "Honeybadger.Utils"
+  """
+  def dotify(path) when is_list(path) do
+    Enum.map_join(path, ".", &to_string/1)
+  end
+
   @doc """
   Transform value into a consistently cased string representation
 
@@ -39,6 +51,9 @@ defmodule Honeybadger.Utils do
   - recursively truncates deep structures (to a depth of 20)
   - constrains large string values (to 64k)
   - filters out any map keys that might contain sensitive information.
+
+  Options:
+  - `:remove_filtered` - When `true`, filtered keys will be removed instead of replaced with "[FILTERED]". Default: `false`
   """
   @depth_token "[DEPTH]"
   @truncated_token "[TRUNCATED]"
@@ -52,7 +67,8 @@ defmodule Honeybadger.Utils do
     base = %{
       max_depth: @default_max_depth,
       max_string_size: @default_max_string_size,
-      filter_keys: Honeybadger.get_env(:filter_keys)
+      filter_keys: Honeybadger.get_env(:filter_keys),
+      remove_filtered: false
     }
 
     opts =
@@ -70,14 +86,22 @@ defmodule Honeybadger.Utils do
     sanitize_val(Map.from_struct(struct), opts)
   end
 
-  defp sanitize_val(v, %{depth: depth, filter_keys: filter_keys} = opts) when is_map(v) do
-    for {key, val} <- v, into: %{} do
+  defp sanitize_val(v, opts) when is_map(v) do
+    %{depth: depth, filter_keys: filter_keys, remove_filtered: remove_filtered} = opts
+
+    Enum.reduce(v, %{}, fn {key, val}, acc ->
       if MapSet.member?(filter_keys, canonicalize(key)) do
-        {key, @filtered_token}
+        if remove_filtered do
+          # Skip this key entirely when remove_filtered is true
+          acc
+        else
+          # Traditional behavior: replace with filtered token
+          Map.put(acc, key, @filtered_token)
+        end
       else
-        {key, sanitize_val(val, Map.put(opts, :depth, depth + 1))}
+        Map.put(acc, key, sanitize_val(val, Map.put(opts, :depth, depth + 1)))
       end
-    end
+    end)
   end
 
   defp sanitize_val(v, %{depth: depth} = opts) when is_list(v) do
diff --git a/mix.exs b/mix.exs
index a4f7a32..9ebe6b1 100644
--- a/mix.exs
+++ b/mix.exs
@@ -49,6 +49,7 @@ defmodule Honeybadger.Mixfile do
   # into the host apps mix tasks. This way our release task is shown only in
   # our mix tasks
   defp elixirc_paths(:dev), do: ["lib", "mix"]
+  defp elixirc_paths(:test), do: ["lib", "test/support"]
   defp elixirc_paths(_), do: ["lib"]
 
   def application do
@@ -72,12 +73,16 @@ defmodule Honeybadger.Mixfile do
 
         # Filters
         notice_filter: Honeybadger.NoticeFilter.Default,
+        event_filter: Honeybadger.EventFilter.Default,
         filter: Honeybadger.Filter.Default,
-        filter_keys: [:password, :credit_card],
+        filter_keys: [:password, :credit_card, :__changed__, :flash, :_csrf_token],
         filter_args: false,
         filter_disable_url: false,
         filter_disable_params: false,
+        filter_disable_assigns: false,
         filter_disable_session: false,
+        insights_enabled: false,
+        insights_config: %{},
 
         # Events
         events_worker_enabled: true,


Test patch:
diff --git a/test/honeybadger/insights/absinthe_test.exs b/test/honeybadger/insights/absinthe_test.exs
new file mode 100644
index 0000000..39cd310
--- /dev/null
+++ b/test/honeybadger/insights/absinthe_test.exs
@@ -0,0 +1,156 @@
+defmodule Honeybadger.Insights.AbsintheTest do
+  use Honeybadger.Case, async: false
+  use Honeybadger.InsightsCase
+
+  # Define mock module for testing
+  defmodule Absinthe do
+  end
+
+  describe "Absinthe instrumentation" do
+    test "extracts metadata from operation stop event" do
+      operation = %{
+        name: "GetUser",
+        type: :query,
+        current: true,
+        selections: [
+          %{name: "user"},
+          %{name: "profile"}
+        ]
+      }
+
+      blueprint = %{
+        schema: MyApp.Schema,
+        operations: [operation],
+        result: %{
+          errors: nil
+        }
+      }
+
+      event =
+        send_and_receive(
+          [:absinthe, :execute, :operation, :stop],
+          %{duration: System.convert_time_unit(25, :millisecond, :native)},
+          %{blueprint: blueprint}
+        )
+
+      assert event["event_type"] == "absinthe.execute.operation.stop"
+      assert event["operation_name"] == "GetUser"
+      assert event["operation_type"] == "query"
+      assert event["selections"] == ["user", "profile"]
+      assert event["schema"] == "Elixir.MyApp.Schema"
+      assert event["errors"] == nil
+      assert event["duration"] == 25
+    end
+
+    test "extracts metadata from operation exception event" do
+      operation = %{
+        name: "GetUser",
+        type: :query,
+        current: true,
+        selections: [
+          %{name: "user"},
+          %{name: "profile"}
+        ]
+      }
+
+      blueprint = %{
+        schema: MyApp.Schema,
+        operations: [operation],
+        result: %{
+          errors: [%{message: "Field 'user' not found"}]
+        }
+      }
+
+      event =
+        send_and_receive(
+          [:absinthe, :execute, :operation, :exception],
+          %{duration: System.convert_time_unit(15, :millisecond, :native)},
+          %{blueprint: blueprint}
+        )
+
+      assert event["event_type"] == "absinthe.execute.operation.exception"
+      assert event["operation_name"] == "GetUser"
+      assert event["operation_type"] == "query"
+      assert event["selections"] == ["user", "profile"]
+      assert event["schema"] == "Elixir.MyApp.Schema"
+      assert event["errors"] == [%{"message" => "Field 'user' not found"}]
+      assert event["duration"] == 15
+    end
+
+    test "extracts metadata from resolve field stop event" do
+      restart_with_config(
+        insights_config: %{absinthe: %{telemetry_events: [[:absinthe, :resolve, :field, :stop]]}}
+      )
+
+      resolution = %{
+        definition: %{
+          name: "name"
+        },
+        parent_type: %{
+          name: "User"
+        },
+        state: :resolved
+      }
+
+      event =
+        send_and_receive(
+          [:absinthe, :resolve, :field, :stop],
+          %{duration: System.convert_time_unit(5, :millisecond, :native)},
+          %{resolution: resolution}
+        )
+
+      assert event["event_type"] == "absinthe.resolve.field.stop"
+      assert event["field_name"] == "name"
+      assert event["parent_type"] == "User"
+      assert event["state"] == "resolved"
+      assert event["duration"] == 5
+    end
+
+    test "handles missing blueprint data gracefully" do
+      event =
+        send_and_receive(
+          [:absinthe, :execute, :operation, :stop],
+          %{duration: System.convert_time_unit(10, :millisecond, :native)},
+          # No blueprint data
+          %{}
+        )
+
+      assert event["event_type"] == "absinthe.execute.operation.stop"
+      assert event["operation_name"] == nil
+      assert event["operation_type"] == nil
+      assert event["selections"] == []
+      assert event["schema"] == nil
+      assert event["errors"] == nil
+      assert event["duration"] == 10
+    end
+
+    test "handles operations without selections" do
+      operation = %{
+        name: "GetUser",
+        type: :query,
+        current: true
+        # No selections key
+      }
+
+      blueprint = %{
+        schema: MyApp.Schema,
+        operations: [operation]
+      }
+
+      event =
+        send_and_receive(
+          [:absinthe, :execute, :operation, :stop],
+          %{duration: System.convert_time_unit(8, :millisecond, :native)},
+          %{blueprint: blueprint}
+        )
+
+      assert event["event_type"] == "absinthe.execute.operation.stop"
+      assert event["operation_name"] == "GetUser"
+      assert event["operation_type"] == "query"
+      # Should handle missing selections
+      assert event["selections"] == []
+      assert event["schema"] == "Elixir.MyApp.Schema"
+      assert event["duration"] == 8
+    end
+  end
+end
diff --git a/test/honeybadger/insights/base_test.exs b/test/honeybadger/insights/base_test.exs
new file mode 100644
index 0000000..01e7206
--- /dev/null
+++ b/test/honeybadger/insights/base_test.exs
@@ -0,0 +1,149 @@
+defmodule Honeybadger.Insights.BaseTest do
+  use Honeybadger.Case, async: false
+
+  defmodule TestEventFilter do
+    def filter_telemetry_event(data, _raw, _name) do
+      Map.put(data, :was_filtered, true)
+    end
+  end
+
+  defmodule TestInsights do
+    use Honeybadger.Insights.Base
+
+    @telemetry_events [
+      [:test, :event, :one],
+      [:test, :event, :two]
+    ]
+
+    # Minimal extraction to verify it's called
+    def extract_metadata(meta, _event) do
+      Map.put(meta, :was_processed, true)
+    end
+
+    # Override process_event to simply send the message to test process
+    def process_event(event_data) do
+      send(self(), {:event_processed, event_data})
+    end
+
+    # We need to make sure to detach so each run is clean
+    def detach() do
+      Enum.each(@telemetry_events, fn e ->
+        :telemetry.detach(Honeybadger.Utils.dotify(e))
+      end)
+    end
+  end
+
+  test "attaches to and processes telemetry events" do
+    with_config([event_filter: TestEventFilter], fn ->
+      TestInsights.attach()
+
+      # Send test events
+      :telemetry.execute([:test, :event, :one], %{value: 1}, %{data: "test"})
+      :telemetry.execute([:test, :event, :two], %{value: 2}, %{data: "other"})
+
+      # Just verify we got both events and they were processed
+      assert_received {:event_processed, event1}, 50
+      assert event1.event_type == "test.event.one"
+      assert event1.was_processed
+      assert event1.was_filtered
+
+      assert_received {:event_processed, event2}
+      assert event2.event_type == "test.event.two"
+      assert event2.was_processed
+      assert event1.was_filtered
+
+      TestInsights.detach()
+    end)
+  end
+
+  test "process measurement data" do
+    TestInsights.attach()
+
+    measurements = %{
+      duration: 1_000_000,
+      total_time: 2_000_000,
+      decode_time: 3_000_000,
+      query_time: 4_000_000,
+      queue_time: 5_000_000,
+      idle_time: 6_000_000,
+      monotonic_time: 10_000_000
+    }
+
+    :telemetry.execute([:test, :event, :two], measurements, %{data: "other"})
+
+    assert_received {:event_processed, event}, 50
+
+    # Hardcoded expected values after converting native to milliseconds
+    # Assuming 1000 native time units equal 1 millisecond:
+    assert event.duration == 1
+    assert event.total_time == 2
+    assert event.decode_time == 3
+    assert event.query_time == 4
+    assert event.queue_time == 5
+    assert event.idle_time == 6
+
+    refute Map.has_key?(event, :monotonic_time)
+
+    TestInsights.detach()
+  end
+
+  test "sanitizes nested data" do
+    TestInsights.attach()
+
+    :telemetry.execute([:test, :event, :one], %{value: 1}, %{
+      data: "test",
+      nested: %{
+        __changed__: "changed",
+        more: %{
+          flash: "bang"
+        }
+      }
+    })
+
+    assert_received {:event_processed, event}, 50
+    refute get_in(event, [:nested, :more, :flash])
+    refute get_in(event, [:nested, :__changed__])
+
+    TestInsights.detach()
+  end
+
+  test "removes params" do
+    with_config(
+      [filter_disable_params: true, filter_disable_assigns: true, filter_disable_session: true],
+      fn ->
+        TestInsights.attach()
+
+        :telemetry.execute([:test, :event, :two], %{value: 1}, %{
+          data: "test",
+          session: %{user_id: 123},
+          assigns: %{other: "value"},
+          params: %{password: "secret"}
+        })
+
+        assert_received {:event_processed, event}, 50
+        refute event[:params]
+        refute event[:assigns]
+        refute event[:assigns]
+
+        TestInsights.detach()
+      end
+    )
+  end
+
+  test "limits telemetry events" do
+    with_config(
+      [insights_config: %{test_insights: %{telemetry_events: [[:test, :event, :one]]}}],
+      fn ->
+        TestInsights.attach()
+
+        :telemetry.execute([:test, :event, :one], %{value: 1}, %{data: "test"})
+        :telemetry.execute([:test, :event, :two], %{value: 2}, %{data: "other"})
+
+        assert_received {:event_processed, _event1}, 50
+        refute_received {:event_processed, _event2}
+
+        TestInsights.detach()
+      end
+    )
+  end
+end
diff --git a/test/honeybadger/insights/ecto_test.exs b/test/honeybadger/insights/ecto_test.exs
new file mode 100644
index 0000000..ac56ce8
--- /dev/null
+++ b/test/honeybadger/insights/ecto_test.exs
@@ -0,0 +1,107 @@
+defmodule Honeybadger.Insights.EctoTest do
+  use Honeybadger.Case, async: false
+  use Honeybadger.InsightsCase
+
+  alias Honeybadger.Insights
+
+  defmodule Ecto.Repo do
+  end
+
+  defmodule Adapter.Postgres do
+  end
+
+  defmodule Test.MockEctoConfig do
+    def config() do
+      [telemetry_prefix: [:a, :b]]
+    end
+  end
+
+  setup do
+    restart_with_config(ecto_repos: [Test.MockEctoConfig])
+  end
+
+  describe "Ecto instrumentation" do
+    test "extracts metadata from query event" do
+      event =
+        send_and_receive(
+          [:a, :b, :query],
+          %{
+            total_time: System.convert_time_unit(25, :millisecond, :native),
+            decode_time: System.convert_time_unit(5, :millisecond, :native),
+            query_time: System.convert_time_unit(15, :millisecond, :native),
+            queue_time: System.convert_time_unit(5, :millisecond, :native)
+          },
+          %{
+            query: "SELECT u0.id, u0.name FROM users u0 WHERE u0.id = ?",
+            source: "users",
+            repo: %{__adapter__: Adapter.Postgres}
+          }
+        )
+
+      assert event["query"] =~ "SELECT u0.id, u0.name FROM users u0 WHERE u0.id = ?"
+      assert event["source"] == "users"
+      assert event["total_time"] == 25
+      assert event["decode_time"] == 5
+      assert event["query_time"] == 15
+      assert event["queue_time"] == 5
+    end
+
+    test "ignores excluded sources" do
+      with_config([insights_config: %{ecto: %{excluded_sources: ["users"]}}], fn ->
+        :telemetry.execute(
+          [:a, :b, :query],
+          %{},
+          %{
+            query: "SELECT u0.id, u0.name FROM users u0 WHERE u0.id = ?",
+            source: "users",
+            repo: %{__adapter__: Adapter.Postgres}
+          }
+        )
+
+        refute_receive {:api_request, _}
+      end)
+    end
+
+    test "ignores excluded queries" do
+      with_config([insights_config: %{ecto: %{excluded_queries: [~r/FROM colors/]}}], fn ->
+        :telemetry.execute(
+          [:a, :b, :query],
+          %{},
+          %{
+            query: "SELECT a, b FROM colors WHERE a = ?",
+            source: "users",
+            repo: %{__adapter__: Adapter.Postgres}
+          }
+        )
+
+        refute_receive {:api_request, _}
+      end)
+    end
+  end
+
+  describe "obfuscate/2" do
+    test "replaces single quoted strings and numbers for non-Postgres adapter" do
+      sql = "  SELECT * FROM users WHERE name = 'John' AND age = 42  "
+      expected = "SELECT * FROM users WHERE name = '?' AND age = ?"
+      assert Insights.Ecto.obfuscate(sql, "Ecto.Adapters.MySQL") == expected
+    end
+
+    test "replaces double quoted strings for non-Postgres adapter" do
+      sql = "SELECT * FROM items WHERE category = \"books\""
+      expected = "SELECT * FROM items WHERE category = \"?\""
+      assert Insights.Ecto.obfuscate(sql, "Ecto.Adapters.MySQL") == expected
+    end
+
+    test "leaves double quoted strings intact for Postgres adapter" do
+      sql = "SELECT * FROM items WHERE category = \"books\""
+      expected = "SELECT * FROM items WHERE category = \"books\""
+      assert Insights.Ecto.obfuscate(sql, "Ecto.Adapters.Postgres") == expected
+    end
+
+    test "combines multiple replacements" do
+      sql = "INSERT INTO users (name, age, token) VALUES ('Alice', 30, 'secret')"
+      expected = "INSERT INTO users (name, age, token) VALUES ('?', ?, '?')"
+      assert Insights.Ecto.obfuscate(sql, "Ecto.Adapters.MySQL") == expected
+    end
+  end
+end
diff --git a/test/honeybadger/insights/finch_test.exs b/test/honeybadger/insights/finch_test.exs
new file mode 100644
index 0000000..cc3520f
--- /dev/null
+++ b/test/honeybadger/insights/finch_test.exs
@@ -0,0 +1,161 @@
+defmodule Honeybadger.Insights.FinchTest do
+  use Honeybadger.Case, async: false
+  use Honeybadger.InsightsCase
+
+  describe "Finch instrumentation" do
+    test "captures Finch request events" do
+      event =
+        send_and_receive(
+          [:finch, :request, :stop],
+          %{duration: System.convert_time_unit(10, :millisecond, :native)},
+          %{
+            name: :my_client,
+            request: %{
+              method: "GET",
+              scheme: :https,
+              host: "api.example.com",
+              port: nil,
+              path: "/users"
+            },
+            result: {:ok, %Finch.Response{status: 200}}
+          }
+        )
+
+      assert event["event_type"] == "finch.request.stop"
+      assert event["method"] == "GET"
+      assert event["host"] == "api.example.com"
+      assert event["status"] == 200
+      assert event["duration"] == 10
+
+      refute event["url"]
+    end
+
+    test "captures error responses" do
+      error = RuntimeError.exception("connection refused")
+
+      event =
+        send_and_receive(
+          [:finch, :request, :stop],
+          %{duration: System.convert_time_unit(25, :millisecond, :native)},
+          %{
+            name: :my_client,
+            request: %{
+              method: "POST",
+              scheme: :https,
+              host: "api.example.com",
+              port: 443,
+              path: "/users"
+            },
+            result: {:error, error}
+          }
+        )
+
+      assert event["event_type"] == "finch.request.stop"
+      assert event["method"] == "POST"
+      assert event["host"] == "api.example.com"
+      assert event["error"] == "connection refused"
+      assert event["duration"] == 25
+    end
+
+    test "captures streaming responses" do
+      event =
+        send_and_receive(
+          [:finch, :request, :stop],
+          %{duration: System.convert_time_unit(15, :millisecond, :native)},
+          %{
+            name: :my_client,
+            request: %{
+              method: "GET",
+              scheme: :https,
+              host: "api.example.com",
+              port: 443,
+              path: "/stream"
+            },
+            # Simulating a streaming accumulator
+            result: {:ok, []}
+          }
+        )
+
+      assert event["event_type"] == "finch.request.stop"
+      assert event["method"] == "GET"
+      assert event["host"] == "api.example.com"
+      assert event["streaming"] == true
+      assert event["duration"] == 15
+    end
+
+    test "captures full url when configured" do
+      with_config([insights_config: %{finch: %{full_url: true}}], fn ->
+        event =
+          send_and_receive(
+            [:finch, :request, :stop],
+            %{duration: System.convert_time_unit(8, :millisecond, :native)},
+            %{
+              name: :my_client,
+              request: %{
+                method: "GET",
+                scheme: :https,
+                host: "api.example.com",
+                port: 443,
+                path: "/users"
+              },
+              result: {:ok, %Finch.Response{status: 200}}
+            }
+          )
+
+        assert event["event_type"] == "finch.request.stop"
+        assert event["method"] == "GET"
+        assert event["host"] == "api.example.com"
+        assert event["url"] == "https://api.example.com/users"
+        assert event["status"] == 200
+        assert event["duration"] == 8
+      end)
+    end
+
+    test "handles non-standard ports correctly" do
+      event =
+        send_and_receive(
+          [:finch, :request, :stop],
+          %{duration: System.convert_time_unit(12, :millisecond, :native)},
+          %{
+            name: :my_client,
+            request: %{
+              method: "GET",
+              scheme: :http,
+              host: "localhost",
+              port: 8080,
+              path: "/api"
+            },
+            result: {:ok, %Finch.Response{status: 200}}
+          }
+        )
+
+      assert event["event_type"] == "finch.request.stop"
+      assert event["method"] == "GET"
+      assert event["host"] == "localhost"
+      assert event["status"] == 200
+      assert event["duration"] == 12
+
+      # Test with full_url config
+      with_config([insights_config: %{finch: %{full_url: true}}], fn ->
+        event =
+          send_and_receive(
+            [:finch, :request, :stop],
+            %{duration: System.convert_time_unit(12, :millisecond, :native)},
+            %{
+              name: :my_client,
+              request: %{
+                method: "GET",
+                scheme: :http,
+                host: "localhost",
+                port: 8080,
+                path: "/api"
+              },
+              result: {:ok, %Finch.Response{status: 200}}
+            }
+          )
+
+        assert event["url"] == "http://localhost:8080/api"
+      end)
+    end
+  end
+end
diff --git a/test/honeybadger/insights/live_view_test.exs b/test/honeybadger/insights/live_view_test.exs
new file mode 100644
index 0000000..5396bfc
--- /dev/null
+++ b/test/honeybadger/insights/live_view_test.exs
@@ -0,0 +1,57 @@
+defmodule Honeybadger.Insights.LiveViewTest do
+  use Honeybadger.Case, async: false
+  use Honeybadger.InsightsCase
+
+  # Define mock module for testing
+  defmodule Phoenix.LiveView do
+  end
+
+  describe "LiveView instrumentation" do
+    test "extracts metadata from mount event" do
+      event =
+        send_and_receive(
+          [:phoenix, :live_view, :mount, :stop],
+          %{duration: System.convert_time_unit(15, :millisecond, :native)},
+          %{
+            uri: "/dashboard",
+            socket_id: "phx-Fxyz123",
+            socket: %{
+              view: MyApp.DashboardLive,
+              assigns: %{
+                page_title: "Dashboard",
+                user_id: 123
+              }
+            },
+            params: %{"tab" => "overview"}
+          }
+        )
+
+      assert event["url"] == "/dashboard"
+      assert event["socket_id"] == "phx-Fxyz123"
+      assert event["view"] == "MyApp.DashboardLive"
+      assert event["params"] == %{"tab" => "overview"}
+      assert event["assigns"]["page_title"] == "Dashboard"
+      assert event["assigns"]["user_id"] == 123
+    end
+
+    test "handles missing socket data gracefully" do
+      event =
+        send_and_receive(
+          [:phoenix, :live_view, :mount, :stop],
+          %{duration: System.convert_time_unit(10, :millisecond, :native)},
+          %{
+            uri: "/dashboard",
+            socket_id: "phx-Ghi012",
+            params: %{"id" => "123"}
+            # No socket data provided
+          }
+        )
+
+      assert event["url"] == "/dashboard"
+      assert event["socket_id"] == "phx-Ghi012"
+      assert event["params"] == %{"id" => "123"}
+      assert event["view"] == nil
+      assert event["assigns"] == nil
+    end
+  end
+end
diff --git a/test/honeybadger/insights/oban_test.exs b/test/honeybadger/insights/oban_test.exs
new file mode 100644
index 0000000..2269853
--- /dev/null
+++ b/test/honeybadger/insights/oban_test.exs
@@ -0,0 +1,72 @@
+defmodule Honeybadger.Insights.ObanTest do
+  use Honeybadger.Case, async: false
+  use Honeybadger.InsightsCase
+
+  # Define mock module for testing
+  defmodule Oban do
+  end
+
+  describe "Oban instrumentation" do
+    test "extracts metadata from job stop event" do
+      event =
+        send_and_receive(
+          [:oban, :job, :stop],
+          %{duration: System.convert_time_unit(150, :millisecond, :native)},
+          %{
+            conf: %{prefix: "oban_jobs"},
+            job: %{
+              id: 123,
+              args: %{"user_id" => 456, "action" => "send_welcome_email"},
+              attempt: 1,
+              queue: "mailer",
+              worker: "MyApp.Mailers.WelcomeMailer",
+              tags: ["email", "onboarding"]
+            },
+            state: :success
+          }
+        )
+
+      assert event["event_type"] == "oban.job.stop"
+      assert event["id"] == 123
+      assert event["args"] == %{"user_id" => 456, "action" => "send_welcome_email"}
+      assert event["attempt"] == 1
+      assert event["queue"] == "mailer"
+      assert event["worker"] == "MyApp.Mailers.WelcomeMailer"
+      assert event["tags"] == ["email", "onboarding"]
+      assert event["prefix"] == "oban_jobs"
+      assert event["state"] == "success"
+      assert event["duration"] == 150
+    end
+
+    test "extracts metadata from job exception event" do
+      event =
+        send_and_receive(
+          [:oban, :job, :exception],
+          %{duration: System.convert_time_unit(75, :millisecond, :native)},
+          %{
+            conf: %{prefix: "oban_jobs"},
+            job: %{
+              id: 456,
+              args: %{"post_id" => 789, "action" => "process_image"},
+              attempt: 2,
+              queue: "media",
+              worker: "MyApp.Media.ImageProcessor",
+              tags: ["image", "processing"]
+            },
+            state: :failure
+          }
+        )
+
+      assert event["event_type"] == "oban.job.exception"
+      assert event["id"] == 456
+      assert event["args"] == %{"post_id" => 789, "action" => "process_image"}
+      assert event["attempt"] == 2
+      assert event["queue"] == "media"
+      assert event["worker"] == "MyApp.Media.ImageProcessor"
+      assert event["tags"] == ["image", "processing"]
+      assert event["prefix"] == "oban_jobs"
+      assert event["state"] == "failure"
+      assert event["duration"] == 75
+    end
+  end
+end
diff --git a/test/honeybadger/insights/plug_test.exs b/test/honeybadger/insights/plug_test.exs
new file mode 100644
index 0000000..69b797c
--- /dev/null
+++ b/test/honeybadger/insights/plug_test.exs
@@ -0,0 +1,93 @@
+defmodule Honeybadger.Insights.PlugTest do
+  use Honeybadger.Case, async: false
+  use Honeybadger.InsightsCase
+
+  def mock_conn(attrs \\ %{}) do
+    conn = %Plug.Conn{
+      method: "GET",
+      request_path: "/users/123",
+      params: %{"id" => "123"},
+      status: 200,
+      assigns: %{},
+      req_headers: %{},
+      adapter: {nil, nil},
+      owner: self(),
+      remote_ip: {127, 0, 0, 1}
+    }
+
+    Map.merge(conn, attrs)
+  end
+
+  describe "Plug instrumentation" do
+    test "extracts metadata from plug event with request_id in assigns" do
+      event =
+        send_and_receive(
+          [:phoenix, :endpoint, :stop],
+          %{duration: System.convert_time_unit(15, :millisecond, :native)},
+          %{
+            conn:
+              mock_conn(%{
+                assigns: %{request_id: "abc-xyz-123"}
+              })
+          }
+        )
+
+      assert event["method"] == "GET"
+      assert event["request_path"] == "/users/123"
+      assert event["params"] == %{"id" => "123"}
+      assert event["status"] == 200
+      assert event["request_id"] == "abc-xyz-123"
+      assert event["duration"] == 15
+    end
+
+    test "extracts request_id from headers when not in assigns" do
+      event =
+        send_and_receive(
+          [:phoenix, :endpoint, :stop],
+          %{duration: System.convert_time_unit(10, :millisecond, :native)},
+          %{
+            conn:
+              mock_conn(%{
+                method: "POST",
+                request_path: "/api/items",
+                params: %{"title" => "New Item"},
+                status: 201,
+                assigns: %{},
+                resp_headers: [{"x-request-id", "req-123-456"}]
+              })
+          }
+        )
+
+      assert event["method"] == "POST"
+      assert event["request_path"] == "/api/items"
+      assert event["params"] == %{"title" => "New Item"}
+      assert event["status"] == 201
+      assert event["request_id"] == "req-123-456"
+      assert event["duration"] == 10
+    end
+
+    test "handles missing request_id gracefully" do
+      event =
+        send_and_receive(
+          [:phoenix, :endpoint, :stop],
+          %{duration: System.convert_time_unit(5, :millisecond, :native)},
+          %{
+            conn:
+              mock_conn(%{
+                method: "DELETE",
+                request_path: "/api/items/456",
+                params: %{"id" => "456"},
+                status: 204
+              })
+          }
+        )
+
+      assert event["method"] == "DELETE"
+      assert event["request_path"] == "/api/items/456"
+      assert event["params"] == %{"id" => "456"}
+      assert event["status"] == 204
+      assert event["request_id"] == nil
+      assert event["duration"] == 5
+    end
+  end
+end
diff --git a/test/honeybadger/insights/tesla_test.exs b/test/honeybadger/insights/tesla_test.exs
new file mode 100644
index 0000000..5298ae3
--- /dev/null
+++ b/test/honeybadger/insights/tesla_test.exs
@@ -0,0 +1,99 @@
+# Define a mock Tesla module just for testing
+defmodule Tesla do
+  defmodule Adapter do
+    defmodule Finch do
+    end
+  end
+end
+
+defmodule Honeybadger.Insights.TeslaTest do
+  use Honeybadger.Case, async: false
+  use Honeybadger.InsightsCase
+
+  describe "Tesla instrumentation" do
+    test "captures Tesla request events" do
+      event =
+        send_and_receive(
+          [:tesla, :request, :stop],
+          %{duration: System.convert_time_unit(5, :millisecond, :native)},
+          %{
+            env: %{
+              method: :get,
+              url: "https://api.example.com",
+              status: 200
+            }
+          }
+        )
+
+      assert event["event_type"] == "tesla.request.stop"
+      assert event["method"] == "GET"
+      assert event["host"] == "api.example.com"
+      assert event["status_code"] == 200
+      assert event["duration"] == 5
+
+      refute event["url"]
+    end
+
+    test "captures Tesla request exceptions" do
+      event =
+        send_and_receive(
+          [:tesla, :request, :exception],
+          %{duration: System.convert_time_unit(30, :millisecond, :native)},
+          %{
+            env: %{
+              method: :post,
+              url: "https://a.example.net/users",
+              status: 500
+            }
+          }
+        )
+
+      # Assert the correct data was included
+      assert event["event_type"] == "tesla.request.exception"
+      assert event["method"] == "POST"
+      assert event["host"] == "a.example.net"
+      assert event["status_code"] == 500
+      assert event["duration"] == 30
+    end
+
+    test "Ignores telemetry events from Finch adapters" do
+      :telemetry.execute(
+        [:tesla, :request, :stop],
+        %{},
+        %{
+          env: %{
+            method: :post,
+            url: "https://a.example.net/users",
+            status: 500,
+            __client__: %{adapter: {Tesla.Adapter.Finch, :a, :b}}
+          }
+        }
+      )
+
+      refute_receive {:api_request, _}
+    end
+
+    test "captures full url" do
+      with_config([insights_config: %{tesla: %{full_url: true}}], fn ->
+        event =
+          send_and_receive(
+            [:tesla, :request, :stop],
+            %{duration: System.convert_time_unit(5, :millisecond, :native)},
+            %{
+              env: %{
+                method: :get,
+                url: "https://api.example.com",
+                status: 200
+              }
+            }
+          )
+
+        assert event["event_type"] == "tesla.request.stop"
+        assert event["method"] == "GET"
+        assert event["url"] == "https://api.example.com"
+        assert event["status_code"] == 200
+        assert event["duration"] == 5
+      end)
+    end
+  end
+end
diff --git a/test/honeybadger/notice_test.exs b/test/honeybadger/notice_test.exs
index c5a9686..84ee24e 100644
--- a/test/honeybadger/notice_test.exs
+++ b/test/honeybadger/notice_test.exs
@@ -96,7 +96,14 @@ defmodule Honeybadger.NoticeTest do
   test "default active filters", %{notice: notice} do
     assert Honeybadger.get_env(:notice_filter) == Honeybadger.NoticeFilter.Default
     assert Honeybadger.get_env(:filter) == Honeybadger.Filter.Default
-    assert Honeybadger.get_env(:filter_keys) == [:password, :credit_card]
+
+    assert Honeybadger.get_env(:filter_keys) == [
+             :password,
+             :credit_card,
+             :__changed__,
+             :flash,
+             :_csrf_token
+           ]
 
     refute get_in(notice.request, [:params, :password])
   end
diff --git a/test/support/insights_case.ex b/test/support/insights_case.ex
new file mode 100644
index 0000000..93fe23a
--- /dev/null
+++ b/test/support/insights_case.ex
@@ -0,0 +1,41 @@
+defmodule Honeybadger.InsightsCase do
+  @moduledoc """
+  This module defines helpers for testing
+  Honeybadger.Insights instrumentation.
+  """
+
+  use ExUnit.CaseTemplate
+
+  using do
+    quote do
+      # Import helpers from this module
+      import Honeybadger.InsightsCase
+
+      setup do
+        {:ok, _} = Honeybadger.API.start(self())
+
+        restart_with_config(
+          insights_enabled: true,
+          events_worker_enabled: false,
+          exclude_envs: []
+        )
+
+        on_exit(fn ->
+          Honeybadger.API.stop()
+        end)
+
+        :ok
+      end
+    end
+  end
+
+  @doc """
+  Sends a telemetry event and waits to receive the resulting API request.
+  Returns the event data from the request.
+  """
+  def send_and_receive(event, measurements, metadata) do
+    :telemetry.execute(event, measurements, metadata)
+    assert_receive {:api_request, request}
+    request
+  end
+end
diff --git a/test/test_helper.exs b/test/test_helper.exs
index 50a6cce..7ecd797 100644
--- a/test/test_helper.exs
+++ b/test/test_helper.exs
@@ -9,7 +9,7 @@ Application.put_all_env(
     environment_name: :test,
     api_key: "abc123",
     origin: "http://localhost:4444",
-    events_worker_enabled: true
+    insights_enabled: true
   ]
 )
 
@@ -18,6 +18,8 @@ Application.put_all_env(
 
 ExUnit.start(assert_receive_timeout: 1000, refute_receive_timeout: 1000)
 
+_ = ~w(live_component live_view live_socket phoenix operation)a
+
 defmodule Honeybadger.Case do
   use ExUnit.CaseTemplate