Skip to content

Commit daa8657

Browse files
committed
Add configurable logging for proxy lifecycle events
Introduce a :log_level config option (default :debug) that logs request start, upstream status, completion, errors, and handler rejections. Errors always log at :error level; log_level: false silences everything. All messages use iolists with lazy evaluation.
1 parent d2ac7b1 commit daa8657

3 files changed

Lines changed: 200 additions & 5 deletions

File tree

lib/philter.ex

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,8 @@ defmodule Philter do
9898
finch_name: atom(),
9999
receive_timeout: pos_integer(),
100100
max_payload_size: pos_integer(),
101-
persistable_content_types: [String.t()]
101+
persistable_content_types: [String.t()],
102+
log_level: Logger.level() | false
102103
]
103104

104105
@doc """
@@ -152,6 +153,9 @@ defmodule Philter do
152153
* `:persistable_content_types` - Content types eligible for body accumulation.
153154
Supports wildcards like `"text/*"`. Default: JSON, XML, and text types.
154155
156+
* `:log_level` - Logger level for lifecycle events (`:debug`, `:info`, etc.)
157+
or `false` to disable all logging. Default: `:debug`.
158+
155159
## Return Value
156160
157161
Returns the `conn` with response sent. Observations are stored in:
@@ -178,6 +182,19 @@ defmodule Philter do
178182
upstream_url = build_upstream_url(upstream, path, conn.query_string)
179183
req_content_type = get_content_type(conn.req_headers)
180184
outbound_headers = build_outbound_headers(Keyword.get(opts, :headers), conn, upstream)
185+
log_level = config.log_level
186+
187+
# Log #1: Request start
188+
log(log_level, fn ->
189+
[
190+
"Philter ",
191+
conn.method,
192+
" ",
193+
upstream_url,
194+
" host=",
195+
extract_host(upstream)
196+
]
197+
end)
181198

182199
# Notify handler of request start
183200
case notify_request_started(handler, %{
@@ -220,7 +237,9 @@ defmodule Philter do
220237
observer: observer,
221238
status: nil,
222239
started_at: started_at,
223-
error: nil
240+
error: nil,
241+
log_level: log_level,
242+
upstream_url: upstream_url
224243
}
225244

226245
result =
@@ -235,6 +254,20 @@ defmodule Philter do
235254
case result do
236255
{:ok, acc} ->
237256
observations = Observer.finalize(observer)
257+
duration_us = System.monotonic_time(:microsecond) - started_at
258+
259+
# Log #3: Success complete
260+
log(log_level, fn ->
261+
[
262+
"Philter complete ",
263+
Integer.to_string(acc.status),
264+
" ",
265+
Integer.to_string(observations.response.size),
266+
"B ",
267+
Integer.to_string(div(duration_us, 1000)),
268+
"ms"
269+
]
270+
end)
238271

239272
handler_state = handler_state(acc)
240273

@@ -248,7 +281,7 @@ defmodule Philter do
248281
upstream_url: upstream_url,
249282
method: acc.conn.method,
250283
status: acc.conn.status,
251-
duration_us: System.monotonic_time(:microsecond) - started_at
284+
duration_us: duration_us
252285
},
253286
handler_state
254287
)
@@ -298,13 +331,27 @@ defmodule Philter do
298331
end
299332

300333
{:reject, status, body, _handler_state} ->
334+
# Log #5: Handler rejection
335+
log(log_level, fn ->
336+
[
337+
"Philter rejected ",
338+
conn.method,
339+
" ",
340+
upstream_url,
341+
" status=",
342+
Integer.to_string(status)
343+
]
344+
end)
345+
301346
conn |> send_resp(status, body) |> halt()
302347
end
303348
end
304349

305350
# Stream message handlers (replaces ResponseStreamer)
306351

307352
defp handle_stream_message({:status, status}, acc) do
353+
# Log #2: Status from upstream
354+
log(acc.log_level, fn -> ["Philter ", Integer.to_string(status), " from upstream"] end)
308355
{:cont, %{acc | status: status}}
309356
end
310357

@@ -484,6 +531,20 @@ defmodule Philter do
484531
defp handler_state(_), do: nil
485532

486533
defp handle_error(acc, handler, info) do
534+
# Log #4: Error
535+
if acc.log_level do
536+
Logger.error(fn ->
537+
[
538+
"Philter error ",
539+
Integer.to_string(info.status),
540+
" ",
541+
inspect(info.error),
542+
" upstream=",
543+
info.upstream_url
544+
]
545+
end)
546+
end
547+
487548
observations = Observer.finalize(acc.observer)
488549
handler_state = handler_state(acc)
489550

@@ -519,4 +580,7 @@ defmodule Philter do
519580
defp notify_response_finished({module, _args}, result, state) do
520581
module.handle_response_finished(result, state)
521582
end
583+
584+
defp log(false, _fun), do: :ok
585+
defp log(level, fun), do: Logger.log(level, fun)
522586
end

lib/philter/config.ex

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ defmodule Philter.Config do
2828
- `:receive_timeout` - Timeout in ms for receiving response (default: 15_000)
2929
- `:max_payload_size` - Max size in bytes for full body accumulation (default: 1_048_576 / 1MB)
3030
- `:persistable_content_types` - Content types eligible for full body storage (default: see below)
31+
- `:log_level` - Logger level for lifecycle events, or `false` to disable (default: `:debug`)
3132
3233
## Default Persistable Content Types
3334
@@ -45,6 +46,7 @@ defmodule Philter.Config do
4546
@default_finch_name Philter.Finch
4647
@default_receive_timeout 15_000
4748
@default_max_payload_size 1_048_576
49+
@default_log_level :debug
4850
@default_persistable_content_types [
4951
"application/json",
5052
"application/xml",
@@ -57,7 +59,8 @@ defmodule Philter.Config do
5759
finch_name: atom(),
5860
receive_timeout: pos_integer(),
5961
max_payload_size: pos_integer(),
60-
persistable_content_types: [String.t()]
62+
persistable_content_types: [String.t()],
63+
log_level: Logger.level() | false
6164
}
6265

6366
@doc """
@@ -142,6 +145,30 @@ defmodule Philter.Config do
142145
end)
143146
end
144147

