Philter (Philter v0.3.0)

Copy Markdown View Source

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.Finch

Or 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_880

Handler Callbacks

Implement Philter.Handler to hook into the proxy lifecycle:

  • handle_request_started/2 - Called before sending to upstream
  • handle_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

proxy_opts()

@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

proxy(conn, opts)

@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. See Philter.Handler for the callback interface.

  • :headers - Pre-assembled outbound request headers as [{name, value}] tuples. When provided, these replace conn.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_headers are filtered (hop-by-hop removed, keys lowercased) and the host header is always rewritten to match upstream. Cannot be combined with :extra_headers or :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_headers and :extra_headers are used, the processing order is: filter hop-by-hop headers → rewrite host → strip → merge extra.

  • :finch_name - Finch pool name. Default: configured value (see Philter.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.) or false to disable all logging. Default: :debug.

  • :collect_timing - When true, captures per-phase timing breakdown (queue, connect, send, recv, idle_time, reused_connection) from HTTP client telemetry events. Phase fields in timing are nil when disabled. Default: false.

Return Value

Returns the conn with response sent. Observations are stored in:

  • conn.private[:philter_request_observation] - Request body observation
  • conn.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.