Streaming HTTP proxy with request/response observation.
Philter forwards HTTP requests to upstream servers while capturing body observations (hash, size, timing, preview) without buffering. Supports conditional body accumulation for content types you want to persist.
Philter — an alchemical potion or charm; from Greek philtron (φίλτρον), "love potion." Here it evokes both filtering (the proxy inspects and forwards HTTP traffic) and the Elixir ecosystem's alchemical tradition.
Finch Setup (Required)
Philter requires a running Finch HTTP client instance. Add to your application's supervision tree:
# lib/my_app/application.ex
children = [
{Finch, name: MyApp.Finch}
]Then configure Philter to use it:
# config/config.exs
config :philter, finch_name: MyApp.FinchOr pass it per-request:
Philter.proxy(conn, upstream: "https://api.example.com", finch_name: MyApp.Finch)Quick Start
# In a Phoenix controller
def proxy(conn, _params) do
Philter.proxy(conn, upstream: "http://api.example.com")
end
# Or as a Plug in your router
forward "/api", Philter.ProxyPlug, upstream: "http://api.example.com"Configuration
Set defaults in your config and override per-request. See Philter.Config for details.
# config/config.exs
config :philter,
finch_name: MyApp.Finch,
receive_timeout: 30_000,
max_payload_size: 5_242_880Handler Callbacks
Implement Philter.Handler to hook into the proxy lifecycle:
handle_request_started/2- Called before sending to upstreamhandle_response_started/2- Called on first byte received (TTFB)handle_response_finished/2- Called when complete, with body observations
Example:
defmodule MyHandler do
use Philter.Handler
@impl true
def handle_response_finished(result, state) do
# result contains :request_observation and :response_observation
# each with :hash, :size, :body, :preview
{:ok, state}
end
end
Summary
Functions
Proxies an HTTP request to an upstream server.
Types
@type proxy_opts() :: [ upstream: String.t(), path: String.t() | (Plug.Conn.t() -> String.t()), handler: module() | {module(), term()}, headers: [{String.t(), String.t()}], extra_headers: [{String.t(), String.t()}], strip_headers: [String.t()], finch_name: atom(), receive_timeout: pos_integer(), max_payload_size: pos_integer(), persistable_content_types: [String.t()], log_level: Logger.level() | false, collect_timing: boolean() ]
Functions
@spec proxy(Plug.Conn.t(), proxy_opts()) :: Plug.Conn.t()
Proxies an HTTP request to an upstream server.
Streams the request to upstream and the response back to the client. Call this
after authentication or other pre-processing (unlike Philter.ProxyPlug).
Examples
# Basic proxy
Philter.proxy(conn, upstream: "http://api.example.com")
# With handler for logging/persistence
Philter.proxy(conn,
upstream: "http://api.example.com",
handler: {MyHandler, %{user_id: user.id}}
)
# Override timeout for slow endpoints
Philter.proxy(conn,
upstream: "http://api.example.com",
receive_timeout: 60_000
)Options
:upstream- Base URL of the upstream server. Required.:handler- Handler module or{module, state}tuple for lifecycle callbacks. SeePhilter.Handlerfor the callback interface.:headers- Pre-assembled outbound request headers as[{name, value}]tuples. When provided, these replaceconn.req_headers(no hop-by-hop filtering) and an explicit"host"entry (case-insensitive) is preserved as-is; if no"host"is included, the upstream host is appended as a default. When omitted,conn.req_headersare filtered (hop-by-hop removed, keys lowercased) and thehostheader is always rewritten to match upstream. Cannot be combined with:extra_headersor:strip_headers.:extra_headers- Additional[{name, value}]headers to merge into the outbound request. Applied after hop-by-hop filtering and host rewriting. If an extra header matches an existing header name (case-insensitive), the existing header is replaced. Cannot be combined with:headers.:strip_headers- List of header names (case-insensitive) to remove from the outbound request. Applied after hop-by-hop filtering and host rewriting but before:extra_headers. Cannot be combined with:headers.When both
:strip_headersand:extra_headersare used, the processing order is: filter hop-by-hop headers → rewrite host → strip → merge extra.:finch_name- Finch pool name. Default: configured value (seePhilter.Config).:receive_timeout- Response timeout in milliseconds. Default:15_000.:max_payload_size- Max body size in bytes for full accumulation. Bodies exceeding this are still hashed and previewed. Default:1_048_576(1MB).:path- Override the request path sent to upstream. Can be a string or a function(Plug.Conn.t() -> String.t()). Default:conn.request_path.:persistable_content_types- Content types eligible for body accumulation. Supports wildcards like"text/*". Default: JSON, XML, and text types.:log_level- Logger level for lifecycle events (:debug,:info, etc.) orfalseto disable all logging. Default::debug.:collect_timing- Whentrue, captures per-phase timing breakdown (queue, connect, send, recv, idle_time, reused_connection) from HTTP client telemetry events. Phase fields intimingarenilwhen disabled. Default:false.
Return Value
Returns the conn with response sent. Observations are stored in:
conn.private[:philter_request_observation]- Request body observationconn.private[:philter_response_observation]- Response body observation
Each observation contains :hash, :size, :body (if accumulated), and :preview.
Error Handling
On upstream errors, returns 502 Bad Gateway. On timeouts, returns 504 Gateway Timeout.
The handler's handle_response_finished/2 is still called with the :error field set.