148+
@doc """
149+
Returns the log level for lifecycle events.
150+
151+
Set to `false` to disable all logging. Defaults to `:debug`.
152+
153+
## Examples
154+
155+
iex> Philter.Config.log_level()
156+
:debug
157+
158+
iex> Philter.Config.log_level(log_level: :info)
159+
:info
160+
161+
iex> Philter.Config.log_level(log_level: false)
162+
false
163+
164+
"""
165+
@spec log_level(keyword()) :: Logger.level() | false
166+
def log_level(opts \\ []) do
167+
Keyword.get_lazy(opts, :log_level, fn ->
168+
Application.get_env(:philter, :log_level, @default_log_level)
169+
end)
170+
end
171+
145172
@doc """
146173
Returns all configuration as a map, with per-request overrides applied.
147174
@@ -160,7 +187,8 @@ defmodule Philter.Config do
160187
finch_name: finch_name(opts),
161188
receive_timeout: receive_timeout(opts),
162189
max_payload_size: max_payload_size(opts),
163-
persistable_content_types: persistable_content_types(opts)
190+
persistable_content_types: persistable_content_types(opts),
191+
log_level: log_level(opts)
164192
}
165193
end
166194

test/philter_test.exs

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
defmodule PhilterTest do
22
use ExUnit.Case, async: true
3+
import ExUnit.CaptureLog
34
import Plug.Test
45
import Plug.Conn
56

@@ -543,4 +544,106 @@ defmodule PhilterTest do
543544
assert conn.halted
544545
end
545546
end
547+
548+
describe "logging" do
549+
test "default :debug level emits request start with method, URL, and host", %{
550+
bypass: bypass,
551+
upstream: upstream
552+
} do
553+
Bypass.expect(bypass, "GET", "/log-test", fn conn ->
554+
send_resp(conn, 200, "ok")
555+
end)
556+
557+
log =
558+
capture_log([level: :debug], fn ->
559+
conn(:get, "/log-test")
560+
|> Philter.proxy(upstream: upstream, finch_name: Philter.TestFinch)
561+
end)
562+
563+
assert log =~ "Philter GET"
564+
assert log =~ "/log-test"
565+
assert log =~ "host=localhost:#{bypass.port}"
566+
end
567+
568+
test "log_level: false produces no log output", %{bypass: bypass, upstream: upstream} do
569+
Bypass.expect(bypass, "GET", "/silent", fn conn ->
570+
send_resp(conn, 200, "ok")
571+
end)
572+
573+
log =
574+
capture_log([level: :debug], fn ->
575+
conn(:get, "/silent")
576+
|> Philter.proxy(
577+
upstream: upstream,
578+
finch_name: Philter.TestFinch,
579+
log_level: false
580+
)
581+
end)
582+
583+
assert log == ""
584+
end
585+
586+
test "error paths log at :error level", %{upstream: _upstream} do
587+
log =
588+
capture_log([level: :error], fn ->
589+
conn(:get, "/fail")
590+
|> Philter.proxy(
591+
upstream: "http://localhost:59999",
592+
finch_name: Philter.TestFinch
593+
)
594+
end)
595+
596+
assert log =~ "Philter error 502"
597+
assert log =~ "upstream=http://localhost:59999/fail"
598+
end
599+
600+
test "response complete log includes status, size, and duration", %{
601+
bypass: bypass,
602+
upstream: upstream
603+
} do
604+
Bypass.expect(bypass, "GET", "/complete", fn conn ->
605+
send_resp(conn, 200, "hello")
606+
end)
607+
608+
log =
609+
capture_log([level: :debug], fn ->
610+
conn(:get, "/complete")
611+
|> Philter.proxy(upstream: upstream, finch_name: Philter.TestFinch)
612+
end)
613+
614+
assert log =~ "Philter complete 200"
615+
assert log =~ "5B"
616+
assert log =~ ~r/\d+ms/
617+
end
618+
619+
test "rejection is logged with method, URL, and status", %{upstream: upstream} do
620+
log =
621+
capture_log([level: :debug], fn ->
622+
conn(:get, "/rejected")
623+
|> Philter.proxy(
624+
upstream: upstream,
625+
finch_name: Philter.TestFinch,
626+
handler: {RejectingHandler, %{}}
627+
)
628+
end)
629+
630+
assert log =~ "Philter rejected GET"
631+
assert log =~ "/rejected"
632+
assert log =~ "status=413"
633+
end
634+
635+
test "error log is suppressed when log_level: false" do
636+
log =
637+
capture_log([level: :error], fn ->
638+
conn(:get, "/fail")
639+
|> Philter.proxy(
640+
upstream: "http://localhost:59999",
641+
finch_name: Philter.TestFinch,
642+
log_level: false
643+
)
644+
end)
645+
646+
assert log == ""
647+
end
648+
end
546649
end

0 commit comments

Comments
 (0)