Repository: honeybadger-io/honeybadger-elixir
Pull request #606 (2025-04-04 21:59:09)

Add `Honeybadger.EventContext`
This refactors `Honeybadger.RequestId` to allow for a map of data to be stored and inserted into Insights events.

Check the README.md changes for more info.


Patch:
diff --git a/README.md b/README.md
index 2b06a79..8252248 100644
--- a/README.md
+++ b/README.md
@@ -272,6 +272,77 @@ Then configure the filter in your application's configuration:
 config :honeybadger, event_filter: MyApp.EventFilter
 ```
 
+### Event Context
+
+You can add custom metadata to the events sent to Honeybadger Insights by
+using the `event_context/1` function. This metadata will be included in each
+event call within the same process.
+
+Note: This will add the metadata to all events sent, so be careful not to
+include too much data. Try to keep it to simple key/value pairs.
+
+For example, you can add user ID information to all events (via plug):
+
+```elixir
+defmodule MyAppWeb.Plug.UserContext do
+  import Plug.Conn
+
+  def init(opts), do: opts
+
+  def call(conn, _opts) do
+    user = get_session(conn, :user)
+    Honeybadger.event_context(%{user_id: user.id})
+    conn
+  end
+end
+```
+
+Event context is not automatically propagated to other processes. If you want to
+add context to events in a different process, you can use the
+`Honeybadger.event_context/0` function to get the current context and pass it
+to the `Honeybadger.event/1` function:
+
+```elixir
+defmodule MyApp.MyGenServer do
+  use GenServer
+
+  def set(value) do
+    GenServer.cast(__MODULE__, {:set, value, Honeybadger.event_context()})
+  end
+
+  def handle_cast({:set, value, hb_context}, _state) do
+    Honeybadger.event_context(hb_context)
+
+    Honeybadger.event("set_value", %{value: value})
+    {:noreply, value}
+  end
+end
+```
+
+If you know the parent process is alive and part of the process tree, you can
+use `Honeybadger.inherit_event_context/0` to inherit the context from the
+parent process. This method works best if you are running OTP 25+.
+
+```elixir
+Task.async(fn ->
+  Honeybadger.inherit_event_context()
+  # Do some work here
+  Honeybadger.event("work", %{did: "some_work"})
+end)
+```
+
+Scenarios when inheriting context is useful include:
+
+- Direct process spawning (`spawn`, `spawn_link`)
+- `Task.async/1`, `Task.await/1`, etc.
+- Other scenarios where direct parent-child relationships are maintained
+
+When explicit passing is needed:
+
+- GenServer
+- Supervised processes
+- Processes started through other OTP abstractions
+
 ## Breadcrumbs
 
 Breadcrumbs allow you to record events along a processes execution path. If
diff --git a/lib/honeybadger.ex b/lib/honeybadger.ex
index 7b51c9c..dd0935b 100644
--- a/lib/honeybadger.ex
+++ b/lib/honeybadger.ex
@@ -361,9 +361,9 @@ defmodule Honeybadger do
 
   @spec event(map()) :: :ok
   def event(event_data) do
-    event_data
+    event_context()
+    |> Map.merge(event_data)
     |> Map.put_new(:ts, DateTime.utc_now(:millisecond) |> DateTime.to_iso8601())
-    |> maybe_add_request_id()
     |> event_filter()
     |> send_event()
   end
@@ -457,32 +457,51 @@ defmodule Honeybadger do
   end
 
   @doc """
-  Sets the request ID for the current process.
+  Retrieves the current event context.
   """
-  def put_request_id(request_id) do
-    Honeybadger.RequestId.put(request_id)
+  @spec event_context() :: map()
+  def event_context do
+    Honeybadger.EventContext.get()
   end
 
   @doc """
-  Retrieves the request ID for the current process.
+  Merges the given map or keyword list into the current event context.
   """
-  def get_request_id do
-    Honeybadger.RequestId.get()
+  @spec event_context(map() | keyword()) :: map()
+  def event_context(context) do
+    Honeybadger.EventContext.merge(context)
   end
 
-  @doc false
-  def clear_request_id do
-    Honeybadger.RequestId.put(nil)
+  @doc """
+  Inherits the current event context from a parent process.
+  """
+  @spec inherit_event_context() :: :already_initialized | :inherited | :not_found
+  def inherit_event_context do
+    Honeybadger.EventContext.inherit()
+  end
+
+  @doc """
+  Clears the current event context.
+  """
+  @spec clear_event_context() :: map()
+  def clear_event_context do
+    Honeybadger.EventContext.replace(%{})
   end
 
   @doc false
   def maybe_add_request_id(data) when is_map(data) do
-    case Honeybadger.RequestId.get() do
+    case Honeybadger.EventContext.get(:request_id) do
       nil -> data
       request_id -> Map.put(data, :request_id, request_id)
     end
   end
 
+  @doc false
+  # Merges event context into the map, with the data taking precedence.
+  def merge_into_event_context(data) do
+    Map.merge(event_context(), data)
+  end
+
   @doc """
   Fetch configuration specific to the :honeybadger application.
 
diff --git a/lib/honeybadger/event_context.ex b/lib/honeybadger/event_context.ex
new file mode 100644
index 0000000..1ecc83e
--- /dev/null
+++ b/lib/honeybadger/event_context.ex
@@ -0,0 +1,82 @@
+defmodule Honeybadger.EventContext do
+  @moduledoc false
+
+  @key __MODULE__
+
+  @doc """
+  Merge the given map or keyword list into the current event context.
+  """
+  @spec merge(map() | list()) :: map()
+  def merge(kw) when is_list(kw), do: merge(Map.new(kw))
+
+  def merge(context) when is_map(context) do
+    get()
+    |> Map.merge(context)
+    |> tap(&Process.put(@key, &1))
+  end
+
+  @doc """
+  Replace the current event context with the given map or keyword list.
+  """
+  @spec replace(map() | list()) :: map()
+  def replace(kw) when is_list(kw), do: replace(Map.new(kw))
+
+  def replace(context) when is_map(context) do
+    Process.put(@key, context)
+
+    context
+  end
+
+  @doc """
+  Put a new key-value pair in the current event context if the key does not
+  already exist. You can lazy initialize the value by passing a function that
+  returns the value. The function will only be called if the key does not exist
+  in the current event context.
+  """
+  @spec put_new(atom(), (-> any()) | any()) :: map()
+  def put_new(key, fun) when is_function(fun, 0) do
+    new_context = Map.put_new_lazy(get(), key, fun)
+    Process.put(@key, new_context)
+    new_context
+  end
+
+  def put_new(key, value) when is_atom(key) do
+    new_context = Map.put_new(get(), key, value)
+    Process.put(@key, new_context)
+    new_context
+  end
+
+  @doc """
+  Get the current event context map
+  """
+  @spec get() :: map()
+  def get, do: Process.get(@key, %{})
+
+  @spec get(atom()) :: any() | nil
+  def get(key) when is_atom(key) do
+    Map.get(get(), key)
+  end
+
+  @doc """
+  Get the current event context map from the closest parent process. Will not
+  inherit if current process already has a context.
+  """
+  @spec inherit() :: :already_initialized | :inherited | :not_found
+  def inherit do
+    if Process.get(@key) == nil do
+      case get_from_parents() do
+        nil ->
+          :not_found
+
+        data ->
+          merge(data)
+          :inherited
+      end
+    else
+      :already_initialized
+    end
+  end
+
+  @doc false
+  defp get_from_parents, do: ProcessTree.get(@key)
+end
diff --git a/lib/honeybadger/insights/live_view.ex b/lib/honeybadger/insights/live_view.ex
index 7ac4e58..f44e185 100644
--- a/lib/honeybadger/insights/live_view.ex
+++ b/lib/honeybadger/insights/live_view.ex
@@ -55,7 +55,11 @@ defmodule Honeybadger.Insights.LiveView do
         metadata,
         opts
       ) do
-    Honeybadger.RequestId.inherit_or_initialize()
+    Honeybadger.EventContext.inherit()
+
+    Honeybadger.EventContext.put_new(:request_id, fn ->
+      Honeybadger.Utils.rand_id()
+    end)
 
     if event in get_insights_config(:telemetry_events, @telemetry_events) do
       handle_event_impl(event, measurements, metadata, opts)
diff --git a/lib/honeybadger/insights/oban.ex b/lib/honeybadger/insights/oban.ex
index 53a04d3..a147805 100644
--- a/lib/honeybadger/insights/oban.ex
+++ b/lib/honeybadger/insights/oban.ex
@@ -36,19 +36,19 @@ defmodule Honeybadger.Insights.Oban do
   ## Public API
 
   @doc """
-  Adds the current Honeybadger request ID to the job's metadata.
+  Adds the current Honeybadger event context to the Oban job's metadata.
 
   ## Example
 
       MyApp.Worker.new()
-      |> Honeybadger.Insights.Oban.add_request_id()
+      |> Honeybadger.Insights.Oban.add_event_context()
       |> Oban.insert()
   """
-  def add_request_id(changeset) do
+  def add_event_context(changeset) do
     meta =
       changeset
       |> Ecto.Changeset.get_field(:meta, %{})
-      |> Map.put("hb_request_id", Honeybadger.get_request_id())
+      |> Map.put("hb_event_context", Honeybadger.event_context())
 
     Ecto.Changeset.put_change(changeset, :meta, meta)
   end
@@ -71,12 +71,16 @@ defmodule Honeybadger.Insights.Oban do
 
   @doc false
   def handle_telemetry([:oban, :job, :start] = event, measurements, metadata, opts) do
-    if request_id = metadata.job.meta["hb_request_id"] do
-      Honeybadger.put_request_id(request_id)
+    if event_context = metadata.job.meta["hb_event_context"] do
+      Honeybadger.event_context(event_context)
     else
-      Honeybadger.RequestId.inherit_or_initialize()
+      Honeybadger.inherit_event_context()
     end
 
+    Honeybadger.EventContext.put_new(:request_id, fn ->
+      Honeybadger.Utils.rand_id()
+    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/plug.ex b/lib/honeybadger/insights/plug.ex
index bbf6e49..312258e 100644
--- a/lib/honeybadger/insights/plug.ex
+++ b/lib/honeybadger/insights/plug.ex
@@ -49,7 +49,7 @@ defmodule Honeybadger.Insights.Plug do
     |> get_request_id()
     |> then(fn
       nil -> :ok
-      request_id -> Honeybadger.put_request_id(request_id)
+      request_id -> Honeybadger.EventContext.put_new(:request_id, request_id)
     end)
 
     if event in get_insights_config(:telemetry_events, @telemetry_events) do
diff --git a/lib/honeybadger/request_id.ex b/lib/honeybadger/request_id.ex
deleted file mode 100644
index 0256417..0000000
--- a/lib/honeybadger/request_id.ex
+++ /dev/null
@@ -1,44 +0,0 @@
-defmodule Honeybadger.RequestId do
-  @moduledoc false
-
-  @key __MODULE__
-
-  alias Honeybadger.Utils
-
-  @doc """
-  Put a request ID into the current process.
-  """
-  @spec put(String.t()) :: String.t() | nil
-  def put(id), do: Process.put(@key, id)
-
-  @doc """
-  Get the current request ID from the current process.
-  """
-  @spec get() :: String.t() | nil
-  def get, do: Process.get(@key)
-
-  @doc """
-  Store the request ID from any parent process or initialize a new one. This
-  will only work if the calling process is still alive. It is most reliable to
-  pass the request ID explicitly whenever possible.
-  """
-  @spec inherit_or_initialize((-> String.t())) :: :already_set | :initialized | :inherited
-  def inherit_or_initialize(default_fn \\ fn -> Utils.rand_id() end) do
-    if get() do
-      :already_set
-    else
-      case get_from_parents() do
-        nil ->
-          put(default_fn.())
-          :initialized
-
-        id ->
-          put(id)
-          :inherited
-      end
-    end
-  end
-
-  @doc false
-  defp get_from_parents, do: ProcessTree.get(@key)
-end


Test patch:
diff --git a/test/honeybadger/event_context_test.exs b/test/honeybadger/event_context_test.exs
new file mode 100644
index 0000000..115df55
--- /dev/null
+++ b/test/honeybadger/event_context_test.exs
@@ -0,0 +1,184 @@
+defmodule Honeybadger.EventContextTest do
+  use ExUnit.Case, async: true
+
+  alias Honeybadger.EventContext
+
+  setup do
+    # Clear event context before each test
+    Process.delete(Honeybadger.EventContext)
+    :ok
+  end
+
+  describe "get/0" do
+    test "returns an empty map when no context exists" do
+      assert EventContext.get() == %{}
+    end
+
+    test "returns the stored context" do
+      context = %{user_id: 123, action: "test"}
+      EventContext.replace(context)
+      assert EventContext.get() == context
+    end
+  end
+
+  describe "get/1" do
+    test "returns nil when no context exists" do
+      assert EventContext.get(:user_id) == nil
+    end
+
+    test "returns nil when key doesn't exist in context" do
+      EventContext.replace(%{action: "test"})
+      assert EventContext.get(:user_id) == nil
+    end
+
+    test "returns the value for the given key" do
+      EventContext.replace(%{user_id: 123, action: "test"})
+      assert EventContext.get(:user_id) == 123
+    end
+  end
+
+  describe "merge/1" do
+    test "with keyword list" do
+      result = EventContext.merge(user_id: 123, action: "test")
+      assert result == %{user_id: 123, action: "test"}
+      assert EventContext.get() == %{user_id: 123, action: "test"}
+    end
+
+    test "with map" do
+      result = EventContext.merge(%{user_id: 123, action: "test"})
+      assert result == %{user_id: 123, action: "test"}
+      assert EventContext.get() == %{user_id: 123, action: "test"}
+    end
+
+    test "merges with existing context" do
+      Process.put(Honeybadger.EventContext, %{user_id: 123})
+      result = EventContext.merge(%{action: "test"})
+      assert result == %{user_id: 123, action: "test"}
+      assert EventContext.get() == %{user_id: 123, action: "test"}
+    end
+
+    test "overwrites existing keys" do
+      Process.put(Honeybadger.EventContext, %{user_id: 123, action: "old"})
+      result = EventContext.merge(%{action: "new"})
+      assert result == %{user_id: 123, action: "new"}
+      assert EventContext.get() == %{user_id: 123, action: "new"}
+    end
+  end
+
+  describe "replace/1" do
+    test "with keyword list" do
+      result = EventContext.replace(user_id: 123, action: "test")
+      assert result == %{user_id: 123, action: "test"}
+      assert EventContext.get() == %{user_id: 123, action: "test"}
+    end
+
+    test "with map" do
+      result = EventContext.replace(%{user_id: 123, action: "test"})
+      assert result == %{user_id: 123, action: "test"}
+      assert EventContext.get() == %{user_id: 123, action: "test"}
+    end
+
+    test "replaces existing context" do
+      Process.put(Honeybadger.EventContext, %{user_id: 123, other: "value"})
+      result = EventContext.replace(%{action: "test"})
+      assert result == %{action: "test"}
+      assert EventContext.get() == %{action: "test"}
+    end
+  end
+
+  describe "put_new/2" do
+    test "adds key when it doesn't exist" do
+      result = EventContext.put_new(:user_id, 123)
+      assert result == %{user_id: 123}
+      assert EventContext.get() == %{user_id: 123}
+    end
+
+    test "doesn't overwrite existing key" do
+      Process.put(Honeybadger.EventContext, %{user_id: 123})
+      result = EventContext.put_new(:user_id, 456)
+      assert result == %{user_id: 123}
+      assert EventContext.get() == %{user_id: 123}
+    end
+
+    test "with function that returns value" do
+      expensive_function = fn -> 123 end
+      result = EventContext.put_new(:user_id, expensive_function)
+      assert result == %{user_id: 123}
+      assert EventContext.get() == %{user_id: 123}
+    end
+
+    test "function is not called when key exists" do
+      EventContext.replace(%{user_id: 123})
+
+      # Use a reference to track if the function was called
+      test_pid = self()
+
+      expensive_function = fn ->
+        send(test_pid, :function_was_called)
+        456
+      end
+
+      result = EventContext.put_new(:user_id, expensive_function)
+      assert result == %{user_id: 123}
+      assert EventContext.get() == %{user_id: 123}
+
+      # Verify the function was never called
+      refute_received :function_was_called
+    end
+  end
+
+  describe "inherit/0" do
+    test "returns :not_found when no parent context exists" do
+      # ProcessTree should return nil when there's no parent context
+      # This is testing the default case when inherit() is called
+      assert EventContext.inherit() == :not_found
+      assert EventContext.get() == %{}
+    end
+
+    test "returns :already_initialized when context already exists" do
+      EventContext.replace(%{user_id: 123})
+      assert EventContext.inherit() == :already_initialized
+    end
+
+    test "inherits parent context" do
+      parent_context = Honeybadger.EventContext.merge(%{user_id: 123, action: "test"})
+      test_pid = self()
+
+      Task.async(fn ->
+        # Simulate a child process inheriting the context
+        EventContext.inherit()
+        send(test_pid, {:context, EventContext.get()})
+      end)
+
+      receive do
+        {:context, spawn_context} ->
+          assert parent_context == spawn_context
+      after
+        1000 -> flunk("Test timed out")
+      end
+    end
+  end
+
+  # Test integration with Honeybadger module
+  describe "integration with Honeybadger module" do
+    test "Honeybadger.event_context/0 calls EventContext.get/0" do
+      context = %{user_id: 123, action: "test"}
+      Process.put(Honeybadger.EventContext, context)
+      assert Honeybadger.event_context() == context
+    end
+
+    test "Honeybadger.event_context/1 calls EventContext.merge/1" do
+      result = Honeybadger.event_context(%{user_id: 123})
+      assert result == %{user_id: 123}
+      assert EventContext.get() == %{user_id: 123}
+    end
+
+    test "Honeybadger.clear_event_context/0 clears the context" do
+      Honeybadger.event_context(%{user_id: 123})
+      assert Honeybadger.event_context() == %{user_id: 123}
+
+      Honeybadger.clear_event_context()
+      assert Honeybadger.event_context() == %{}
+    end
+  end
+end
diff --git a/test/honeybadger/insights/oban_test.exs b/test/honeybadger/insights/oban_test.exs
index 9a628c9..497db96 100644
--- a/test/honeybadger/insights/oban_test.exs
+++ b/test/honeybadger/insights/oban_test.exs
@@ -68,5 +68,35 @@ defmodule Honeybadger.Insights.ObanTest do
       assert event["state"] == "failure"
       assert event["duration"] == 75
     end
+
+    test "sets event_context if in metadata" do
+      :telemetry.execute(
+        [:oban, :job, :start],
+        %{},
+        %{job: %{meta: %{"hb_event_context" => %{user_id: 123, action: "generate_report"}}}}
+      )
+
+      event =
+        send_and_receive(
+          [:oban, :job, :stop],
+          %{duration: System.convert_time_unit(200, :microsecond, :native)},
+          %{
+            conf: %{prefix: "oban_jobs"},
+            job: %{
+              id: 789,
+              args: %{"user_id" => 123, "action" => "generate_report"},
+              attempt: 1,
+              queue: "reports",
+              worker: "MyApp.Reports.ReportGenerator",
+              tags: ["report", "generation"]
+            },
+            state: :success
+          }
+        )
+
+      assert event["event_type"] == "oban.job.stop"
+      assert event["user_id"] == 123
+      assert event["action"] == "generate_report"
+    end
   end
 end
diff --git a/test/honeybadger_test.exs b/test/honeybadger_test.exs
index 69dd947..ac98056 100644
--- a/test/honeybadger_test.exs
+++ b/test/honeybadger_test.exs
@@ -143,6 +143,15 @@ defmodule HoneybadgerTest do
 
       refute_receive {:api_request, _}
     end
+
+    test "includes request_id if within EventContext" do
+      restart_with_config(exclude_envs: [])
+      Honeybadger.event_context(%{request_id: "12345"})
+      err = %RuntimeError{message: "test error"}
+      Honeybadger.notify(err)
+      assert_receive {:api_request, notice}
+      assert notice["correlation_context"]["request_id"] == "12345"
+    end
   end
 
   describe "deprecated Honeybadger.notify works" do
@@ -531,5 +540,37 @@ defmodule HoneybadgerTest do
 
       assert request_events == stringified_events
     end
+
+    test "includes event_context if present" do
+      restart_with_config(
+        exclude_envs: [],
+        events_worker_enabled: false
+      )
+
+      event_data = %{event_type: "test_event", key: "value"}
+      Honeybadger.event_context(%{user_id: 123})
+      Honeybadger.event(event_data)
+
+      assert_receive {:api_request, data}
+      assert data["event_type"] == "test_event"
+      assert data["key"] == "value"
+      assert data["user_id"] == 123
+    end
+
+    test "overrides event_context with event data" do
+      restart_with_config(
+        exclude_envs: [],
+        events_worker_enabled: false
+      )
+
+      event_data = %{event_type: "test_event", key: "value", user_id: 456}
+      Honeybadger.event_context(%{user_id: 123})
+      Honeybadger.event(event_data)
+
+      assert_receive {:api_request, data}
+      assert data["event_type"] == "test_event"
+      assert data["key"] == "value"
+      assert data["user_id"] == 456
+    end
   end
 end