/
logger.ex
275 lines (213 loc) · 7.03 KB
/
logger.ex
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
defmodule Tesla.Middleware.Logger.Formatter do
@moduledoc false
# Heavily based on Elixir's Logger.Formatter
# https://github.com/elixir-lang/elixir/blob/v1.6.4/lib/logger/lib/logger/formatter.ex
@default_format "$method $url -> $status ($time ms)"
@keys ~w(method url status time query)
@type format :: [atom | binary]
@spec compile(binary | nil) :: format
def compile(nil), do: compile(@default_format)
def compile(binary) do
~r/(?<h>)\$[a-z]+(?<t>)/
|> Regex.split(binary, on: [:h, :t], trim: true)
|> Enum.map(&compile_key/1)
end
defp compile_key("$" <> key) when key in @keys, do: String.to_atom(key)
defp compile_key("$" <> key), do: raise(ArgumentError, "$#{key} is an invalid format pattern.")
defp compile_key(part), do: part
@spec format(Tesla.Env.t(), Tesla.Env.result(), integer, format) :: IO.chardata()
def format(request, response, time, format) do
Enum.map(format, &output(&1, request, response, time))
end
defp output(:query, env, _, _), do: env.query |> Tesla.encode_query()
defp output(:method, env, _, _), do: env.method |> to_string() |> String.upcase()
defp output(:url, env, _, _), do: env.url
defp output(:status, _, {:ok, env}, _), do: to_string(env.status)
defp output(:status, _, {:error, reason}, _), do: "error: " <> inspect(reason)
defp output(:time, _, _, time), do: :io_lib.format("~.3f", [time / 1000])
defp output(binary, _, _, _), do: binary
end
defmodule Tesla.Middleware.Logger do
@moduledoc """
Log requests using Elixir's Logger.
With the default settings it logs request method, url, response status and time taken in milliseconds.
## Example usage
```
defmodule MyClient do
use Tesla
plug Tesla.Middleware.Logger
end
```
## Options
- `:log_level` - custom function for calculating log level (see below)
- `:filter_headers` - sanitizes sensitive headers before logging in debug mode (see below)
- `:debug` - show detailed request/response logging
## Custom log format
The default log format is `"$method $url -> $status ($time ms)"`
which shows in logs like:
```
2018-03-25 18:32:40.397 [info] GET https://bitebot.io -> 200 (88.074 ms)
```
Because log format is processed during compile time it needs to be set in config:
```
config :tesla, Tesla.Middleware.Logger, format: "$method $url ====> $status / time=$time"
```
## Custom log levels
By default, the following log levels will be used:
- `:error` - for errors, 5xx and 4xx responses
- `:warn` - for 3xx responses
- `:info` - for 2xx responses
You can customize this setting by providing your own `log_level/1` function:
```
defmodule MyClient do
use Tesla
plug Tesla.Middleware.Logger, log_level: &my_log_level/1
def my_log_level(env) do
case env.status do
404 -> :info
_ -> :default
end
end
end
```
## Logger Debug output
When the Elixir Logger log level is set to `:debug`
Tesla Logger will show full request & response.
If you want to disable detailed request/response logging
but keep the `:debug` log level (i.e. in development)
you can set `debug: false` in your config:
```
# config/dev.local.exs
config :tesla, Tesla.Middleware.Logger, debug: false
```
Note that the logging configuration is evaluated at compile time,
so Tesla must be recompiled for the configuration to take effect:
```
mix deps.clean --build tesla
mix deps.compile tesla
```
In order to be able to set `:debug` at runtime we can
pass it as a option to the middleware at runtime.
```elixir
def client do
middleware = [
# ...
{Tesla.Middleware.Logger, debug: false}
]
Tesla.client(middleware)
end
```
### Filter headers
To sanitize sensitive headers such as `authorization` in
debug logs, add them to the `:filter_headers` option.
`:filter_headers` expects a list of header names as strings.
```
# config/dev.local.exs
config :tesla, Tesla.Middleware.Logger,
filter_headers: ["authorization"]
```
"""
@behaviour Tesla.Middleware
alias Tesla.Middleware.Logger.Formatter
@config Application.get_env(:tesla, __MODULE__, [])
@format Formatter.compile(@config[:format])
@type log_level :: :info | :warn | :error
require Logger
@impl Tesla.Middleware
def call(env, next, opts) do
{time, response} = :timer.tc(Tesla, :run, [env, next])
config = Keyword.merge(@config, opts)
level = log_level(response, config)
Logger.log(level, fn -> Formatter.format(env, response, time, @format) end)
if Keyword.get(config, :debug, true) do
Logger.debug(fn -> debug(env, response, config) end)
end
response
end
defp log_level({:error, _}, _), do: :error
defp log_level({:ok, env}, config) do
case Keyword.get(config, :log_level) do
nil ->
default_log_level(env)
fun when is_function(fun) ->
case fun.(env) do
:default -> default_log_level(env)
level -> level
end
atom when is_atom(atom) ->
atom
end
end
@spec default_log_level(Tesla.Env.t()) :: log_level
def default_log_level(env) do
cond do
env.status >= 400 -> :error
env.status >= 300 -> :warn
true -> :info
end
end
@debug_no_query "(no query)"
@debug_no_headers "(no headers)"
@debug_no_body "(no body)"
@debug_stream "[Elixir.Stream]"
defp debug(request, {:ok, response}, config) do
[
"\n>>> REQUEST >>>\n",
debug_query(request.query),
?\n,
debug_headers(request.headers, config),
?\n,
debug_body(request.body),
?\n,
"\n<<< RESPONSE <<<\n",
debug_headers(response.headers, config),
?\n,
debug_body(response.body)
]
end
defp debug(request, {:error, error}, config) do
[
"\n>>> REQUEST >>>\n",
debug_query(request.query),
?\n,
debug_headers(request.headers, config),
?\n,
debug_body(request.body),
?\n,
"\n<<< RESPONSE ERROR <<<\n",
inspect(error)
]
end
defp debug_query([]), do: @debug_no_query
defp debug_query(query) do
query
|> Enum.flat_map(&Tesla.encode_pair/1)
|> Enum.map(fn {k, v} -> ["Query: ", to_string(k), ": ", to_string(v), ?\n] end)
end
defp debug_headers([], _config), do: @debug_no_headers
defp debug_headers(headers, config) do
filtered = Keyword.get(config, :filter_headers, [])
Enum.map(headers, fn {k, v} ->
v = if k in filtered, do: "[FILTERED]", else: v
[k, ": ", v, ?\n]
end)
end
defp debug_body(nil), do: @debug_no_body
defp debug_body([]), do: @debug_no_body
defp debug_body(%Stream{}), do: @debug_stream
defp debug_body(stream) when is_function(stream), do: @debug_stream
defp debug_body(%Tesla.Multipart{} = mp) do
[
"[Tesla.Multipart]\n",
"boundary: ",
mp.boundary,
?\n,
"content_type_params: ",
inspect(mp.content_type_params),
?\n
| Enum.map(mp.parts, &[inspect(&1), ?\n])
]
end
defp debug_body(data) when is_binary(data) or is_list(data), do: data
defp debug_body(term), do: inspect(term)
end