-
Notifications
You must be signed in to change notification settings - Fork 40
Expand file tree
/
Copy patherror_tracker.ex
More file actions
406 lines (309 loc) · 12.8 KB
/
error_tracker.ex
File metadata and controls
406 lines (309 loc) · 12.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
defmodule ErrorTracker do
@moduledoc """
En Elixir-based built-in error tracking solution.
The main objectives behind this project are:
* Provide a basic free error tracking solution: because tracking errors in
your application should be a requirement for almost any project, and helps to
provide quality and maintenance to your project.
* Be easy to use: by providing plug-and-play integrations, documentation and a
simple UI to manage your errors.
* Be as minimalistic as possible: you just need a database to store errors and
a Phoenix application if you want to inspect them via web. That's all.
## Requirements
ErrorTracker requires Elixir 1.15+, Ecto 3.11+, Phoenix LiveView 0.19+, and
PostgreSQL, MySQL/MariaDB or SQLite3 as database.
## Integrations
We currently include integrations for what we consider the basic stack of
an application: Phoenix, Plug, and Oban.
However, we may continue working in adding support for more systems and
libraries in the future if there is enough interest from the community.
If you want to manually report an error, you can use the `ErrorTracker.report/3` function.
## Context
Aside from the information about each exception (kind, message, stack trace...)
we also store contexts.
Contexts are arbitrary maps that allow you to store extra information about an
exception to be able to reproduce it later.
Each integration includes a default context with useful information they
can gather, but aside from that, you can also add your own information. You can
do this in a per-process basis or in a per-call basis (or both).
There are some requirements on the type of data that can be included in the
context, so we recommend taking a look at `set_context/1` documentation.
**Per process**
This allows you to set a general context for the current process such as a Phoenix
request or an Oban job. For example, you could include the following code in your
authentication Plug to automatically include the user ID in any error that is
tracked during the Phoenix request handling.
```elixir
ErrorTracker.set_context(%{user_id: conn.assigns.current_user.id})
```
**Per call**
As we had seen before, you can use `ErrorTracker.report/3` to manually report an
error. The third parameter of this function is optional and allows you to include
extra context that will be tracked along with the error.
## Breadcrumbs
Aside from contextual information, it is sometimes useful to know in which points
of your code the code was executed in a given request / process.
Using breadcrumbs allows you to add that information to any error generated and
stored on a given process / request. And if you are using `Ash` or `Splode` their
exceptions' breadcrumbs will be automatically populated.
If you want to add a breadcrumb in a point of your code you can do so:
```elixir
ErrorTracker.add_breadcrumb("Executed my super secret code")
```
Breadcrumbs can be viewed in the dashboard on the details page of an occurrence.
"""
import Ecto.Query
alias ErrorTracker.Error
alias ErrorTracker.Occurrence
alias ErrorTracker.Repo
alias ErrorTracker.Telemetry
@typedoc """
A map containing the relevant context for a particular error.
"""
@type context :: %{(String.t() | atom()) => any()}
@typedoc """
An `Exception` or a `{kind, payload}` tuple compatible with `Exception.normalize/3`.
"""
@type exception :: Exception.t() | {:error, any()} | {Exception.non_error_kind(), any()}
@doc """
Report an exception to be stored.
Returns the occurrence stored or `:noop` if the ErrorTracker is disabled by
configuration the exception has not been stored.
Aside from the exception, it is expected to receive the stack trace and,
optionally, a context map which will be merged with the current process
context.
Keep in mind that errors that occur in Phoenix controllers, Phoenix LiveViews
and Oban jobs are automatically reported. You will need this function only if you
want to report custom errors.
```elixir
try do
# your code
catch
e ->
ErrorTracker.report(e, __STACKTRACE__)
end
```
## Exceptions
Exceptions can be passed in three different forms:
* An exception struct: the module of the exception is stored along with
the exception message.
* A `{kind, exception}` tuple in which case the information is converted to
an Elixir exception (if possible) and stored.
"""
@spec report(exception(), Exception.stacktrace(), context()) :: Occurrence.t() | :noop
def report(exception, stacktrace, given_context \\ %{}) do
{kind, reason} = normalize_exception(exception, stacktrace)
{:ok, stacktrace} = ErrorTracker.Stacktrace.new(stacktrace)
{:ok, error} = Error.new(kind, reason, stacktrace)
context = Map.merge(get_context(), given_context)
breadcrumbs = get_breadcrumbs() ++ exception_breadcrumbs(exception)
if enabled?() && !ignored?(error, context) do
sanitized_context = sanitize_context(context)
upsert_error!(error, stacktrace, sanitized_context, breadcrumbs, reason)
else
:noop
end
end
@doc """
Marks an error as resolved.
If an error is marked as resolved and it happens again, it will automatically
appear as unresolved again.
"""
@spec resolve(Error.t()) :: {:ok, Error.t()} | {:error, Ecto.Changeset.t()}
def resolve(%Error{status: :unresolved} = error) do
changeset = Ecto.Changeset.change(error, status: :resolved)
with {:ok, updated_error} <- Repo.update(changeset) do
Telemetry.resolved_error(updated_error)
{:ok, updated_error}
end
end
@doc """
Marks an error as unresolved.
"""
@spec unresolve(Error.t()) :: {:ok, Error.t()} | {:error, Ecto.Changeset.t()}
def unresolve(%Error{status: :resolved} = error) do
changeset = Ecto.Changeset.change(error, status: :unresolved)
with {:ok, updated_error} <- Repo.update(changeset) do
Telemetry.unresolved_error(updated_error)
{:ok, updated_error}
end
end
@doc """
Mutes the error so new occurrences won't send telemetry events.
When an error is muted:
- New occurrences are still tracked and stored in the database
- No telemetry events are emitted for new occurrences
- You can still see the error and its occurrences in the web UI
This is useful for noisy errors that you want to keep tracking but don't want to
receive notifications about.
"""
@spec mute(Error.t()) :: {:ok, Error.t()} | {:error, Ecto.Changeset.t()}
def mute(%Error{} = error) do
changeset = Ecto.Changeset.change(error, muted: true)
Repo.update(changeset)
end
@doc """
Unmutes the error so new occurrences will send telemetry events again.
This reverses the effect of `mute/1`, allowing telemetry events to be emitted
for new occurrences of this error again.
"""
@spec unmute(Error.t()) :: {:ok, Error.t()} | {:error, Ecto.Changeset.t()}
def unmute(%Error{} = error) do
changeset = Ecto.Changeset.change(error, muted: false)
Repo.update(changeset)
end
@doc """
Sets the current process context.
The given context will be merged into the current process context. The given context
may override existing keys from the current process context.
## Context depth
You can store context on more than one level of depth, but take into account
that the merge operation is performed on the first level.
That means that any existing data on deep levels for he current context will
be replaced if the first level key is received on the new contents.
## Content serialization
The content stored on the context should be serializable using the JSON library used by the
application (usually `JSON` for Elixir 1.18+ and `Jason` for older versions), so it is
recommended to use primitive types (strings, numbers, booleans...).
If you still need to pass more complex data types to your context, please test
that they can be encoded to JSON or storing the errors will fail. You may need to define a
custom encoder for that data type if not included by default.
"""
@spec set_context(context()) :: context()
def set_context(params) when is_map(params) do
current_context = Process.get(:error_tracker_context, %{})
Process.put(:error_tracker_context, Map.merge(current_context, params))
params
end
@doc """
Obtain the context of the current process.
"""
@spec get_context() :: context()
def get_context do
Process.get(:error_tracker_context, %{})
end
@doc """
Adds a breadcrumb to the current process.
The new breadcrumb will be added as the most recent entry of the breadcrumbs
list.
## Breadcrumbs limit
Breadcrumbs are a powerful tool that allows to add an infinite number of
entries. However, it is not recommended to store errors with an excessive
amount of breadcrumbs.
As they are stored as an array of strings under the hood, storing many
entries per error can lead to some delays and using extra disk space on the
database.
"""
@spec add_breadcrumb(String.t()) :: list(String.t())
def add_breadcrumb(breadcrumb) when is_binary(breadcrumb) do
current_breadcrumbs = Process.get(:error_tracker_breadcrumbs, [])
new_breadcrumbs = current_breadcrumbs ++ [breadcrumb]
Process.put(:error_tracker_breadcrumbs, new_breadcrumbs)
new_breadcrumbs
end
@doc """
Obtain the breadcrumbs of the current process.
"""
@spec get_breadcrumbs() :: list(String.t())
def get_breadcrumbs do
Process.get(:error_tracker_breadcrumbs, [])
end
defp enabled? do
!!Application.get_env(:error_tracker, :enabled, true)
end
defp ignored?(error, context) do
ignorer = Application.get_env(:error_tracker, :ignorer)
ignorer && ignorer.ignore?(error, context)
end
defp sanitize_context(context) do
filter_mod = Application.get_env(:error_tracker, :filter)
if filter_mod,
do: filter_mod.sanitize(context),
else: context
end
defp normalize_exception(%struct{} = ex, _stacktrace) when is_exception(ex) do
{to_string(struct), Exception.message(ex)}
end
defp normalize_exception({kind, ex}, stacktrace) do
case Exception.normalize(kind, ex, stacktrace) do
%struct{} = ex -> {to_string(struct), Exception.message(ex)}
payload -> {to_string(kind), safe_to_string(payload)}
end
end
defp safe_to_string(term) do
to_string(term)
rescue
Protocol.UndefinedError ->
inspect(term)
end
defp exception_breadcrumbs(exception) do
case exception do
{_kind, exception} -> exception_breadcrumbs(exception)
%{bread_crumbs: breadcrumbs} -> breadcrumbs
_other -> []
end
end
defp upsert_error!(error, stacktrace, context, breadcrumbs, reason) do
status_and_muted_query =
from e in Error,
where: [fingerprint: ^error.fingerprint],
select: {e.status, e.muted}
{existing_status, muted} =
case Repo.one(status_and_muted_query) do
{existing_status, muted} -> {existing_status, muted}
nil -> {nil, false}
end
{:ok, {error, occurrence}} =
Repo.transaction(fn ->
error =
Repo.with_adapter(fn
:mysql ->
Repo.insert!(error,
on_conflict: [set: [status: :unresolved, last_occurrence_at: DateTime.utc_now()]]
)
_other ->
Repo.insert!(error,
on_conflict: [set: [status: :unresolved, last_occurrence_at: DateTime.utc_now()]],
conflict_target: :fingerprint
)
end)
occurrence =
error
|> Ecto.build_assoc(:occurrences)
|> Occurrence.changeset(%{
stacktrace: stacktrace,
context: context,
breadcrumbs: breadcrumbs,
reason: reason
})
|> Repo.insert!()
{error, occurrence}
end)
%Occurrence{} = occurrence
occurrence = %{occurrence | error: error}
# If the error existed and was marked as resolved before this exception,
# sent a Telemetry event
# If it is a new error, sent a Telemetry event
case existing_status do
:resolved -> Telemetry.previously_resolved_error(error, occurrence)
:unresolved -> :noop
nil -> Telemetry.new_error(error, occurrence)
end
Telemetry.new_occurrence(occurrence, muted)
occurrence
end
@default_json_encoder (cond do
Code.ensure_loaded?(JSON) ->
JSON
Code.ensure_loaded?(Jason) ->
Jason
true ->
raise """
No JSON encoder found. Please add Jason to your dependencies:
{:jason, "~> 1.1"}
Or upgrade to Elixir 1.18+.
"""
end)
@doc false
def __default_json_encoder__, do: @default_json_encoder
end