Repository: elixir-grpc/grpc
Pull request #381 (2024-07-10 22:35:59)

[ 377 ] Add `crash_report` logger metadata for exceptions captured by `Cowboy.Handler`
# Introduces #377 

This change aims to follow the elixir standard to make usage of `crash_report` metadata as [listed here](https://hexdocs.pm/logger/main/Logger.html#module-metadata) so that users can access these with additional information about the request and connection. 

One example of usage for this is the platforms for bug reports such as Sentry or Bugsnag. A user can implement its own logger backend and process the log lines that contains crash_report metadata. Notice that we're normalizing the exceptions based on the elixir docs

One extra thing changed here: Use `:proc_lib.start_link` which is a erlang module that also adds [crash_report to processes](https://github.com/erlang/otp/blob/OTP-27.0.1/lib/stdlib/src/proc_lib.erl#L944)


Patch:
diff --git a/lib/grpc/logger.ex b/lib/grpc/logger.ex
new file mode 100644
index 0000000..eff6f27
--- /dev/null
+++ b/lib/grpc/logger.ex
@@ -0,0 +1,9 @@
+defmodule GRPC.Logger do
+  @doc """
+  Normalizes the exception and stacktrace inputs by its kind to match the format specified for `crash_report` metadata
+  in [Logger](https://hexdocs.pm/logger/main/Logger.html#module-metadata)
+  """
+  def crash_reason(:throw, reason, stacktrace), do: {{:nocatch, reason}, stacktrace}
+  def crash_reason(:error, reason, stack), do: {Exception.normalize(:error, reason, stack), stack}
+  def crash_reason(:exit, reason, stacktrace), do: {reason, stacktrace}
+end
diff --git a/lib/grpc/server/adapters/cowboy/handler.ex b/lib/grpc/server/adapters/cowboy/handler.ex
index 1fad43a..eb1e3d8 100644
--- a/lib/grpc/server/adapters/cowboy/handler.ex
+++ b/lib/grpc/server/adapters/cowboy/handler.ex
@@ -3,6 +3,7 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do
   A cowboy handler accepting all requests and calls corresponding functions defined by users.
   """
 
+  alias GRPC.Server.Adapters.ReportException
   alias GRPC.Transport.HTTP2
   alias GRPC.RPCError
   require Logger
@@ -72,7 +73,7 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do
         http_transcode: transcode?(req)
       }
 
-      server_rpc_pid = spawn_link(__MODULE__, :call_rpc, [server, route, stream])
+      server_rpc_pid = :proc_lib.spawn_link(__MODULE__, :call_rpc, [server, route, stream])
       Process.flag(:trap_exit, true)
 
       req = :cowboy_req.set_resp_headers(HTTP2.server_headers(stream), req)
@@ -455,27 +456,37 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do
   end
 
   # expected error raised from user to return error immediately
-  def info({:EXIT, pid, {%RPCError{} = error, _stacktrace}}, req, state = %{pid: pid}) do
+  def info({:EXIT, pid, {%RPCError{} = error, stacktrace}}, req, state = %{pid: pid}) do
     req = send_error(req, error, state, :rpc_error)
+
+    [req: req]
+    |> ReportException.new(error, stacktrace)
+    |> log_error(stacktrace)
+
     {:stop, req, state}
   end
 
   # unknown error raised from rpc
-  def info({:EXIT, pid, {:handle_error, _kind}} = err, req, state = %{pid: pid}) do
-    Logger.warning("3. #{inspect(state)} #{inspect(err)}")
+  def info({:EXIT, pid, {:handle_error, error}}, req, state = %{pid: pid}) do
+    %{kind: kind, reason: reason, stack: stack} = error
+    rpc_error = %RPCError{status: GRPC.Status.unknown(), message: "Internal Server Error"}
+    req = send_error(req, rpc_error, state, :error)
 
-    error = %RPCError{status: GRPC.Status.unknown(), message: "Internal Server Error"}
-    req = send_error(req, error, state, :error)
+    [req: req]
+    |> ReportException.new(reason, stack, kind)
+    |> log_error(stack)
 
     {:stop, req, state}
   end
 
   def info({:EXIT, pid, {reason, stacktrace}}, req, state = %{pid: pid}) do
-    Logger.error(Exception.format(:error, reason, stacktrace))
-
     error = %RPCError{status: GRPC.Status.unknown(), message: "Internal Server Error"}
     req = send_error(req, error, state, reason)
 
+    [req: req]
+    |> ReportException.new(reason, stacktrace)
+    |> log_error(stacktrace)
+
     {:stop, req, state}
   end
 
@@ -500,17 +511,17 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do
         end
       catch
         kind, e ->
-          Logger.error(Exception.format(kind, e, __STACKTRACE__))
+          reason = Exception.normalize(kind, e, __STACKTRACE__)
 
-          exit({:handle_error, kind})
+          {:error, %{kind: kind, reason: reason, stack: __STACKTRACE__}}
       end
 
     case result do
       {:error, %GRPC.RPCError{} = e} ->
-        exit({e, ""})
+        exit({e, _stacktrace = []})
 
-      {:error, %{kind: kind}} ->
-        exit({:handle_error, kind})
+      {:error, %{kind: _kind, reason: _reason, stack: _stack} = e} ->
+        exit({:handle_error, e})
 
       other ->
         other
@@ -648,4 +659,12 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do
 
     {:wait, ref}
   end
+
+  defp log_error(%ReportException{kind: kind} = exception, stacktrace) do
+    crash_reason = GRPC.Logger.crash_reason(kind, exception, stacktrace)
+
+    kind
+    |> Exception.format(exception, stacktrace)
+    |> Logger.error(crash_reason: crash_reason)
+  end
 end
diff --git a/lib/grpc/server/adapters/report_exception.ex b/lib/grpc/server/adapters/report_exception.ex
new file mode 100644
index 0000000..8403514
--- /dev/null
+++ b/lib/grpc/server/adapters/report_exception.ex
@@ -0,0 +1,17 @@
+defmodule GRPC.Server.Adapters.ReportException do
+  @moduledoc """
+  Exception raised by server adapter, meant to be used as part of metadata `crash_report`
+  """
+
+  defexception [:kind, :reason, :stack, :adapter_extra]
+
+  def new(adapter_extra, %{__exception__: _} = exception, stack \\ [], kind \\ :error) do
+    exception(kind: kind, reason: exception, stack: stack, adapter_extra: adapter_extra)
+  end
+
+  def message(%__MODULE__{adapter_extra: [{:req, req}], kind: kind, reason: reason, stack: stack}) do
+    # Cowboy adapter message builder
+    path = :cowboy_req.path(req)
+    "Exception raised while handling #{path}:\n" <> Exception.format_banner(kind, reason, stack)
+  end
+end


Test patch:
diff --git a/test/grpc/integration/server_test.exs b/test/grpc/integration/server_test.exs
index e4e344d..e02bae5 100644
--- a/test/grpc/integration/server_test.exs
+++ b/test/grpc/integration/server_test.exs
@@ -224,27 +224,37 @@ defmodule GRPC.Integration.ServerTest do
   end
 
   test "returns appropriate error for unary requests" do
-    run_server([HelloErrorServer], fn port ->
-      {:ok, channel} = GRPC.Stub.connect("localhost:#{port}")
-      req = %Helloworld.HelloRequest{name: "Elixir"}
-      {:error, reply} = channel |> Helloworld.Greeter.Stub.say_hello(req)
+    logs =
+      ExUnit.CaptureLog.capture_log(fn ->
+        run_server([HelloErrorServer], fn port ->
+          {:ok, channel} = GRPC.Stub.connect("localhost:#{port}")
+          req = %Helloworld.HelloRequest{name: "Elixir"}
+          {:error, reply} = channel |> Helloworld.Greeter.Stub.say_hello(req)
 
-      assert %GRPC.RPCError{
-               status: GRPC.Status.unauthenticated(),
-               message: "Please authenticate"
-             } == reply
-    end)
+          assert %GRPC.RPCError{
+                   status: GRPC.Status.unauthenticated(),
+                   message: "Please authenticate"
+                 } == reply
+        end)
+      end)
+
+    assert logs =~ "Exception raised while handling /helloworld.Greeter/SayHello"
   end
 
   test "return errors for unknown errors" do
-    run_server([HelloErrorServer], fn port ->
-      {:ok, channel} = GRPC.Stub.connect("localhost:#{port}")
-      req = %Helloworld.HelloRequest{name: "unknown error"}
+    logs =
+      ExUnit.CaptureLog.capture_log(fn ->
+        run_server([HelloErrorServer], fn port ->
+          {:ok, channel} = GRPC.Stub.connect("localhost:#{port}")
+          req = %Helloworld.HelloRequest{name: "unknown error"}
 
-      assert {:error,
-              %GRPC.RPCError{message: "Internal Server Error", status: GRPC.Status.unknown()}} ==
-               channel |> Helloworld.Greeter.Stub.say_hello(req)
-    end)
+          assert {:error,
+                  %GRPC.RPCError{message: "Internal Server Error", status: GRPC.Status.unknown()}} ==
+                   channel |> Helloworld.Greeter.Stub.say_hello(req)
+        end)
+      end)
+
+    assert logs =~ "Exception raised while handling /helloworld.Greeter/SayHello"
   end
 
   test "returns appropriate error for stream requests" do