├── test ├── test_helper.exs └── raven_test.exs ├── .gitignore ├── .travis.yml ├── mix.lock ├── mix.exs ├── LICENSE ├── README.md └── lib └── raven.ex /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | erl_crash.dump 4 | *.ez -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: 3 | - 1.2.2 4 | - 1.1.1 5 | - 1.0.5 6 | - 1.0.0 7 | notifications: 8 | email: 9 | - vishnevskiy@gmail.com 10 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"certifi": {:hex, :certifi, "0.4.0"}, 2 | "hackney": {:hex, :hackney, "1.6.0"}, 3 | "idna": {:hex, :idna, "1.2.0"}, 4 | "metrics": {:hex, :metrics, "1.0.1"}, 5 | "mimerl": {:hex, :mimerl, "1.0.2"}, 6 | "poison": {:hex, :poison, "2.1.0"}, 7 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.0"}, 8 | "ssl_verify_hostname": {:hex, :ssl_verify_hostname, "1.0.5"}, 9 | "uuid": {:hex, :uuid, "1.0.1"}} 10 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Raven.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :raven, 7 | version: "0.0.5", 8 | elixir: "~> 1.0", 9 | description: "Raven is an Elixir client for Sentry", 10 | package: package, 11 | deps: deps 12 | ] 13 | end 14 | 15 | def application do 16 | applications = [:hackney, :uuid, :poison] 17 | if Mix.env == :test, do: applications = [:logger|applications] 18 | [ 19 | applications: applications 20 | ] 21 | end 22 | 23 | defp deps do 24 | [ 25 | {:hackney, "~> 1.6"}, 26 | {:uuid, "~> 1.0"}, 27 | {:poison, "~> 2.0"} 28 | ] 29 | end 30 | 31 | defp package do 32 | [ 33 | files: ["lib", "LICENSE", "mix.exs", "README.md"], 34 | contributors: ["Stanislav Vishnevskiy"], 35 | licenses: ["MIT"], 36 | links: %{ 37 | "github" => "https://github.com/vishnevskiy/raven-elixir" 38 | } 39 | ] 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Stanislav Vishnevskiy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | raven-elixir 2 | ============ 3 | 4 | [![Build Status](https://img.shields.io/travis/vishnevskiy/raven-elixir.svg?style=flat)](https://travis-ci.org/vishnevskiy/raven-elixir) 5 | [![hex.pm version](https://img.shields.io/hexpm/v/raven.svg?style=flat)](https://hex.pm/packages/raven) 6 | 7 | 8 | # Getting Started 9 | 10 | To use Raven with your projects, edit your mix.exs file and add it as a dependency: 11 | 12 | ```elixir 13 | defp deps do 14 | [{:raven, "~> 0.0.5"}] 15 | end 16 | ``` 17 | 18 | # Overview 19 | 20 | The goal of this project is to provide a full-feature Sentry client based on the guidelines in [Writing a Client](http://sentry.readthedocs.org/en/latest/developer/client/) on the Sentry documentation. 21 | 22 | However currently it only supports a `Logger` backend that will parse stacktraces and log them to Sentry. 23 | 24 | # Example 25 | 26 | ![Example](http://i.imgur.com/GM8kQYE.png) 27 | 28 | # Usage 29 | 30 | Setup the application environment in your config. 31 | 32 | ```elixir 33 | config :raven, 34 | dsn: "https://public:secret@app.getsentry.com/1", 35 | tags: %{ 36 | env: "production" 37 | } 38 | ``` 39 | 40 | Install the Logger backend. 41 | 42 | ```elixir 43 | Logger.add_backend(Raven) 44 | ``` 45 | -------------------------------------------------------------------------------- /test/raven_test.exs: -------------------------------------------------------------------------------- 1 | defmodule RavenTest do 2 | use ExUnit.Case 3 | 4 | defmodule Forwarder do 5 | use GenEvent 6 | 7 | def handle_call({:configure, options}, _state) do 8 | {:ok, :ok, options[:pid]} 9 | end 10 | 11 | def handle_event({:error, gl, {Logger, msg, _ts, _md}}, test_pid) when node(gl) == node() do 12 | send(test_pid, msg) 13 | {:ok, test_pid} 14 | end 15 | 16 | def handle_event(_data, test_pid) do 17 | {:ok, test_pid} 18 | end 19 | end 20 | 21 | defmodule MyGenServer do 22 | use GenServer 23 | 24 | def handle_call(:error, _, _) do 25 | raise "oops" 26 | end 27 | end 28 | 29 | defmodule MyGenEvent do 30 | use GenEvent 31 | 32 | def handle_call(:error, _) do 33 | raise "oops" 34 | end 35 | end 36 | 37 | setup do 38 | Logger.remove_backend(:console) 39 | Logger.add_backend(Forwarder) 40 | Logger.configure_backend(Forwarder, pid: self) 41 | {:ok, []} 42 | end 43 | 44 | test "parses GenServer crashes" do 45 | {:ok, pid} = GenServer.start(MyGenServer, :ok) 46 | catch_exit(GenServer.call(pid, :error)) 47 | 48 | frames = case :erlang.system_info(:otp_release) do 49 | '17' -> [ 50 | %{filename: "test/raven_test.exs", function: "RavenTest.MyGenServer.handle_call/3", in_app: true}, 51 | %{filename: "gen_server.erl", function: ":gen_server.handle_msg/5", in_app: false, lineno: 580}, 52 | %{filename: "proc_lib.erl", function: ":proc_lib.init_p_do_apply/3", in_app: false, lineno: 239} 53 | ] 54 | _ -> [ 55 | %{filename: "test/raven_test.exs", function: "RavenTest.MyGenServer.handle_call/3", in_app: true}, 56 | %{filename: "gen_server.erl", function: ":gen_server.try_handle_call/4", in_app: false}, 57 | %{filename: "gen_server.erl", function: ":gen_server.handle_msg/5", in_app: false}, 58 | %{filename: "proc_lib.erl", function: ":proc_lib.init_p_do_apply/3", in_app: false} 59 | ] 60 | end 61 | 62 | assert %Raven.Event{ 63 | culprit: "RavenTest.MyGenServer.handle_call/3", 64 | exception: [ 65 | %{type: "RuntimeError", value: "oops"} 66 | ], 67 | extra: %{ 68 | last_message: ":error", 69 | state: ":ok" 70 | }, 71 | level: "error", 72 | message: "(RuntimeError) oops", 73 | platform: "elixir", 74 | stacktrace: %{ 75 | frames: frames 76 | } 77 | } = receive_transform 78 | end 79 | 80 | test "parses GenEvent crashes" do 81 | {:ok, pid} = GenEvent.start() 82 | :ok = GenEvent.add_handler(pid, MyGenEvent, :ok) 83 | GenEvent.call(pid, MyGenEvent, :error) 84 | 85 | assert %Raven.Event{ 86 | culprit: "RavenTest.MyGenEvent.handle_call/2", 87 | exception: [ 88 | %{type: "RuntimeError", value: "oops"} 89 | ], 90 | extra: %{ 91 | last_message: ":error", 92 | state: ":ok" 93 | }, 94 | level: "error", 95 | message: "(RuntimeError) oops", 96 | platform: "elixir", 97 | stacktrace: %{ 98 | frames: [ 99 | %{filename: "test/raven_test.exs", function: "RavenTest.MyGenEvent.handle_call/2", in_app: true}, 100 | %{filename: "lib/gen_event.ex", function: "GenEvent.do_handler/3", in_app: false} 101 | | _ 102 | ] 103 | } 104 | } = receive_transform 105 | end 106 | 107 | test "parses Task crashes" do 108 | {:ok, pid} = Task.start_link(__MODULE__, :task, [self()]) 109 | ref = Process.monitor(pid) 110 | send(pid, :go) 111 | receive do: ({:DOWN, ^ref, _, _, _} -> :ok) 112 | 113 | assert %Raven.Event{ 114 | culprit: "anonymous fn/0 in RavenTest.task/1", 115 | exception: [ 116 | %{type: "RuntimeError", value: "oops"} 117 | ], 118 | level: "error", 119 | message: "(RuntimeError) oops", 120 | platform: "elixir", 121 | stacktrace: %{ 122 | frames: [ 123 | %{filename: "test/raven_test.exs", function: "anonymous fn/0 in RavenTest.task/1", in_app: true}, 124 | %{filename: "lib/task/supervised.ex", function: "Task.Supervised.do_apply/2", in_app: false}, 125 | %{filename: "proc_lib.erl", function: ":proc_lib.init_p_do_apply/3", in_app: false} 126 | ] 127 | } 128 | } = receive_transform 129 | end 130 | 131 | test "parses function crashes" do 132 | spawn fn -> "a" + 1 end 133 | 134 | case :erlang.system_info(:otp_release) do 135 | '17' -> 136 | assert %Raven.Event{ 137 | culprit: nil, 138 | level: "error", 139 | message: "Error in process " <> _, 140 | platform: "elixir", 141 | stacktrace: %{ 142 | frames: [] 143 | } 144 | } = receive_transform 145 | _ -> 146 | assert %Raven.Event{ 147 | culprit: "anonymous fn/0 in RavenTest.test parses function crashes/1", 148 | level: "error", 149 | message: "(ArithmeticError) bad argument in arithmetic expression", 150 | platform: "elixir", 151 | stacktrace: %{ 152 | frames: [ 153 | %{filename: "test/raven_test.exs", function: "anonymous fn/0 in RavenTest.test parses function crashes/1", in_app: true} 154 | ] 155 | } 156 | } = receive_transform 157 | end 158 | end 159 | 160 | test "does not crash on unknown error" do 161 | assert %Raven.Event{} = Raven.transform("unknown error of some kind") 162 | end 163 | 164 | @sentry_dsn "https://public:secret@app.getsentry.com/1" 165 | 166 | test "parning dsn" do 167 | assert {"https://app.getsentry.com/api/1/store/", "public", "secret"} = Raven.parse_dsn!(@sentry_dsn) 168 | end 169 | 170 | test "authorization" do 171 | {_endpoint, public_key, private_key} = Raven.parse_dsn!(@sentry_dsn) 172 | assert "Sentry sentry_version=5, sentry_client=raven-elixir/0.0.5, sentry_timestamp=1, sentry_key=public, sentry_secret=secret" == Raven.authorization_header(public_key, private_key, 1) 173 | end 174 | 175 | def task(parent, fun \\ (fn() -> raise "oops" end)) do 176 | Process.unlink(parent) 177 | receive do: (:go -> fun.()) 178 | end 179 | 180 | defp receive_transform do 181 | receive do 182 | exception -> Raven.transform(exception) 183 | end 184 | end 185 | end 186 | -------------------------------------------------------------------------------- /lib/raven.ex: -------------------------------------------------------------------------------- 1 | defmodule Raven do 2 | use GenEvent 3 | 4 | @moduledoc """ 5 | Setup the application environment in your config. 6 | 7 | config :raven, 8 | dsn: "https://public:secret@app.getsentry.com/1" 9 | tags: %{ 10 | env: "production" 11 | } 12 | 13 | Install the Logger backend. 14 | 15 | Logger.add_backend(Raven) 16 | """ 17 | 18 | @type parsed_dsn :: {String.t, String.t, Integer.t} 19 | 20 | ## Server 21 | 22 | def handle_call({:configure, _options}, state) do 23 | {:ok, :ok, state} 24 | end 25 | 26 | def handle_event({:error, gl, {Logger, msg, _ts, _md}}, state) when node(gl) == node() do 27 | capture_exception(msg) 28 | {:ok, state} 29 | end 30 | 31 | def handle_event(_data, state) do 32 | {:ok, state} 33 | end 34 | 35 | ## Sentry 36 | 37 | defmodule Event do 38 | defstruct event_id: nil, 39 | culprit: nil, 40 | timestamp: nil, 41 | message: nil, 42 | tags: %{}, 43 | level: "error", 44 | platform: "elixir", 45 | server_name: nil, 46 | exception: nil, 47 | stacktrace: %{ 48 | frames: [] 49 | }, 50 | extra: %{} 51 | end 52 | 53 | @doc """ 54 | Parses a Sentry DSN which is simply a URI. 55 | """ 56 | @spec parse_dsn!(String.t) :: parsed_dsn 57 | def parse_dsn!(dsn) do 58 | # {PROTOCOL}://{PUBLIC_KEY}:{SECRET_KEY}@{HOST}/{PATH}{PROJECT_ID} 59 | %URI{userinfo: userinfo, host: host, path: path, scheme: protocol} = URI.parse(dsn) 60 | [public_key, secret_key] = userinfo |> String.split(":", parts: 2) 61 | {project_id, _} = path |> String.slice(1..-1) |> Integer.parse 62 | endpoint = "#{protocol}://#{host}/api/#{project_id}/store/" 63 | {endpoint, public_key, secret_key} 64 | end 65 | 66 | @sentry_version 5 67 | quote do 68 | unquote(@sentry_client "raven-elixir/#{Mix.Project.config[:version]}") 69 | end 70 | 71 | @doc """ 72 | Generates a Sentry API authorization header. 73 | """ 74 | @spec authorization_header(String.t, String.t, Integer.t) :: String.t 75 | def authorization_header(public_key, secret_key, timestamp \\ nil) do 76 | # X-Sentry-Auth: Sentry sentry_version=5, 77 | # sentry_client=, 78 | # sentry_timestamp=, 79 | # sentry_key=, 80 | # sentry_secret= 81 | unless timestamp do 82 | timestamp = unix_timestamp 83 | end 84 | "Sentry sentry_version=#{@sentry_version}, sentry_client=#{@sentry_client}, sentry_timestamp=#{timestamp}, sentry_key=#{public_key}, sentry_secret=#{secret_key}" 85 | end 86 | 87 | @doc """ 88 | Parses and submits an exception to Sentry if DSN is setup in application env. 89 | """ 90 | @spec capture_exception(String.t) :: {:ok, String.t} | :error 91 | def capture_exception(exception) do 92 | case Application.get_env(:raven, :dsn) do 93 | dsn when is_bitstring(dsn) -> 94 | capture_exception(exception |> transform, dsn |> parse_dsn!) 95 | _ -> 96 | :error 97 | end 98 | end 99 | 100 | @spec capture_exception(%Event{}, parsed_dsn) :: {:ok, String.t} | :error 101 | def capture_exception(%Event{message: nil, exception: nil}, _) do 102 | {:ok, "Unable to parse as exception, ignoring..."} 103 | end 104 | 105 | def capture_exception(event, {endpoint, public_key, private_key}) do 106 | body = event |> Poison.encode! 107 | headers = [ 108 | {"User-Agent", @sentry_client}, 109 | {"X-Sentry-Auth", authorization_header(public_key, private_key)}, 110 | ] 111 | case :hackney.request(:post, endpoint, headers, body, []) do 112 | {:ok, 200, _headers, client} -> 113 | case :hackney.body(client) do 114 | {:ok, body} -> {:ok, body |> Poison.decode! |> Dict.get("id")} 115 | _ -> :error 116 | end 117 | _ -> :error 118 | end 119 | end 120 | 121 | ## Transformers 122 | 123 | @doc """ 124 | Transforms a exception string to a Sentry event. 125 | """ 126 | @spec transform(String.t) :: %Event{} 127 | def transform(stacktrace) do 128 | transform(stacktrace |> :erlang.iolist_to_binary |> String.split("\n"), %Event{}) 129 | end 130 | 131 | @spec transform([String.t], %Event{}) :: %Event{} 132 | def transform(["Error in process " <> _ = message|t], state) do 133 | transform(t, %{state | message: message}) 134 | end 135 | 136 | @spec transform([String.t], %Event{}) :: %Event{} 137 | def transform(["Last message: " <> last_message|t], state) do 138 | transform(t, put_in(state.extra, Map.put_new(state.extra, :last_message, last_message))) 139 | end 140 | 141 | @spec transform([String.t], %Event{}) :: %Event{} 142 | def transform(["State: " <> last_state|t], state) do 143 | transform(t, put_in(state.extra, Map.put_new(state.extra, :state, last_state))) 144 | end 145 | 146 | @spec transform([String.t], %Event{}) :: %Event{} 147 | def transform(["Function: " <> function|t], state) do 148 | transform(t, put_in(state.extra, Map.put_new(state.extra, :function, function))) 149 | end 150 | 151 | @spec transform([String.t], %Event{}) :: %Event{} 152 | def transform([" Args: " <> args|t], state) do 153 | transform(t, put_in(state.extra, Map.put_new(state.extra, :args, args))) 154 | end 155 | 156 | @spec transform([String.t], %Event{}) :: %Event{} 157 | def transform([" ** " <> message|t], state) do 158 | transform_first_stacktrace_line([message|t], state) 159 | end 160 | 161 | @spec transform([String.t], %Event{}) :: %Event{} 162 | def transform(["** " <> message|t], state) do 163 | transform_first_stacktrace_line([message|t], state) 164 | end 165 | 166 | @spec transform([String.t], %Event{}) :: %Event{} 167 | def transform([" " <> frame|t], state) do 168 | transform_stacktrace_line([frame|t], state) 169 | end 170 | 171 | @spec transform([String.t], %Event{}) :: %Event{} 172 | def transform([" " <> frame|t], state) do 173 | transform_stacktrace_line([frame|t], state) 174 | end 175 | 176 | @spec transform([String.t], %Event{}) :: %Event{} 177 | def transform([_|t], state) do 178 | transform(t, state) 179 | end 180 | 181 | @spec transform([String.t], %Event{}) :: %Event{} 182 | def transform([], state) do 183 | %{state | 184 | event_id: UUID.uuid4(:hex), 185 | timestamp: iso8601_timestamp, 186 | tags: Application.get_env(:raven, :tags, %{}), 187 | server_name: :net_adm.localhost |> to_string} 188 | end 189 | 190 | @spec transform(any, %Event{}) :: %Event{} 191 | def transform(_, state) do 192 | # TODO: maybe do something with this? 193 | state 194 | end 195 | 196 | ## Private 197 | 198 | defp transform_first_stacktrace_line([message|t], state) do 199 | [_, type, value] = Regex.run(~r/^\((.+?)\) (.+)$/, message) 200 | transform(t, %{state | message: message, exception: [%{type: type, value: value}]}) 201 | end 202 | 203 | defp transform_stacktrace_line([frame|t], state) do 204 | [app, filename, lineno, function] = 205 | case Regex.run(~r/^(\((.+?)\) )?(.+?):(\d+): (.+)$/, frame) do 206 | [_, _, filename, lineno, function] -> [:unknown, filename, lineno, function] 207 | [_, _, app, filename, lineno, function] -> [app, filename, lineno, function] 208 | end 209 | 210 | unless state.culprit do 211 | state = %{state | culprit: function} 212 | end 213 | 214 | state = put_in(state.stacktrace.frames, state.stacktrace.frames ++ [%{ 215 | filename: filename, 216 | function: function, 217 | module: nil, 218 | lineno: String.to_integer(lineno), 219 | colno: nil, 220 | abs_path: nil, 221 | context_line: nil, 222 | pre_context: nil, 223 | post_context: nil, 224 | in_app: not app in ["stdlib", "elixir"], 225 | vars: %{}, 226 | }]) 227 | 228 | transform(t, state) 229 | end 230 | 231 | @spec unix_timestamp :: Number.t 232 | defp unix_timestamp do 233 | {mega, sec, _micro} = :os.timestamp() 234 | mega * (1000000 + sec) 235 | end 236 | 237 | @spec unix_timestamp :: String.t 238 | defp iso8601_timestamp do 239 | [year, month, day, hour, minute, second] = 240 | :calendar.universal_time 241 | |> Tuple.to_list 242 | |> Enum.map(&Tuple.to_list(&1)) 243 | |> List.flatten 244 | |> Enum.map(&to_string(&1)) 245 | |> Enum.map(&String.rjust(&1, 2, ?0)) 246 | "#{year}-#{month}-#{day}T#{hour}:#{minute}:#{second}" 247 | end 248 | end 249 | --------------------------------------------------------------------------------