defmodule VavPipeline.NoTrackingLogger do @moduledoc """ Plug-side enforcement of the Form 3 architectural commitment. Replaces Plug.Logger. The default `Plug.Logger` logs every request with method, path, status, and timing. For /peah/* (the public tier), the path can carry the caller's question content (GET /peah/ask?question=...). Logging that content turns the no-tracking promise from `/peah/ask`'s own response body into a contradiction: the response says "No tracking" while the log accumulates the query stream. THE ARCHITECTURAL DECISION: This logger always records the categorical fact (request received, status returned, timing) but NEVER the per-caller details that would make the categorical fact attributable. Specifically: - For /peah/* paths: log "GET /peah/ask" with NO query string, NO request body summary, NO IP. The path-class is logged so operators can see traffic patterns; the per-caller content cannot be reconstructed from the log. - For other tiers: log path including any path parameters but not query strings. Tokens (Bearer ...) are NEVER logged (Plug.Logger doesn't log them by default; this module is explicit about it). - Timing is always logged. Aggregate timing patterns are not caller-identifying; per-request timing is, but timing is the kind of data an operator legitimately needs for capacity planning. The trade-off is named here so it is reviewable. Each request also calls VavPipeline.MachtzitHaShekel.increment/0 so the aggregate caller count is maintained — without storing any per-caller distinction. The count is the zikaron; the log is the request-class trace; nothing reconstructs the caller. TORAH ANCHORS: Vayikra 19:14 — לֹא תִתֵּן מִכְשֹׁל וְיָרֵאתָ מֵּאֱלֹהֶיךָ. The no-witness protocol: the logger holds its commitment under invisibility. Even if no operator reviews the logs, the logs themselves do not record what they shouldn't. Devarim 23:16 — לֹא־תַסְגִּיר עֶבֶד אֶל־אֲדֹנָיו. The architectural refusal-to-deliver: the log is what would be subpoenaed; if it doesn't contain caller-identifying detail, the system has nothing to deliver. CI INVARIANT (test_no_tracking_logger.exs): - `Plug.Logger` is NOT mounted directly in the router; this module is the only request-scoped logger. - No middleware in the router has `:cookies` or `:session` in its configuration. - No module imports `Plug.Session` or `Plug.Conn.Cookies`. """ require Logger @behaviour Plug @impl true def init(opts), do: opts @impl true def call(conn, _opts) do start = System.monotonic_time() # Increment the zikaron — uniform per-request, no caller distinction # (machtzit_ha_shekel.ex). Process.whereis-guarded so tests without # the supervisor still work. if Process.whereis(VavPipeline.MachtzitHaShekel) do VavPipeline.MachtzitHaShekel.increment() end Plug.Conn.register_before_send(conn, fn conn -> duration_us = System.convert_time_unit( System.monotonic_time() - start, :native, :microsecond ) Logger.info(format_line(conn, duration_us)) conn end) end @doc """ Build the log line for a request. Public so tests can verify the sanitization behavior directly as a pure function — the test config filters Logger.info, so behavioral capture_log tests would not see the output. The structural defense is what's load-bearing; this function is the structural surface. IP, request body, response body, headers — never appear in the log line. The function takes a conn and a duration; nothing else. """ def format_line(conn, duration_us) do method = conn.method path = sanitized_path(conn) status = conn.status || 0 "#{method} #{path} #{status} #{duration_us}us" end @doc """ Sanitize a request path for logging. Strips query strings on /peah/* (the public no-tracking tier); query strings present on other tiers (operators need them for diagnosis; bearer-auth tiers are not the no-tracking surface). Pure function; testable in isolation. """ def sanitized_path(conn) do case conn.request_path do "/peah/" <> _rest = path -> # /peah/* — drop everything after the path; no query string. # The path class is preserved (operators see /peah/ask vs. # /peah/azazel_log); the per-caller content is not. path other -> # Non-peah tier: keep the path but still drop query string # (queries on /chatzer/sanhedrin etc. are content; operators # who need diagnosis can reproduce from the response). other end end end