From f27de774aa81f6911c7d0db7c13537327908ccac Mon Sep 17 00:00:00 2001 From: Stuart Corbishley Date: Thu, 9 Apr 2026 15:50:24 +0200 Subject: [PATCH 01/23] Phase 1a: test contracts for channel request detail page (#4541) Define the target interface for schema, handler, and proxy plug changes needed by the channel request detail page. These tests fail until Phase 1b implements the backing code. Test contracts cover: - ChannelEvent new fields: request_query_string, request_body_size, response_body_size, request_send_us, response_duration_us - ChannelEvent headers as native jsonb (round-trip without Jason.decode!) - ChannelRequest auth tracking: client_webhook_auth_method_id (FK with on_delete nilify_all) and client_auth_type (denormalized snapshot) - Handler persist_completion adapted to Philter 0.3.0: timing.total_us, timing.send_us, timing.recv_us, observation sizes - Proxy plug: query string and client auth method pass-through --- .../channels/channel_requests_test.exs | 105 +++++++ test/lightning/channels/handler_test.exs | 266 ++++++++++++++++++ .../plugs/channel_proxy_plug_test.exs | 169 +++++++++++ 3 files changed, 540 insertions(+) diff --git a/test/lightning/channels/channel_requests_test.exs b/test/lightning/channels/channel_requests_test.exs index 105173d1c9f..cb9f896b85a 100644 --- a/test/lightning/channels/channel_requests_test.exs +++ b/test/lightning/channels/channel_requests_test.exs @@ -284,6 +284,111 @@ defmodule Lightning.Channels.ChannelRequestsTest do end end + # --------------------------------------------------------------- + # Phase 1a contract tests — client auth method tracking on requests + # --------------------------------------------------------------- + # + # These tests define the target interface after: + # - D3: client_webhook_auth_method_id and client_auth_type on channel_requests + # + # They will not compile/pass until Phase 1b implements the changes. + + describe "ChannelRequest changeset — auth method fields" do + test "accepts client_webhook_auth_method_id and client_auth_type" do + channel = insert(:channel) + {:ok, snapshot} = Channels.get_or_create_current_snapshot(channel) + auth_method = insert(:webhook_auth_method, project: channel.project) + + attrs = %{ + channel_id: channel.id, + channel_snapshot_id: snapshot.id, + request_id: "req-auth-test", + state: :success, + started_at: DateTime.utc_now(), + client_webhook_auth_method_id: auth_method.id, + client_auth_type: "api" + } + + changeset = ChannelRequest.changeset(%ChannelRequest{}, attrs) + assert changeset.valid? + + {:ok, request} = Repo.insert(changeset) + assert request.client_webhook_auth_method_id == auth_method.id + assert request.client_auth_type == "api" + end + + test "auth method fields are nullable" do + channel = insert(:channel) + {:ok, snapshot} = Channels.get_or_create_current_snapshot(channel) + + attrs = %{ + channel_id: channel.id, + channel_snapshot_id: snapshot.id, + request_id: "req-no-auth", + state: :success, + started_at: DateTime.utc_now() + } + + {:ok, request} = + ChannelRequest.changeset(%ChannelRequest{}, attrs) |> Repo.insert() + + assert request.client_webhook_auth_method_id == nil + assert request.client_auth_type == nil + end + + test "belongs_to client_webhook_auth_method loads correctly" do + channel = insert(:channel) + {:ok, snapshot} = Channels.get_or_create_current_snapshot(channel) + auth_method = insert(:webhook_auth_method, project: channel.project) + + request = + insert(:channel_request, + channel: channel, + channel_snapshot: snapshot, + client_webhook_auth_method_id: auth_method.id, + client_auth_type: "basic" + ) + + loaded = + ChannelRequest + |> Repo.get!(request.id) + |> Repo.preload(:client_webhook_auth_method) + + assert loaded.client_webhook_auth_method.id == auth_method.id + assert loaded.client_auth_type == "basic" + end + end + + describe "client_webhook_auth_method_id nilification on delete" do + test "FK is nilified when auth method is deleted, client_auth_type survives" do + channel = insert(:channel) + {:ok, snapshot} = Channels.get_or_create_current_snapshot(channel) + auth_method = insert(:webhook_auth_method, project: channel.project) + + request = + insert(:channel_request, + channel: channel, + channel_snapshot: snapshot, + client_webhook_auth_method_id: auth_method.id, + client_auth_type: "api" + ) + + # Verify FK is set + assert Repo.get!(ChannelRequest, request.id).client_webhook_auth_method_id == + auth_method.id + + # Delete the auth method + Repo.delete!(auth_method) + + # FK should be nilified by on_delete: :nilify_all + reloaded = Repo.get!(ChannelRequest, request.id) + assert reloaded.client_webhook_auth_method_id == nil + + # client_auth_type is a denormalized snapshot — it survives deletion + assert reloaded.client_auth_type == "api" + end + end + describe "delete_channel/2 with requests" do test "removes requests before deleting channel" do user = insert(:user) diff --git a/test/lightning/channels/handler_test.exs b/test/lightning/channels/handler_test.exs index 23aa5c5fec2..b28c37e2e07 100644 --- a/test/lightning/channels/handler_test.exs +++ b/test/lightning/channels/handler_test.exs @@ -190,6 +190,242 @@ defmodule Lightning.Channels.HandlerTest do end end + # --------------------------------------------------------------- + # Phase 1a contract tests — Philter 0.3.0 adaptation + new fields + # --------------------------------------------------------------- + # + # These tests define the target interface after: + # - D1: New columns on channel_events (body sizes, durations, query string) + # - D2: Headers text → jsonb migration + # - D4: Handler reads from Philter 0.3.0 result structure + # + # They will not compile/pass until Phase 1b implements the changes. + + describe "ChannelEvent changeset — new fields" do + test "accepts body size fields" do + attrs = %{ + channel_request_id: Ecto.UUID.generate(), + type: :destination_response, + request_body_size: 1024, + response_body_size: 2048 + } + + changeset = ChannelEvent.changeset(%ChannelEvent{}, attrs) + assert changeset.valid? + assert changeset.changes.request_body_size == 1024 + assert changeset.changes.response_body_size == 2048 + end + + test "accepts per-direction duration fields" do + attrs = %{ + channel_request_id: Ecto.UUID.generate(), + type: :destination_response, + request_send_us: 3_500, + response_duration_us: 8_000 + } + + changeset = ChannelEvent.changeset(%ChannelEvent{}, attrs) + assert changeset.valid? + assert changeset.changes.request_send_us == 3_500 + assert changeset.changes.response_duration_us == 8_000 + end + + test "accepts request_query_string" do + attrs = %{ + channel_request_id: Ecto.UUID.generate(), + type: :destination_response, + request_query_string: "page=1&limit=10" + } + + changeset = ChannelEvent.changeset(%ChannelEvent{}, attrs) + assert changeset.valid? + assert changeset.changes.request_query_string == "page=1&limit=10" + end + + test "new fields are all nullable" do + attrs = %{ + channel_request_id: Ecto.UUID.generate(), + type: :error, + error_message: "nxdomain" + } + + changeset = ChannelEvent.changeset(%ChannelEvent{}, attrs) + assert changeset.valid? + refute Map.has_key?(changeset.changes, :request_body_size) + refute Map.has_key?(changeset.changes, :response_body_size) + refute Map.has_key?(changeset.changes, :request_send_us) + refute Map.has_key?(changeset.changes, :response_duration_us) + refute Map.has_key?(changeset.changes, :request_query_string) + end + end + + describe "persist_completion — Philter 0.3.0 fields" do + setup %{state: state} do + metadata = request_metadata() + {:ok, state} = Handler.handle_request_started(metadata, state) + + state = + Map.merge(state, %{ + ttfb_us: 10_000, + response_status: 200, + response_headers: [{"content-type", "text/plain"}] + }) + + %{state: state} + end + + test "uses timing.total_us for latency_ms", %{state: state} do + result = philter_result(timing: %{total_us: 50_000, send_us: 2_000}) + + assert {:ok, _state} = Handler.handle_response_finished(result, state) + + event = Repo.one!(ChannelEvent) + assert event.latency_ms == 50 + end + + test "persists request_send_us from timing.send_us", %{state: state} do + result = philter_result(timing: %{total_us: 50_000, send_us: 3_500}) + + assert {:ok, _state} = Handler.handle_response_finished(result, state) + + event = Repo.one!(ChannelEvent) + assert event.request_send_us == 3_500 + end + + test "persists response_duration_us from timing.recv_us", + %{state: state} do + result = + philter_result( + timing: %{total_us: 50_000, send_us: 2_000, recv_us: 8_000} + ) + + assert {:ok, _state} = Handler.handle_response_finished(result, state) + + event = Repo.one!(ChannelEvent) + assert event.response_duration_us == 8_000 + end + + test "persists body sizes from observations", %{state: state} do + result = + philter_result( + request_observation: %{ + hash: "req123", + size: 1024, + body: nil, + preview: "request body" + }, + response_observation: %{ + hash: "resp123", + size: 2048, + body: nil, + preview: "response body" + } + ) + + assert {:ok, _state} = Handler.handle_response_finished(result, state) + + event = Repo.one!(ChannelEvent) + assert event.request_body_size == 1024 + assert event.response_body_size == 2048 + end + + test "persists query string from handler state", %{state: state} do + state = Map.put(state, :query_string, "page=1&limit=10") + result = philter_result() + + assert {:ok, _state} = Handler.handle_response_finished(result, state) + + event = Repo.one!(ChannelEvent) + assert event.request_query_string == "page=1&limit=10" + end + + test "nil phase timings when collect_timing is disabled", %{state: state} do + result = + philter_result(timing: %{total_us: 50_000, send_us: nil, recv_us: nil}) + + assert {:ok, _state} = Handler.handle_response_finished(result, state) + + event = Repo.one!(ChannelEvent) + assert event.request_send_us == nil + assert event.response_duration_us == nil + assert event.latency_ms == 50 + end + end + + describe "header encoding — native jsonb" do + setup %{state: state} do + metadata = + request_metadata( + headers: [ + {"content-type", "application/json"}, + {"x-custom", "value"} + ] + ) + + {:ok, state} = Handler.handle_request_started(metadata, state) + + state = + Map.merge(state, %{ + ttfb_us: 10_000, + response_status: 200, + response_headers: [ + {"content-type", "text/plain"}, + {"x-resp", "val"} + ] + }) + + %{state: state} + end + + test "request headers round-trip as list without Jason.decode!", %{ + state: state + } do + result = philter_result() + Handler.handle_response_finished(result, state) + + event = Repo.one!(ChannelEvent) + + # After jsonb migration, headers are native lists, not JSON strings + assert is_list(event.request_headers) + + assert event.request_headers == [ + ["content-type", "application/json"], + ["x-custom", "value"] + ] + end + + test "response headers round-trip as list without Jason.decode!", %{ + state: state + } do + result = philter_result() + Handler.handle_response_finished(result, state) + + event = Repo.one!(ChannelEvent) + + assert is_list(event.response_headers) + + assert event.response_headers == [ + ["content-type", "text/plain"], + ["x-resp", "val"] + ] + end + + test "nil headers remain nil", %{state: state} do + state = Map.delete(state, :response_headers) + + result = + philter_result( + status: nil, + error: %Mint.TransportError{reason: :econnrefused} + ) + + Handler.handle_response_finished(result, state) + + event = Repo.one!(ChannelEvent) + assert event.response_headers == nil + end + end + # Helpers defp request_metadata(overrides \\ []) do @@ -231,4 +467,34 @@ defmodule Lightning.Channels.HandlerTest do duration_us: Keyword.get(overrides, :duration_us, 10_000) } end + + # Philter 0.3.0 result format: + # - Observations are content-only (hash, size, preview, body) + # - All timing lives in the top-level timing map + defp philter_result(overrides \\ []) do + observation = %{ + hash: "abc123", + size: 100, + body: nil, + preview: "test body" + } + + %{ + request_observation: + Keyword.get(overrides, :request_observation, observation), + response_observation: + Keyword.get(overrides, :response_observation, observation), + error: Keyword.get(overrides, :error, nil), + upstream_url: + Keyword.get(overrides, :upstream_url, "http://localhost:4999"), + method: Keyword.get(overrides, :method, "GET"), + status: Keyword.get(overrides, :status, 200), + timing: + Keyword.get(overrides, :timing, %{ + total_us: 10_000, + send_us: 2_000, + recv_us: 1_000 + }) + } + end end diff --git a/test/lightning_web/plugs/channel_proxy_plug_test.exs b/test/lightning_web/plugs/channel_proxy_plug_test.exs index 0e48d90e469..287ac9a78a5 100644 --- a/test/lightning_web/plugs/channel_proxy_plug_test.exs +++ b/test/lightning_web/plugs/channel_proxy_plug_test.exs @@ -985,6 +985,175 @@ defmodule LightningWeb.ChannelProxyPlugTest do end end + # --------------------------------------------------------------- + # Phase 1a contract tests — query string + client auth tracking + # --------------------------------------------------------------- + # + # These tests define the target interface after: + # - D1: request_query_string on channel_events + # - D3: client_webhook_auth_method_id and client_auth_type on channel_requests + # - D4: Proxy plug passes query string and auth info into handler state + # + # They will not compile/pass until Phase 1b implements the changes. + + describe "query string persistence" do + test "persists query string on channel event", %{ + bypass: bypass, + channel: channel + } do + Bypass.expect_once(bypass, "GET", "/search", fn conn -> + Plug.Conn.send_resp(conn, 200, "results") + end) + + conn(:get, "/channels/#{channel.id}/search?q=foo&page=2") + |> send_to_endpoint() + + event = + Lightning.Repo.one!( + from(e in ChannelEvent, + join: r in ChannelRequest, + on: r.id == e.channel_request_id, + where: r.channel_id == ^channel.id + ) + ) + + assert event.request_query_string == "q=foo&page=2" + end + + test "empty query string when no params", %{ + bypass: bypass, + channel: channel + } do + Bypass.expect_once(bypass, "GET", "/plain", fn conn -> + Plug.Conn.send_resp(conn, 200, "ok") + end) + + conn(:get, "/channels/#{channel.id}/plain") + |> send_to_endpoint() + + event = + Lightning.Repo.one!( + from(e in ChannelEvent, + join: r in ChannelRequest, + on: r.id == e.channel_request_id, + where: r.channel_id == ^channel.id + ) + ) + + assert event.request_query_string == "" + end + end + + describe "client auth tracking" do + test "persists auth method ID and type for API key auth", %{bypass: bypass} do + channel = + create_client_auth_channel(bypass, [ + %{auth_type: :api, api_key: "track-me"} + ]) + + auth_method = + channel + |> Lightning.Repo.preload(client_webhook_auth_methods: []) + |> Map.get(:client_webhook_auth_methods) + |> hd() + + Bypass.expect_once(bypass, "GET", "/tracked", fn conn -> + Plug.Conn.send_resp(conn, 200, "ok") + end) + + conn(:get, "/channels/#{channel.id}/tracked") + |> put_req_header("x-api-key", "track-me") + |> send_to_endpoint() + + request = + Lightning.Repo.one!( + from(r in ChannelRequest, where: r.channel_id == ^channel.id) + ) + + assert request.client_webhook_auth_method_id == auth_method.id + assert request.client_auth_type == "api" + end + + test "persists auth method ID and type for Basic auth", %{bypass: bypass} do + channel = + create_client_auth_channel(bypass, [ + %{auth_type: :basic, username: "user", password: "pass"} + ]) + + auth_method = + channel + |> Lightning.Repo.preload(client_webhook_auth_methods: []) + |> Map.get(:client_webhook_auth_methods) + |> hd() + + Bypass.expect_once(bypass, "GET", "/tracked", fn conn -> + Plug.Conn.send_resp(conn, 200, "ok") + end) + + encoded = Base.encode64("user:pass") + + conn(:get, "/channels/#{channel.id}/tracked") + |> put_req_header("authorization", "Basic #{encoded}") + |> send_to_endpoint() + + request = + Lightning.Repo.one!( + from(r in ChannelRequest, where: r.channel_id == ^channel.id) + ) + + assert request.client_webhook_auth_method_id == auth_method.id + assert request.client_auth_type == "basic" + end + + test "nil auth method when no client auth configured", %{ + bypass: bypass, + channel: channel + } do + Bypass.expect_once(bypass, "GET", "/open", fn conn -> + Plug.Conn.send_resp(conn, 200, "ok") + end) + + conn(:get, "/channels/#{channel.id}/open") + |> send_to_endpoint() + + request = + Lightning.Repo.one!( + from(r in ChannelRequest, where: r.channel_id == ^channel.id) + ) + + assert request.client_webhook_auth_method_id == nil + assert request.client_auth_type == nil + end + end + + describe "collect_timing integration" do + test "persists per-direction timing after successful proxy", %{ + bypass: bypass, + channel: channel + } do + Bypass.expect_once(bypass, "GET", "/timed", fn conn -> + Plug.Conn.send_resp(conn, 200, "ok") + end) + + conn(:get, "/channels/#{channel.id}/timed") + |> send_to_endpoint() + + event = + Lightning.Repo.one!( + from(e in ChannelEvent, + join: r in ChannelRequest, + on: r.id == e.channel_request_id, + where: r.channel_id == ^channel.id + ) + ) + + # With collect_timing: true, Philter populates timing.send_us + # which the handler persists as request_send_us + assert is_integer(event.request_send_us) + assert event.request_send_us >= 0 + end + end + defp send_to_endpoint(conn) do LightningWeb.Endpoint.call(conn, LightningWeb.Endpoint.init([])) end From e58ce77547d3c39fcdafb3fc050dc31e78471d19 Mon Sep 17 00:00:00 2001 From: Stuart Corbishley Date: Thu, 9 Apr 2026 16:22:32 +0200 Subject: [PATCH 02/23] Schema, handler, and proxy plug changes for channel request detail page (#4541) Add new columns to channel_events (body sizes, per-direction durations, query string), migrate headers from text to native jsonb, add client auth tracking to channel_requests, adapt handler to Philter 0.3.0 timing structure, and pass collect_timing/query_string/auth method through the proxy plug pipeline. --- lib/lightning/channels/channel_event.ex | 59 +++++++++++------ lib/lightning/channels/channel_request.ex | 7 ++ lib/lightning/channels/handler.ex | 17 +++-- lib/lightning_web/plugs/channel_proxy_plug.ex | 64 +++++++++++++------ ...00001_add_channel_event_detail_columns.exs | 13 ++++ ...convert_channel_event_headers_to_jsonb.exs | 39 +++++++++++ ...ient_auth_tracking_to_channel_requests.exs | 14 ++++ test/lightning/channels/handler_test.exs | 17 +++-- .../plugs/channel_proxy_plug_test.exs | 5 +- 9 files changed, 182 insertions(+), 53 deletions(-) create mode 100644 priv/repo/migrations/20260409100001_add_channel_event_detail_columns.exs create mode 100644 priv/repo/migrations/20260409100002_convert_channel_event_headers_to_jsonb.exs create mode 100644 priv/repo/migrations/20260409100003_add_client_auth_tracking_to_channel_requests.exs diff --git a/lib/lightning/channels/channel_event.ex b/lib/lightning/channels/channel_event.ex index 16db6d5104a..2f07c704072 100644 --- a/lib/lightning/channels/channel_event.ex +++ b/lib/lightning/channels/channel_event.ex @@ -23,15 +23,20 @@ defmodule Lightning.Channels.ChannelEvent do type: :destination_response | :error, request_method: String.t() | nil, request_path: String.t() | nil, - request_headers: String.t() | nil, + request_query_string: String.t() | nil, + request_headers: list() | nil, request_body_preview: String.t() | nil, request_body_hash: String.t() | nil, + request_body_size: integer() | nil, response_status: integer() | nil, - response_headers: String.t() | nil, + response_headers: list() | nil, response_body_preview: String.t() | nil, response_body_hash: String.t() | nil, + response_body_size: integer() | nil, latency_ms: integer() | nil, ttfb_ms: integer() | nil, + request_send_us: integer() | nil, + response_duration_us: integer() | nil, error_message: String.t() | nil, inserted_at: DateTime.t() } @@ -41,17 +46,22 @@ defmodule Lightning.Channels.ChannelEvent do field :request_method, :string field :request_path, :string - field :request_headers, :string + field :request_query_string, :string + field :request_headers, {:array, {:array, :string}} field :request_body_preview, :string field :request_body_hash, :string + field :request_body_size, :integer field :response_status, :integer - field :response_headers, :string + field :response_headers, {:array, {:array, :string}} field :response_body_preview, :string field :response_body_hash, :string + field :response_body_size, :integer field :latency_ms, :integer field :ttfb_ms, :integer + field :request_send_us, :integer + field :response_duration_us, :integer field :error_message, :string belongs_to :channel_request, ChannelRequest @@ -61,22 +71,31 @@ defmodule Lightning.Channels.ChannelEvent do def changeset(event, attrs) do event - |> cast(attrs, [ - :channel_request_id, - :type, - :request_method, - :request_path, - :request_headers, - :request_body_preview, - :request_body_hash, - :response_status, - :response_headers, - :response_body_preview, - :response_body_hash, - :latency_ms, - :ttfb_ms, - :error_message - ]) + |> cast( + attrs, + [ + :channel_request_id, + :type, + :request_method, + :request_path, + :request_query_string, + :request_headers, + :request_body_preview, + :request_body_hash, + :request_body_size, + :response_status, + :response_headers, + :response_body_preview, + :response_body_hash, + :response_body_size, + :latency_ms, + :ttfb_ms, + :request_send_us, + :response_duration_us, + :error_message + ], + empty_values: [] + ) |> validate_required([:channel_request_id, :type]) |> assoc_constraint(:channel_request) end diff --git a/lib/lightning/channels/channel_request.ex b/lib/lightning/channels/channel_request.ex index 97cf9989715..6c974f55d7d 100644 --- a/lib/lightning/channels/channel_request.ex +++ b/lib/lightning/channels/channel_request.ex @@ -8,6 +8,7 @@ defmodule Lightning.Channels.ChannelRequest do alias Lightning.Channels.Channel alias Lightning.Channels.ChannelEvent alias Lightning.Channels.ChannelSnapshot + alias Lightning.Workflows.WebhookAuthMethod @type t :: %__MODULE__{ id: Ecto.UUID.t(), @@ -15,6 +16,8 @@ defmodule Lightning.Channels.ChannelRequest do channel_snapshot_id: Ecto.UUID.t(), request_id: String.t(), client_identity: String.t() | nil, + client_webhook_auth_method_id: Ecto.UUID.t() | nil, + client_auth_type: String.t() | nil, state: :pending | :success | :failed | :timeout | :error, started_at: DateTime.t(), completed_at: DateTime.t() | nil @@ -23,6 +26,7 @@ defmodule Lightning.Channels.ChannelRequest do schema "channel_requests" do field :request_id, :string field :client_identity, :string + field :client_auth_type, :string field :state, Ecto.Enum, values: [:pending, :success, :failed, :timeout, :error] @@ -32,6 +36,7 @@ defmodule Lightning.Channels.ChannelRequest do belongs_to :channel, Channel belongs_to :channel_snapshot, ChannelSnapshot + belongs_to :client_webhook_auth_method, WebhookAuthMethod has_many :channel_events, ChannelEvent end @@ -43,6 +48,8 @@ defmodule Lightning.Channels.ChannelRequest do :channel_snapshot_id, :request_id, :client_identity, + :client_webhook_auth_method_id, + :client_auth_type, :state, :started_at, :completed_at diff --git a/lib/lightning/channels/handler.ex b/lib/lightning/channels/handler.ex index 4d62f01af27..c4a893510c1 100644 --- a/lib/lightning/channels/handler.ex +++ b/lib/lightning/channels/handler.ex @@ -60,6 +60,9 @@ defmodule Lightning.Channels.Handler do channel_snapshot_id: state.snapshot.id, request_id: state.request_id, client_identity: state.client_identity, + client_webhook_auth_method_id: + Map.get(state, :client_webhook_auth_method_id), + client_auth_type: Map.get(state, :client_auth_type), state: :pending, started_at: state.started_at } @@ -105,15 +108,20 @@ defmodule Lightning.Channels.Handler do type: event_type, request_method: state.request_method, request_path: state.request_path, + request_query_string: Map.get(state, :query_string), request_headers: encode_headers(state.request_headers), request_body_preview: get_in(result, [:request_observation, :preview]), request_body_hash: get_in(result, [:request_observation, :hash]), + request_body_size: get_in(result, [:request_observation, :size]), response_status: result.status, response_headers: encode_headers(Map.get(state, :response_headers)), response_body_preview: get_in(result, [:response_observation, :preview]), response_body_hash: get_in(result, [:response_observation, :hash]), - latency_ms: div(result.duration_us, 1000), + response_body_size: get_in(result, [:response_observation, :size]), + latency_ms: div(result.timing.total_us, 1000), ttfb_ms: state |> Map.get(:ttfb_us) |> maybe_div(1000), + request_send_us: get_in(result, [:timing, :send_us]), + response_duration_us: get_in(result, [:timing, :recv_us]), error_message: if(result.error, do: classify_error(result.error)) } @@ -175,12 +183,11 @@ defmodule Lightning.Channels.Handler do defp encode_headers(nil), do: nil - # Encodes as array-of-pairs rather than a map because HTTP allows + # Returns as array-of-pairs rather than a map because HTTP allows # duplicate header keys (e.g. multiple Set-Cookie headers). + # Stored as native jsonb in the database. defp encode_headers(headers) do - headers - |> Enum.map(fn {k, v} -> [k, v] end) - |> Jason.encode!() + Enum.map(headers, fn {k, v} -> [k, v] end) end defp classify_error({:timeout, :connect_timeout}), do: "connect_timeout" diff --git a/lib/lightning_web/plugs/channel_proxy_plug.ex b/lib/lightning_web/plugs/channel_proxy_plug.ex index dfa0cafc338..dcc7e326cf7 100644 --- a/lib/lightning_web/plugs/channel_proxy_plug.ex +++ b/lib/lightning_web/plugs/channel_proxy_plug.ex @@ -69,15 +69,15 @@ defmodule LightningWeb.ChannelProxyPlug do defp do_proxy(conn, channel_id, rest) do with {:ok, channel} <- fetch_channel_with_telemetry(channel_id), - :ok <- authenticate_client(conn, channel) do - proxy_with_auth(conn, channel, rest) + {:ok, matched_auth} <- authenticate_client(conn, channel) do + proxy_with_auth(conn, channel, rest, matched_auth) else :not_found -> error_response(conn, :not_found, "Not Found") :unauthorized -> error_response(conn, :unauthorized, "Unauthorized") end end - defp proxy_with_auth(conn, channel, rest) do + defp proxy_with_auth(conn, channel, rest, matched_auth) do with {:ok, auth_header} <- resolve_destination_auth(channel), {:ok, snapshot} <- Channels.get_or_create_current_snapshot(channel) do client_auth_types = @@ -97,7 +97,7 @@ defmodule LightningWeb.ChannelProxyPlug do } conn - |> proxy_upstream(req) + |> proxy_upstream(req, matched_auth) |> halt() else {:credential_error, reason} -> @@ -108,18 +108,29 @@ defmodule LightningWeb.ChannelProxyPlug do end end + defp authenticate_client(_conn, %{client_webhook_auth_methods: []}) do + {:ok, nil} + end + defp authenticate_client(conn, channel) do methods = channel.client_webhook_auth_methods - if methods == [] or - Auth.valid_key?(conn, methods) or - Auth.valid_user?(conn, methods) do - :ok - else - :unauthorized + case find_matching_auth_method(conn, methods) do + %{} = method -> {:ok, method} + nil -> :unauthorized end end + defp find_matching_auth_method(conn, methods) do + Enum.find(methods, fn method -> + case method.auth_type do + :api -> Auth.valid_key?(conn, [method]) + :basic -> Auth.valid_user?(conn, [method]) + _ -> false + end + end) + end + defp fetch_channel_with_telemetry(channel_id) do metadata = %{channel_id: channel_id} @@ -133,15 +144,18 @@ defmodule LightningWeb.ChannelProxyPlug do ) end - defp proxy_upstream(conn, %DestinationRequest{} = req) do - handler_state = %{ - channel: req.channel, - snapshot: req.snapshot, - request_id: req.request_id, - started_at: DateTime.utc_now(), - request_path: req.forward_path, - client_identity: req.client_identity - } + defp proxy_upstream(conn, %DestinationRequest{} = req, matched_auth) do + handler_state = + %{ + channel: req.channel, + snapshot: req.snapshot, + request_id: req.request_id, + started_at: DateTime.utc_now(), + request_path: req.forward_path, + client_identity: req.client_identity, + query_string: conn.query_string + } + |> put_auth_method(matched_auth) metadata = %{ channel_id: req.channel.id, @@ -159,7 +173,8 @@ defmodule LightningWeb.ChannelProxyPlug do path: req.forward_path, handler: {Lightning.Channels.Handler, handler_state}, strip_headers: build_strip_headers(req.client_auth_types), - extra_headers: build_extra_headers(conn, req) + extra_headers: build_extra_headers(conn, req), + collect_timing: true ) {result, metadata} @@ -167,6 +182,15 @@ defmodule LightningWeb.ChannelProxyPlug do ) end + defp put_auth_method(state, nil), do: state + + defp put_auth_method(state, %{id: id, auth_type: auth_type}) do + Map.merge(state, %{ + client_webhook_auth_method_id: id, + client_auth_type: Atom.to_string(auth_type) + }) + end + defp build_extra_headers(conn, %DestinationRequest{} = req) do xff = case Plug.Conn.get_req_header(conn, "x-forwarded-for") do diff --git a/priv/repo/migrations/20260409100001_add_channel_event_detail_columns.exs b/priv/repo/migrations/20260409100001_add_channel_event_detail_columns.exs new file mode 100644 index 00000000000..bd773332ede --- /dev/null +++ b/priv/repo/migrations/20260409100001_add_channel_event_detail_columns.exs @@ -0,0 +1,13 @@ +defmodule Lightning.Repo.Migrations.AddChannelEventDetailColumns do + use Ecto.Migration + + def change do + alter table(:channel_events) do + add :request_query_string, :text + add :request_body_size, :bigint + add :response_body_size, :bigint + add :request_send_us, :integer + add :response_duration_us, :integer + end + end +end diff --git a/priv/repo/migrations/20260409100002_convert_channel_event_headers_to_jsonb.exs b/priv/repo/migrations/20260409100002_convert_channel_event_headers_to_jsonb.exs new file mode 100644 index 00000000000..29598390ad3 --- /dev/null +++ b/priv/repo/migrations/20260409100002_convert_channel_event_headers_to_jsonb.exs @@ -0,0 +1,39 @@ +defmodule Lightning.Repo.Migrations.ConvertChannelEventHeadersToJsonb do + use Ecto.Migration + + def up do + execute """ + ALTER TABLE channel_events + ALTER COLUMN request_headers TYPE jsonb + USING CASE + WHEN request_headers IS NULL THEN NULL + WHEN request_headers::jsonb IS NOT NULL THEN request_headers::jsonb + ELSE NULL + END + """ + + execute """ + ALTER TABLE channel_events + ALTER COLUMN response_headers TYPE jsonb + USING CASE + WHEN response_headers IS NULL THEN NULL + WHEN response_headers::jsonb IS NOT NULL THEN response_headers::jsonb + ELSE NULL + END + """ + end + + def down do + execute """ + ALTER TABLE channel_events + ALTER COLUMN request_headers TYPE text + USING request_headers::text + """ + + execute """ + ALTER TABLE channel_events + ALTER COLUMN response_headers TYPE text + USING response_headers::text + """ + end +end diff --git a/priv/repo/migrations/20260409100003_add_client_auth_tracking_to_channel_requests.exs b/priv/repo/migrations/20260409100003_add_client_auth_tracking_to_channel_requests.exs new file mode 100644 index 00000000000..df4eb79d0d0 --- /dev/null +++ b/priv/repo/migrations/20260409100003_add_client_auth_tracking_to_channel_requests.exs @@ -0,0 +1,14 @@ +defmodule Lightning.Repo.Migrations.AddClientAuthTrackingToChannelRequests do + use Ecto.Migration + + def change do + alter table(:channel_requests) do + add :client_webhook_auth_method_id, + references(:webhook_auth_methods, type: :binary_id, on_delete: :nilify_all) + + add :client_auth_type, :string + end + + create index(:channel_requests, [:client_webhook_auth_method_id]) + end +end diff --git a/test/lightning/channels/handler_test.exs b/test/lightning/channels/handler_test.exs index b28c37e2e07..9692e88f122 100644 --- a/test/lightning/channels/handler_test.exs +++ b/test/lightning/channels/handler_test.exs @@ -122,7 +122,11 @@ defmodule Lightning.Channels.HandlerTest do end test "creates ChannelEvent with correct fields", %{state: state} do - result = finished_result(status: 200, duration_us: 50_000) + result = + finished_result( + status: 200, + timing: %{total_us: 50_000, send_us: 2_000, recv_us: 1_000} + ) assert {:ok, _state} = Handler.handle_response_finished(result, state) @@ -449,9 +453,7 @@ defmodule Lightning.Channels.HandlerTest do hash: "abc123", size: 100, body: nil, - preview: "test body", - duration_us: 1000, - time_to_first_byte_us: 500 + preview: "test body" } %{ @@ -464,7 +466,12 @@ defmodule Lightning.Channels.HandlerTest do Keyword.get(overrides, :upstream_url, "http://localhost:4999"), method: Keyword.get(overrides, :method, "GET"), status: Keyword.get(overrides, :status, 200), - duration_us: Keyword.get(overrides, :duration_us, 10_000) + timing: + Keyword.get(overrides, :timing, %{ + total_us: 10_000, + send_us: 2_000, + recv_us: 1_000 + }) } end diff --git a/test/lightning_web/plugs/channel_proxy_plug_test.exs b/test/lightning_web/plugs/channel_proxy_plug_test.exs index 287ac9a78a5..d61da6403ae 100644 --- a/test/lightning_web/plugs/channel_proxy_plug_test.exs +++ b/test/lightning_web/plugs/channel_proxy_plug_test.exs @@ -866,10 +866,9 @@ defmodule LightningWeb.ChannelProxyPlugTest do ) # The handler redacts authorization headers before persisting - headers = Jason.decode!(event.request_headers) - + # Headers are native jsonb arrays, no JSON decoding needed auth_header = - Enum.find(headers, fn [k, _v] -> k == "authorization" end) + Enum.find(event.request_headers, fn [k, _v] -> k == "authorization" end) assert auth_header == ["authorization", "[REDACTED]"] end From 232cea433bbb5ae43da74d0ce3f964f96d9ad6bf Mon Sep 17 00:00:00 2001 From: Stuart Corbishley Date: Fri, 10 Apr 2026 11:46:23 +0200 Subject: [PATCH 03/23] Phase 3a: test contracts for channel request detail page (#4541) Define failing test contracts for the detail LiveView, error humanization helpers, and get_channel_request_for_project context function. Enrich channel_event/channel_error_event factories and migrate hand-rolled Repo.insert! calls across channel tests. --- .../lightning/channels/channel_stats_test.exs | 53 +-- test/lightning/channels_test.exs | 95 +++- .../channel_request_live/helpers_test.exs | 97 ++++ .../live/channel_request_live/show_test.exs | 415 ++++++++++++++++++ test/support/factories.ex | 45 +- test/support/factories/channel_factories.ex | 78 ++++ 6 files changed, 694 insertions(+), 89 deletions(-) create mode 100644 test/lightning_web/live/channel_request_live/helpers_test.exs create mode 100644 test/lightning_web/live/channel_request_live/show_test.exs create mode 100644 test/support/factories/channel_factories.ex diff --git a/test/lightning/channels/channel_stats_test.exs b/test/lightning/channels/channel_stats_test.exs index 696540386c6..9d72fdb7db4 100644 --- a/test/lightning/channels/channel_stats_test.exs +++ b/test/lightning/channels/channel_stats_test.exs @@ -2,7 +2,6 @@ defmodule Lightning.Channels.ChannelStatsTest do use Lightning.DataCase, async: true alias Lightning.Channels - alias Lightning.Channels.ChannelRequest alias Lightning.Channels.SearchParams describe "get_channel_stats_for_project/1" do @@ -20,29 +19,23 @@ defmodule Lightning.Channels.ChannelStatsTest do {:ok, snapshot1} = Channels.get_or_create_current_snapshot(channel1) {:ok, snapshot2} = Channels.get_or_create_current_snapshot(channel2) - Lightning.Repo.insert!(%ChannelRequest{ - channel_id: channel1.id, - channel_snapshot_id: snapshot1.id, - request_id: "stats-r1", - state: :success, - started_at: DateTime.utc_now() - }) - - Lightning.Repo.insert!(%ChannelRequest{ - channel_id: channel1.id, - channel_snapshot_id: snapshot1.id, - request_id: "stats-r2", - state: :success, - started_at: DateTime.utc_now() - }) - - Lightning.Repo.insert!(%ChannelRequest{ - channel_id: channel2.id, - channel_snapshot_id: snapshot2.id, - request_id: "stats-r3", - state: :success, - started_at: DateTime.utc_now() - }) + insert(:channel_request, + channel: channel1, + channel_snapshot: snapshot1, + state: :success + ) + + insert(:channel_request, + channel: channel1, + channel_snapshot: snapshot1, + state: :success + ) + + insert(:channel_request, + channel: channel2, + channel_snapshot: snapshot2, + state: :success + ) assert %{total_channels: 2, total_requests: 3} = Channels.get_channel_stats_for_project(project.id) @@ -53,13 +46,11 @@ defmodule Lightning.Channels.ChannelStatsTest do other_channel = insert(:channel) {:ok, snapshot} = Channels.get_or_create_current_snapshot(other_channel) - Lightning.Repo.insert!(%ChannelRequest{ - channel_id: other_channel.id, - channel_snapshot_id: snapshot.id, - request_id: "stats-other-r1", - state: :success, - started_at: DateTime.utc_now() - }) + insert(:channel_request, + channel: other_channel, + channel_snapshot: snapshot, + state: :success + ) assert %{total_requests: 0} = Channels.get_channel_stats_for_project(project.id) diff --git a/test/lightning/channels_test.exs b/test/lightning/channels_test.exs index 7500a759c3c..66c8ccf76a7 100644 --- a/test/lightning/channels_test.exs +++ b/test/lightning/channels_test.exs @@ -43,21 +43,19 @@ defmodule Lightning.ChannelsTest do t1 = ~U[2025-01-01 10:00:00.000000Z] t2 = ~U[2025-01-02 12:00:00.000000Z] - Lightning.Repo.insert!(%ChannelRequest{ - channel_id: channel.id, - channel_snapshot_id: snapshot.id, - request_id: "req-stats-1", + insert(:channel_request, + channel: channel, + channel_snapshot: snapshot, state: :success, started_at: t1 - }) + ) - Lightning.Repo.insert!(%ChannelRequest{ - channel_id: channel.id, - channel_snapshot_id: snapshot.id, - request_id: "req-stats-2", + insert(:channel_request, + channel: channel, + channel_snapshot: snapshot, state: :success, started_at: t2 - }) + ) results = Channels.list_channels_for_project_with_stats(project.id) @@ -79,13 +77,12 @@ defmodule Lightning.ChannelsTest do {:ok, snapshot_b} = Channels.get_or_create_current_snapshot(channel_b) - Lightning.Repo.insert!(%ChannelRequest{ - channel_id: channel_b.id, - channel_snapshot_id: snapshot_b.id, - request_id: "req-stats-3", + insert(:channel_request, + channel: channel_b, + channel_snapshot: snapshot_b, state: :success, started_at: ~U[2025-06-01 00:00:00.000000Z] - }) + ) results = Channels.list_channels_for_project_with_stats(project.id) @@ -433,4 +430,72 @@ defmodule Lightning.ChannelsTest do assert snapshot2.name == "updated-name" end end + + describe "get_channel_request_for_project/2" do + test "returns channel request with preloads when project matches" do + project = insert(:project) + channel = insert(:channel, project: project) + {:ok, snapshot} = Channels.get_or_create_current_snapshot(channel) + + request = + insert(:channel_request, + channel: channel, + channel_snapshot: snapshot, + state: :success, + started_at: DateTime.utc_now() + ) + + event = + insert(:channel_event, + channel_request: request, + request_path: "/test", + latency_ms: 100 + ) + + result = Channels.get_channel_request_for_project(project.id, request.id) + + assert result.id == request.id + assert result.channel.id == channel.id + assert result.channel_snapshot.id == snapshot.id + + assert length(result.channel_events) == 1 + assert hd(result.channel_events).id == event.id + end + + test "returns nil when channel request belongs to a different project" do + project_a = insert(:project) + project_b = insert(:project) + channel = insert(:channel, project: project_a) + {:ok, snapshot} = Channels.get_or_create_current_snapshot(channel) + + request = + insert(:channel_request, + channel: channel, + channel_snapshot: snapshot, + state: :success, + started_at: DateTime.utc_now() + ) + + assert Channels.get_channel_request_for_project(project_b.id, request.id) == + nil + end + + test "returns nil for non-existent request ID" do + project = insert(:project) + + assert Channels.get_channel_request_for_project( + project.id, + Ecto.UUID.generate() + ) == nil + end + + test "returns nil for invalid UUID" do + project = insert(:project) + + assert Channels.get_channel_request_for_project( + project.id, + "not-a-valid-uuid" + ) == nil + end + end end diff --git a/test/lightning_web/live/channel_request_live/helpers_test.exs b/test/lightning_web/live/channel_request_live/helpers_test.exs new file mode 100644 index 00000000000..c8f37ab573c --- /dev/null +++ b/test/lightning_web/live/channel_request_live/helpers_test.exs @@ -0,0 +1,97 @@ +defmodule LightningWeb.ChannelRequestLive.HelpersTest do + use ExUnit.Case, async: true + + alias LightningWeb.ChannelRequestLive.Helpers + + describe "humanize_error/1" do + test "maps transport error codes to human messages" do + assert Helpers.humanize_error("nxdomain") =~ + "DNS lookup failed" + + assert Helpers.humanize_error("econnrefused") =~ + "Connection refused" + + assert Helpers.humanize_error("ehostunreach") =~ + "Host unreachable" + + assert Helpers.humanize_error("enetunreach") =~ + "Network unreachable" + + assert Helpers.humanize_error("closed") =~ + "Connection closed unexpectedly" + + assert Helpers.humanize_error("econnreset") =~ + "Connection reset" + + assert Helpers.humanize_error("econnaborted") =~ + "Connection aborted" + + assert Helpers.humanize_error("epipe") =~ + "Broken pipe" + + assert Helpers.humanize_error("connect_timeout") =~ + "Connection timed out" + + assert Helpers.humanize_error("response_timeout") =~ + "Response timed out" + + assert Helpers.humanize_error("timeout") =~ + "Request timed out" + end + + test "maps credential error codes to human messages" do + assert Helpers.humanize_error("credential_missing_auth_fields") =~ + "missing required authentication fields" + + assert Helpers.humanize_error("credential_environment_not_found") =~ + "credential environment could not be found" + + assert Helpers.humanize_error("oauth_refresh_failed") =~ + "OAuth token refresh failed" + + assert Helpers.humanize_error("oauth_reauthorization_required") =~ + "OAuth credential needs to be re-authorized" + end + + test "handles unsupported_credential_schema with dynamic name" do + result = + Helpers.humanize_error("unsupported_credential_schema:my_schema") + + assert result =~ "Unsupported credential type" + assert result =~ "my_schema" + end + + test "passes through unknown error codes unchanged" do + assert Helpers.humanize_error("some_unknown_error") == + "some_unknown_error" + end + end + + describe "error_category/1" do + test "classifies transport errors" do + for code <- ~w(nxdomain econnrefused ehostunreach enetunreach closed + econnreset econnaborted epipe connect_timeout + response_timeout timeout) do + assert Helpers.error_category(code) == :transport, + "expected #{code} to be :transport" + end + end + + test "classifies credential errors" do + for code <- ~w(credential_missing_auth_fields + credential_environment_not_found + oauth_refresh_failed + oauth_reauthorization_required) do + assert Helpers.error_category(code) == :credential, + "expected #{code} to be :credential" + end + + assert Helpers.error_category("unsupported_credential_schema:foo") == + :credential + end + + test "returns nil for unknown error codes" do + assert Helpers.error_category("something_else") == nil + end + end +end diff --git a/test/lightning_web/live/channel_request_live/show_test.exs b/test/lightning_web/live/channel_request_live/show_test.exs new file mode 100644 index 00000000000..1f7f31196ce --- /dev/null +++ b/test/lightning_web/live/channel_request_live/show_test.exs @@ -0,0 +1,415 @@ +defmodule LightningWeb.ChannelRequestLive.ShowTest do + use LightningWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Lightning.Factories + + alias Lightning.Channels + + setup :stub_rate_limiter_ok + + defp enable_experimental_features(%{user: user}) do + Lightning.Accounts.update_user_preferences(user, %{ + "experimental_features" => true + }) + + :ok + end + + defp create_channel_request(project, attrs \\ %{}) do + channel = + Map.get_lazy(attrs, :channel, fn -> + insert(:channel, project: project) + end) + + {:ok, snapshot} = Channels.get_or_create_current_snapshot(channel) + + request = + insert(:channel_request, + channel: channel, + channel_snapshot: snapshot, + state: Map.get(attrs, :state, :success), + client_identity: Map.get(attrs, :client_identity, "192.168.1.1"), + client_auth_type: Map.get(attrs, :client_auth_type, "api"), + started_at: Map.get(attrs, :started_at, ~U[2026-04-10 10:00:00.000000Z]), + completed_at: + Map.get(attrs, :completed_at, ~U[2026-04-10 10:00:00.350000Z]) + ) + + {request, channel, snapshot} + end + + defp detail_path(project, request) do + ~p"/projects/#{project.id}/history/channels/#{request.id}" + end + + describe "feature gate" do + setup [:register_and_log_in_user, :create_project_for_current_user] + + test "redirects when experimental features are disabled", %{ + conn: conn, + project: project + } do + {request, _channel, _snapshot} = create_channel_request(project) + + assert {:error, {:redirect, _}} = + live(conn, detail_path(project, request)) + end + end + + describe "detail page — success state" do + setup [:register_and_log_in_user, :create_project_for_current_user] + setup :enable_experimental_features + + test "renders summary card, metadata, headers, and body previews", %{ + conn: conn, + project: project + } do + {request, channel, _snapshot} = create_channel_request(project) + + insert(:channel_event, + channel_request: request, + request_query_string: "format=json" + ) + + {:ok, view, _html} = live(conn, detail_path(project, request)) + html = render(view) + + # Summary card + assert html =~ "POST" + assert html =~ "/api/v1/data" + assert html =~ "format=json" + assert html =~ "200" + assert html =~ "Success" + assert html =~ channel.name + + # Metadata + assert html =~ "192.168.1.1" + assert html =~ "api" + assert html =~ String.slice(request.id, 0..7) + assert html =~ "350" + # Destination URL from channel + assert html =~ channel.destination_url + # Timestamps + assert html =~ "2026" + assert html =~ "10:00" + + # Request headers + assert html =~ "content-type" + assert html =~ "authorization" + assert html =~ "[REDACTED]" + + # Body previews + assert html =~ ~s({"key":"value"}) + assert html =~ ~s({"status":"ok"}) + end + end + + describe "detail page — error state" do + setup [:register_and_log_in_user, :create_project_for_current_user] + setup :enable_experimental_features + + test "renders humanized error and raw string for transport error", %{ + conn: conn, + project: project + } do + {request, _channel, _snapshot} = + create_channel_request(project, state: :error) + + insert(:channel_error_event, + channel_request: request, + error_message: "econnrefused", + latency_ms: 100 + ) + + {:ok, view, _html} = live(conn, detail_path(project, request)) + html = render(view) + + assert html =~ "Connection refused" + assert html =~ "econnrefused" + end + + test "renders credential error with appropriate messaging", %{ + conn: conn, + project: project + } do + {request, _channel, _snapshot} = + create_channel_request(project, state: :error) + + insert(:channel_error_event, + channel_request: request, + error_message: "credential_missing_auth_fields" + ) + + {:ok, view, _html} = live(conn, detail_path(project, request)) + html = render(view) + + assert html =~ "missing required authentication fields" + assert html =~ "credential_missing_auth_fields" + end + end + + describe "detail page — timing section" do + setup [:register_and_log_in_user, :create_project_for_current_user] + setup :enable_experimental_features + + test "renders three-segment bar when all timing fields present", %{ + conn: conn, + project: project + } do + {request, _channel, _snapshot} = create_channel_request(project) + + insert(:channel_event, + channel_request: request, + request_send_us: 5000, + ttfb_ms: 280, + response_duration_us: 65000, + latency_ms: 350 + ) + + {:ok, view, _html} = live(conn, detail_path(project, request)) + html = render(view) + + assert html =~ "timing-section" + assert html =~ "350" + assert html =~ "280" + end + + test "falls back gracefully when timing fields are partially nil", %{ + conn: conn, + project: project + } do + # Two-segment fallback: per-direction durations nil, TTFB + latency present + {req1, _ch1, _snap1} = create_channel_request(project) + + insert(:channel_event, + channel_request: req1, + request_send_us: nil, + response_duration_us: nil, + ttfb_ms: 280, + latency_ms: 350 + ) + + {:ok, view1, _html} = live(conn, detail_path(project, req1)) + html1 = render(view1) + assert html1 =~ "350" + assert html1 =~ "280" + + # Single bar fallback: only latency_ms + {req2, _ch2, _snap2} = create_channel_request(project) + + insert(:channel_event, + channel_request: req2, + request_send_us: nil, + response_duration_us: nil, + ttfb_ms: nil, + latency_ms: 420 + ) + + {:ok, view2, _html} = live(conn, detail_path(project, req2)) + html2 = render(view2) + assert html2 =~ "420" + end + + test "shows single bar for transport errors, hidden for credential errors", + %{conn: conn, project: project} do + # Transport error: timing section visible with single bar + {req_transport, _ch1, _snap1} = + create_channel_request(project, state: :timeout) + + insert(:channel_error_event, + channel_request: req_transport, + error_message: "response_timeout", + latency_ms: 30000 + ) + + {:ok, view1, _html} = live(conn, detail_path(project, req_transport)) + html1 = render(view1) + assert html1 =~ "30000" or html1 =~ "30,000" + + # Credential error: timing section hidden entirely + {req_cred, _ch2, _snap2} = + create_channel_request(project, state: :error) + + insert(:channel_error_event, + channel_request: req_cred, + error_message: "credential_missing_auth_fields" + ) + + {:ok, view2, _html} = live(conn, detail_path(project, req_cred)) + html2 = render(view2) + refute html2 =~ "timing-section" + end + end + + describe "detail page — context section" do + setup [:register_and_log_in_user, :create_project_for_current_user] + setup :enable_experimental_features + + test "renders snapshot data and config changed indicator when versions differ", + %{conn: conn, project: project, user: user} do + channel = insert(:channel, project: project) + {:ok, snapshot} = Channels.get_or_create_current_snapshot(channel) + + request = + insert(:channel_request, + channel: channel, + channel_snapshot: snapshot, + state: :success, + started_at: DateTime.utc_now() + ) + + insert(:channel_event, channel_request: request) + + {:ok, view, _html} = live(conn, detail_path(project, request)) + html = render(view) + + # Snapshot data renders + assert html =~ snapshot.destination_url + assert html =~ to_string(snapshot.lock_version) + + # Bump channel version to create mismatch + {:ok, _updated} = + Channels.update_channel(channel, %{name: "updated-name"}, actor: user) + + {:ok, view2, _html} = live(conn, detail_path(project, request)) + html2 = render(view2) + + assert html2 =~ "changed" or html2 =~ "Config" + end + end + + describe "detail page — nil body" do + setup [:register_and_log_in_user, :create_project_for_current_user] + setup :enable_experimental_features + + test "shows 'Body not captured' when body_preview is nil", %{ + conn: conn, + project: project + } do + {request, _channel, _snapshot} = create_channel_request(project) + + insert(:channel_event, + channel_request: request, + request_body_preview: nil, + request_body_hash: nil, + request_body_size: 2048 + ) + + {:ok, view, _html} = live(conn, detail_path(project, request)) + html = render(view) + + assert html =~ "Body not captured" + end + + test "hides body sub-section entirely when both preview and size are nil", + %{conn: conn, project: project} do + {request, _channel, _snapshot} = + create_channel_request(project, state: :error) + + insert(:channel_error_event, + channel_request: request, + error_message: "credential_missing_auth_fields", + request_body_preview: nil, + request_body_hash: nil, + request_body_size: nil + ) + + {:ok, view, _html} = live(conn, detail_path(project, request)) + html = render(view) + + refute html =~ "Body not captured" + refute html =~ "request-body" + end + + test "shows metadata only for binary (non-text) content-type", %{ + conn: conn, + project: project + } do + {request, _channel, _snapshot} = create_channel_request(project) + + insert(:channel_event, + channel_request: request, + response_headers: [["content-type", "application/octet-stream"]], + response_body_preview: nil, + response_body_size: 4096, + response_body_hash: "binaryhash123" + ) + + {:ok, view, _html} = live(conn, detail_path(project, request)) + html = render(view) + + # Should show size and hash metadata + assert html =~ "4096" or html =~ "4.0 KB" or html =~ "4 KB" + assert html =~ "binaryhash123" + # Should NOT render a body preview
 block
+      refute html =~ ~s({"status":"ok"})
+    end
+  end
+
+  describe "security" do
+    setup [:register_and_log_in_user, :create_project_for_current_user]
+    setup :enable_experimental_features
+
+    test "cross-project isolation and invalid UUID both return 404", %{
+      conn: conn,
+      project: project
+    } do
+      other_project = insert(:project)
+      {request, _channel, _snapshot} = create_channel_request(other_project)
+      insert(:channel_event, channel_request: request)
+
+      assert {:error, {:redirect, _}} =
+               live(conn, detail_path(project, request))
+
+      assert {:error, {:redirect, _}} =
+               live(
+                 conn,
+                 ~p"/projects/#{project.id}/history/channels/not-a-uuid"
+               )
+    end
+  end
+
+  describe "navigation" do
+    setup [:register_and_log_in_user, :create_project_for_current_user]
+    setup :enable_experimental_features
+
+    test "breadcrumbs render correctly", %{conn: conn, project: project} do
+      {request, _channel, _snapshot} = create_channel_request(project)
+      insert(:channel_event, channel_request: request)
+
+      {:ok, view, _html} = live(conn, detail_path(project, request))
+      html = render(view)
+
+      assert html =~ "History"
+      assert html =~ "Channel"
+      assert html =~ String.slice(request.id, 0..7)
+    end
+
+    test "channel logs table rows link to the detail page", %{
+      conn: conn,
+      project: project
+    } do
+      channel = insert(:channel, project: project, name: "link-test")
+      {:ok, snapshot} = Channels.get_or_create_current_snapshot(channel)
+
+      request =
+        insert(:channel_request,
+          channel: channel,
+          channel_snapshot: snapshot,
+          state: :success,
+          started_at: DateTime.utc_now()
+        )
+
+      insert(:channel_event, channel_request: request)
+
+      {:ok, view, _html} =
+        live(conn, ~p"/projects/#{project.id}/history/channels")
+
+      html = render(view)
+
+      assert html =~
+               ~r/href="[^"]*\/projects\/#{project.id}\/history\/channels\/#{request.id}"/
+    end
+  end
+end
diff --git a/test/support/factories.ex b/test/support/factories.ex
index ffa99da33d0..94c2a6de221 100644
--- a/test/support/factories.ex
+++ b/test/support/factories.ex
@@ -1,5 +1,7 @@
 defmodule Lightning.Factories do
   use ExMachina.Ecto, repo: Lightning.Repo
+  use Lightning.Factories.ChannelFactories
+
   alias Lightning.Workflows.Snapshot
 
   def webhook_auth_method_factory do
@@ -858,47 +860,4 @@ defmodule Lightning.Factories do
   def sandbox_for(parent, attrs \\ %{}) do
     build(:project, Map.merge(%{parent: parent}, attrs))
   end
-
-  def channel_factory do
-    %Lightning.Channels.Channel{
-      project: build(:project),
-      name: sequence(:channel_name, &"channel-#{&1}"),
-      destination_url:
-        sequence(
-          :channel_destination_url,
-          &"https://example.com/destination/#{&1}"
-        ),
-      enabled: true
-    }
-  end
-
-  def channel_auth_method_factory do
-    %Lightning.Channels.ChannelAuthMethod{
-      role: :client,
-      webhook_auth_method: build(:webhook_auth_method)
-    }
-  end
-
-  def channel_snapshot_factory do
-    %Lightning.Channels.ChannelSnapshot{
-      lock_version: 1,
-      name: sequence(:channel_snapshot_name, &"channel-#{&1}"),
-      destination_url: "https://example.com/destination",
-      enabled: true
-    }
-  end
-
-  def channel_request_factory do
-    %Lightning.Channels.ChannelRequest{
-      request_id: sequence(:channel_request_id, &"req-#{&1}"),
-      state: :pending,
-      started_at: DateTime.utc_now()
-    }
-  end
-
-  def channel_event_factory do
-    %Lightning.Channels.ChannelEvent{
-      type: :destination_response
-    }
-  end
 end
diff --git a/test/support/factories/channel_factories.ex b/test/support/factories/channel_factories.ex
new file mode 100644
index 00000000000..106ed63e6a1
--- /dev/null
+++ b/test/support/factories/channel_factories.ex
@@ -0,0 +1,78 @@
+defmodule Lightning.Factories.ChannelFactories do
+  @moduledoc false
+
+  defmacro __using__(_opts) do
+    quote do
+      def channel_factory do
+        %Lightning.Channels.Channel{
+          project: build(:project),
+          name: sequence(:channel_name, &"channel-#{&1}"),
+          destination_url:
+            sequence(
+              :channel_destination_url,
+              &"https://example.com/destination/#{&1}"
+            ),
+          enabled: true
+        }
+      end
+
+      def channel_auth_method_factory do
+        %Lightning.Channels.ChannelAuthMethod{
+          role: :client,
+          webhook_auth_method: build(:webhook_auth_method)
+        }
+      end
+
+      def channel_snapshot_factory do
+        %Lightning.Channels.ChannelSnapshot{
+          lock_version: 1,
+          name: sequence(:channel_snapshot_name, &"channel-#{&1}"),
+          destination_url: "https://example.com/destination",
+          enabled: true
+        }
+      end
+
+      def channel_request_factory do
+        %Lightning.Channels.ChannelRequest{
+          request_id: sequence(:channel_request_id, &"req-#{&1}"),
+          client_identity: "127.0.0.1",
+          state: :pending,
+          started_at: DateTime.utc_now()
+        }
+      end
+
+      def channel_event_factory do
+        %Lightning.Channels.ChannelEvent{
+          type: :destination_response,
+          request_method: "POST",
+          request_path: "/api/v1/data",
+          request_headers: [
+            ["content-type", "application/json"],
+            ["authorization", "[REDACTED]"]
+          ],
+          request_body_preview: ~s({"key":"value"}),
+          request_body_hash: "abc123def456",
+          request_body_size: 15,
+          response_status: 200,
+          response_headers: [["content-type", "application/json"]],
+          response_body_preview: ~s({"status":"ok"}),
+          response_body_hash: "def456abc123",
+          response_body_size: 15,
+          latency_ms: 350,
+          ttfb_ms: 280,
+          request_send_us: 5000,
+          response_duration_us: 65000
+        }
+      end
+
+      def channel_error_event_factory do
+        %Lightning.Channels.ChannelEvent{
+          type: :error,
+          request_method: "POST",
+          request_path: "/api/v1/data",
+          request_headers: [["content-type", "application/json"]]
+        }
+      end
+    end
+  end
+end

From a0c31c999751ac7a809b71222af209f4dfe1a607 Mon Sep 17 00:00:00 2001
From: Stuart Corbishley 
Date: Fri, 10 Apr 2026 12:12:48 +0200
Subject: [PATCH 04/23] Phase 3b: channel request detail page implementation
 (#4541)

Implement the detail LiveView, context function, error humanization,
route, and navigation wiring to make all Phase 3a test contracts pass.
---
 lib/lightning/channels.ex                     |  26 +
 .../live/channel_request_live/helpers.ex      |  82 ++
 .../live/channel_request_live/show.ex         | 782 ++++++++++++++++++
 .../live/run_live/channel_logs_component.ex   |   8 +-
 lib/lightning_web/router.ex                   |   1 +
 test/lightning/channels_test.exs              |   1 -
 .../live/channel_request_live/show_test.exs   |  10 +-
 7 files changed, 904 insertions(+), 6 deletions(-)
 create mode 100644 lib/lightning_web/live/channel_request_live/helpers.ex
 create mode 100644 lib/lightning_web/live/channel_request_live/show.ex

diff --git a/lib/lightning/channels.ex b/lib/lightning/channels.ex
index 904dc1597b2..c8e00791a56 100644
--- a/lib/lightning/channels.ex
+++ b/lib/lightning/channels.ex
@@ -441,4 +441,30 @@ defmodule Lightning.Channels do
 
     {total, nil}
   end
+
+  @doc """
+  Returns a channel request with preloads, scoped to the given project.
+
+  Returns `nil` if the request doesn't exist, belongs to a different project,
+  or the ID is not a valid UUID.
+
+  Preloads: `channel_events`, `channel`, `channel_snapshot`.
+  """
+  @spec get_channel_request_for_project(Ecto.UUID.t(), String.t()) ::
+          ChannelRequest.t() | nil
+  def get_channel_request_for_project(project_id, request_id) do
+    case Ecto.UUID.cast(request_id) do
+      {:ok, uuid} ->
+        from(cr in ChannelRequest,
+          join: c in Channel,
+          on: cr.channel_id == c.id,
+          where: cr.id == ^uuid and c.project_id == ^project_id,
+          preload: [:channel_events, :channel, :channel_snapshot]
+        )
+        |> Repo.one()
+
+      :error ->
+        nil
+    end
+  end
 end
diff --git a/lib/lightning_web/live/channel_request_live/helpers.ex b/lib/lightning_web/live/channel_request_live/helpers.ex
new file mode 100644
index 00000000000..0380cd2c6a7
--- /dev/null
+++ b/lib/lightning_web/live/channel_request_live/helpers.ex
@@ -0,0 +1,82 @@
+defmodule LightningWeb.ChannelRequestLive.Helpers do
+  @moduledoc """
+  Error humanization for channel request detail page.
+
+  Provides `humanize_error/1` to convert classified error codes into
+  human-readable descriptions, and `error_category/1` to classify errors
+  as `:transport` or `:credential`.
+  """
+
+  @transport_errors %{
+    "nxdomain" =>
+      "DNS lookup failed — the destination hostname could not be resolved",
+    "econnrefused" =>
+      "Connection refused — the destination server is not accepting connections on this port",
+    "ehostunreach" => "Host unreachable — no route to the destination server",
+    "enetunreach" => "Network unreachable — no network path to the destination",
+    "closed" => "Connection closed unexpectedly by the destination",
+    "econnreset" => "Connection reset — the destination dropped the connection",
+    "econnaborted" => "Connection aborted by the destination",
+    "epipe" =>
+      "Broken pipe — the destination closed the connection while data was being sent",
+    "connect_timeout" =>
+      "Connection timed out — the destination server did not respond to the connection attempt",
+    "response_timeout" =>
+      "Response timed out — the destination accepted the connection but did not send a response in time",
+    "timeout" => "Request timed out"
+  }
+
+  @credential_errors %{
+    "credential_missing_auth_fields" =>
+      "The configured credential is missing required authentication fields",
+    "credential_environment_not_found" =>
+      "The credential environment could not be found",
+    "oauth_refresh_failed" =>
+      "OAuth token refresh failed — the destination credential could not be renewed",
+    "oauth_reauthorization_required" =>
+      "OAuth credential needs to be re-authorized by a user"
+  }
+
+  @doc """
+  Converts a classified error code into a human-readable description.
+  Unknown codes pass through unchanged.
+  """
+  @spec humanize_error(String.t()) :: String.t()
+  def humanize_error(code) when is_binary(code) do
+    cond do
+      Map.has_key?(@transport_errors, code) ->
+        Map.fetch!(@transport_errors, code)
+
+      Map.has_key?(@credential_errors, code) ->
+        Map.fetch!(@credential_errors, code)
+
+      String.starts_with?(code, "unsupported_credential_schema:") ->
+        name = String.replace_prefix(code, "unsupported_credential_schema:", "")
+
+        "Unsupported credential type \"#{name}\" — this credential schema cannot be used for destination auth"
+
+      true ->
+        code
+    end
+  end
+
+  @doc """
+  Classifies an error code as `:transport`, `:credential`, or `nil` (unknown).
+  """
+  @spec error_category(String.t()) :: :transport | :credential | nil
+  def error_category(code) when is_binary(code) do
+    cond do
+      Map.has_key?(@transport_errors, code) ->
+        :transport
+
+      Map.has_key?(@credential_errors, code) ->
+        :credential
+
+      String.starts_with?(code, "unsupported_credential_schema:") ->
+        :credential
+
+      true ->
+        nil
+    end
+  end
+end
diff --git a/lib/lightning_web/live/channel_request_live/show.ex b/lib/lightning_web/live/channel_request_live/show.ex
new file mode 100644
index 00000000000..724a834160a
--- /dev/null
+++ b/lib/lightning_web/live/channel_request_live/show.ex
@@ -0,0 +1,782 @@
+defmodule LightningWeb.ChannelRequestLive.Show do
+  use LightningWeb, :live_view
+
+  import LightningWeb.RunLive.Components, only: [channel_state_pill: 1]
+
+  alias Lightning.Channels
+  alias LightningWeb.ChannelRequestLive.Helpers
+
+  alias Phoenix.LiveView.JS
+
+  on_mount {LightningWeb.Hooks, :project_scope}
+
+  @impl true
+  def mount(%{"id" => id}, _session, socket) do
+    %{current_user: current_user, project: project} = socket.assigns
+
+    if Lightning.Accounts.experimental_features_enabled?(current_user) do
+      case Channels.get_channel_request_for_project(project.id, id) do
+        nil ->
+          {:ok, redirect(socket, to: ~p"/projects/#{project}/history")}
+
+        channel_request ->
+          {:ok,
+           assign(socket,
+             active_menu_item: :runs,
+             page_title: "Channel Request",
+             request_id: id,
+             channel_request: channel_request
+           )}
+      end
+    else
+      {:ok, redirect(socket, to: ~p"/projects/#{project}/history")}
+    end
+  end
+
+  @impl true
+  def render(assigns) do
+    ~H"""
+    
+      <:header>
+        
+          <:breadcrumbs>
+            
+              
+              
+              
+                <:label>
+                  Channel Request
+                  
+                    {display_short_uuid(@request_id)}
+                  
+                
+              
+            
+          
+        
+      
+
+      
+        <% cr = @channel_request %>
+        <% event = primary_event(cr) %>
+        <% error_cat =
+          event && event.error_message && Helpers.error_category(event.error_message) %>
+        
+ <.summary_card + channel_request={cr} + event={event} + channel={cr.channel} + error_category={error_cat} + /> + + <.request_section event={event} /> + + <.response_section event={event} error_category={error_cat} /> + + <.timing_section :if={error_cat != :credential} event={event} /> + + <.context_section + channel_request={cr} + snapshot={cr.channel_snapshot} + channel={cr.channel} + /> +
+
+
+ """ + end + + # --- Summary Card --- + + defp summary_card(assigns) do + ~H""" +
+
+ <.method_badge method={@event && @event.request_method} /> + <.request_path_display event={@event} /> + <.status_code_display status={@event && @event.response_status} /> + <.state_pill_with_tooltip + state={@channel_request.state} + error_message={@event && @event.error_message} + /> +
+ +
+
+
+ Destination +
+
+ {@channel.destination_url} +
+
+
+
+ Channel +
+
+ <.link + navigate={ + ~p"/projects/#{@channel.project_id}/channels/#{@channel.id}/edit" + } + class="text-primary-600 hover:text-primary-800" + > + {@channel.name} + +
+
+
+
+ Client IP +
+
+ {@channel_request.client_identity || "—"} +
+
+
+
+ Auth +
+
+ <.icon name="hero-shield-check" class="h-4 w-4 text-secondary-400" /> + {format_auth_type(@channel_request.client_auth_type)} +
+
+
+
+ Started +
+
+ +
+
+
+
+ Completed +
+
+ +
+
+
+
+ Latency +
+
+ {if @event && @event.latency_ms, do: "#{@event.latency_ms} ms", else: "—"} +
+
+
+
+ Request ID +
+
+ + {String.slice(@channel_request.id, 0..7)} + + <.copy_icon_button + id="copy-request-id" + value={@channel_request.id} + title="Copy request ID" + /> +
+
+
+
+ """ + end + + defp method_badge(assigns) do + color_class = + case assigns.method do + "GET" -> "bg-blue-100 text-blue-800" + "POST" -> "bg-green-100 text-green-800" + "PUT" -> "bg-amber-100 text-amber-800" + "PATCH" -> "bg-amber-100 text-amber-800" + "DELETE" -> "bg-red-100 text-red-800" + _ -> "bg-secondary-100 text-secondary-800" + end + + assigns = assign(assigns, color_class: color_class) + + ~H""" + + {@method || "—"} + + """ + end + + defp request_path_display(assigns) do + ~H""" + + {@event && @event.request_path} + + ?{@event.request_query_string} + + + """ + end + + defp status_code_display(assigns) do + color_class = + case assigns.status do + s when is_integer(s) and s >= 200 and s < 300 -> + "text-green-700 bg-green-50" + + s when is_integer(s) and s >= 300 and s < 400 -> + "text-blue-700 bg-blue-50" + + s when is_integer(s) and s >= 400 and s < 500 -> + "text-amber-700 bg-amber-50" + + s when is_integer(s) and s >= 500 -> + "text-red-700 bg-red-50" + + _ -> + "text-secondary-400" + end + + assigns = assign(assigns, color_class: color_class) + + ~H""" + + {if @status, do: to_string(@status), else: "—"} + + """ + end + + defp state_pill_with_tooltip(assigns) do + ~H""" + <%= if @state == :timeout and @error_message do %> + + <.channel_state_pill state={@state} /> + + <% else %> + <.channel_state_pill state={@state} /> + <% end %> + """ + end + + # --- Request Section --- + + defp request_section(assigns) do + ~H""" + <.disclosure_section id="request-section" title="Request" open={true}> + <:title_right> + <.section_size_badge + :if={@event && @event.request_body_size} + size={@event.request_body_size} + id="request-size-badge" + /> + + <%= if @event do %> + <.headers_table + :if={@event.request_headers} + headers={@event.request_headers} + id="request-headers" + /> + <.body_viewer + id="request-body" + body_preview={@event.request_body_preview} + body_hash={@event.request_body_hash} + body_size={@event.request_body_size} + headers={@event.request_headers} + /> + <% end %> + + """ + end + + # --- Response Section --- + + defp response_section(assigns) do + ~H""" + <.disclosure_section id="response-section" title="Response" open={true}> + <:title_right> + <.status_code_badge + :if={@event && @event.response_status} + status={@event.response_status} + /> + <.section_size_badge + :if={@event && @event.response_body_size} + size={@event.response_body_size} + id="response-size-badge" + /> + + <%= if @error_category == :transport do %> + <.response_empty + type={:transport} + error_code={@event.error_message} + human_message={Helpers.humanize_error(@event.error_message)} + /> + <% end %> + <%= if @error_category == :credential do %> + <.response_empty + type={:credential} + error_code={@event.error_message} + human_message={Helpers.humanize_error(@event.error_message)} + /> + <% end %> + <%= if is_nil(@error_category) and @event do %> + <.headers_table + :if={@event.response_headers} + headers={@event.response_headers} + id="response-headers" + /> + <.body_viewer + id="response-body" + body_preview={@event.response_body_preview} + body_hash={@event.response_body_hash} + body_size={@event.response_body_size} + headers={@event.response_headers} + /> + <% end %> + + """ + end + + defp response_empty(assigns) do + {icon, label} = + case assigns.type do + :transport -> + {"hero-exclamation-triangle", "No response received"} + + :credential -> + {"hero-lock-closed", "Request not sent — credential error"} + end + + assigns = assign(assigns, icon: icon, label: label) + + ~H""" +
+ <.icon name={@icon} class="h-8 w-8 mb-3 text-secondary-400" /> +

{@label}

+

{@human_message}

+ + {@error_code} + +
+ """ + end + + # --- Timing Section --- + + defp timing_section(assigns) do + event = assigns.event + + segments = + if event do + compute_timing_segments(event) + else + nil + end + + assigns = assign(assigns, segments: segments, event: event) + + ~H""" +
+ <.disclosure_section id="timing-section-disclosure" title="Timing" open={true}> +
+ <.timing_bar segments={@segments} total_ms={@event.latency_ms} /> + <.timing_legend segments={@segments} event={@event} /> +
+ +
+ """ + end + + defp compute_timing_segments(event) do + cond do + is_nil(event.latency_ms) -> + nil + + not is_nil(event.request_send_us) and not is_nil(event.ttfb_ms) and + not is_nil(event.response_duration_us) -> + upload_ms = event.request_send_us / 1000 + processing_ms = max(event.ttfb_ms - upload_ms, 0) + download_ms = event.response_duration_us / 1000 + + [ + %{label: "Upload", ms: upload_ms, color: "bg-blue-400"}, + %{label: "Processing", ms: processing_ms, color: "bg-secondary-300"}, + %{label: "Download", ms: download_ms, color: "bg-green-400"} + ] + + not is_nil(event.ttfb_ms) -> + download_ms = max(event.latency_ms - event.ttfb_ms, 0) + + [ + %{label: "TTFB", ms: event.ttfb_ms, color: "bg-blue-400"}, + %{label: "Download", ms: download_ms, color: "bg-green-400"} + ] + + true -> + [%{label: "Total", ms: event.latency_ms, color: "bg-blue-400"}] + end + end + + defp timing_bar(assigns) do + total = Enum.reduce(assigns.segments, 0, fn s, acc -> acc + s.ms end) + total = if total == 0, do: 1, else: total + + segments_with_pct = + Enum.map(assigns.segments, fn s -> + Map.put(s, :pct, max(Float.round(s.ms / total * 100, 1), 1)) + end) + + assigns = assign(assigns, segments: segments_with_pct) + + ~H""" +
+ 0 ms +
+
+ 10} class="truncate px-1"> + {format_ms(seg.ms)} + +
+
+ + {format_ms(@total_ms)} ms + +
+ """ + end + + defp timing_legend(assigns) do + ~H""" +
+
+ + {seg.label}: {format_ms(seg.ms)} ms +
+
+ TTFB: {@event.ttfb_ms} ms +
+
+ """ + end + + # --- Context Section --- + + defp context_section(assigns) do + config_changed = + assigns.snapshot.lock_version != assigns.channel.lock_version + + assigns = assign(assigns, config_changed: config_changed) + + ~H""" + <.disclosure_section id="context-section" title="Context" open={false}> +
+
+
+ Destination URL +
+
+ {@snapshot.destination_url} +
+
+
+
+ Channel Name +
+
{@snapshot.name}
+
+
+
+ Config Version +
+
+ {@snapshot.lock_version} + + Config changed + +
+
+
+ + """ + end + + # --- Shared Components --- + + defp disclosure_section(assigns) do + assigns = + assigns + |> assign_new(:title_right, fn -> [] end) + + ~H""" +
+ +
+ {render_slot(@inner_block)} +
+
+ """ + end + + defp headers_table(assigns) do + ~H""" +
+

+ Headers +

+ + + + + + + +
+ {name} + + {value} +
+
+ """ + end + + defp body_viewer(assigns) do + content_type = extract_content_type(assigns.headers) + is_binary_content = content_type && !text_content_type?(content_type) + + assigns = + assign(assigns, + content_type: content_type, + is_binary_content: is_binary_content + ) + + ~H""" + <%= cond do %> + <% is_nil(@body_preview) and is_nil(@body_size) -> %> + <%!-- Hide entirely when both preview and size are nil --%> + <% @is_binary_content -> %> +
+ + {format_content_type_label(@content_type)} + + {format_bytes(@body_size)} + + {@body_hash} + +
+ <% is_nil(@body_preview) -> %> +
+ Body not captured + + ({format_bytes(@body_size)}) + +
+ <% true -> %> +
+
+ + {format_content_type_label(@content_type)} + + <.copy_icon_button + id={"#{@id}-copy"} + value={@body_preview} + title="Copy body" + /> + byte_size(@body_preview) + } + class="text-xs text-secondary-400" + > + Preview: {format_bytes(byte_size(@body_preview))} of {format_bytes( + @body_size + )} + +
+
{@body_preview}
+
+ {String.slice(@body_hash, 0..11)} + <.copy_icon_button + id={"#{@id}-hash-copy"} + value={@body_hash} + title="Copy hash" + size={3} + /> +
+
+ <% end %> + """ + end + + defp status_code_badge(assigns) do + color_class = + case assigns.status do + s when s >= 200 and s < 300 -> "bg-green-100 text-green-700" + s when s >= 300 and s < 400 -> "bg-blue-100 text-blue-700" + s when s >= 400 and s < 500 -> "bg-amber-100 text-amber-700" + s when s >= 500 -> "bg-red-100 text-red-700" + _ -> "bg-secondary-100 text-secondary-700" + end + + assigns = assign(assigns, color_class: color_class) + + ~H""" + + {@status} + + """ + end + + defp section_size_badge(assigns) do + ~H""" + + {format_bytes(@size)} + + """ + end + + attr :id, :string, required: true + attr :value, :string, required: true + attr :title, :string, default: "Copy" + attr :size, :integer, default: 4 + + defp copy_icon_button(assigns) do + ~H""" + + """ + end + + # --- Helpers --- + + defp primary_event(channel_request) do + channel_request.channel_events + |> Enum.find(&(&1.type == :destination_response)) || + Enum.find(channel_request.channel_events, &(&1.type == :error)) + end + + defp format_auth_type(nil), do: "None" + defp format_auth_type("api"), do: "API key" + defp format_auth_type("basic"), do: "Basic auth" + defp format_auth_type(type), do: type + + defp format_bytes(nil), do: "—" + + defp format_bytes(bytes) when bytes < 1024, + do: "#{bytes} B" + + defp format_bytes(bytes) when bytes < 1_048_576, + do: "#{Float.round(bytes / 1024, 1)} KB" + + defp format_bytes(bytes), + do: "#{Float.round(bytes / 1_048_576, 1)} MB" + + defp format_ms(ms) when is_float(ms) do + if ms == Float.round(ms), + do: trunc(ms) |> to_string(), + else: Float.round(ms, 1) |> to_string() + end + + defp format_ms(ms), do: to_string(ms) + + defp extract_content_type(nil), do: nil + + defp extract_content_type(headers) do + headers + |> Enum.find(fn [name, _] -> String.downcase(name) == "content-type" end) + |> case do + [_, value] -> value + nil -> nil + end + end + + defp text_content_type?(ct) do + String.contains?(ct, "text/") or + String.contains?(ct, "json") or + String.contains?(ct, "xml") or + String.contains?(ct, "javascript") or + String.contains?(ct, "html") + end + + defp format_content_type_label(ct) when is_binary(ct) do + cond do + String.contains?(ct, "json") -> "JSON" + String.contains?(ct, "xml") -> "XML" + String.contains?(ct, "html") -> "HTML" + String.contains?(ct, "text/") -> "TEXT" + true -> ct + end + end + + defp format_content_type_label(_), do: nil +end diff --git a/lib/lightning_web/live/run_live/channel_logs_component.ex b/lib/lightning_web/live/run_live/channel_logs_component.ex index 343b65ff8bf..a1810e52f3f 100644 --- a/lib/lightning_web/live/run_live/channel_logs_component.ex +++ b/lib/lightning_web/live/run_live/channel_logs_component.ex @@ -151,9 +151,13 @@ defmodule LightningWeb.RunLive.ChannelLogsComponent do <%= for entry <- @page.entries do %> <.tr id={"request-#{entry.id}"}> <.td> - + <.link + navigate={~p"/projects/#{@project}/history/channels/#{entry.id}"} + class="link font-mono" + title={entry.request_id} + > {display_short_uuid(entry.request_id)} - + <.td class="text-sm text-gray-700"> {source_event_path(entry)} diff --git a/lib/lightning_web/router.ex b/lib/lightning_web/router.ex index b08cac16ed7..e8f64c1fe53 100644 --- a/lib/lightning_web/router.ex +++ b/lib/lightning_web/router.ex @@ -241,6 +241,7 @@ defmodule LightningWeb.Router do live "/history", RunLive.Index, :index live "/history/channels", RunLive.Index, :channel_logs + live "/history/channels/:id", ChannelRequestLive.Show, :show live "/runs/:id", RunLive.Show, :show live "/dataclips/:id/show", DataclipLive.Show, :show diff --git a/test/lightning/channels_test.exs b/test/lightning/channels_test.exs index 66c8ccf76a7..a2787809811 100644 --- a/test/lightning/channels_test.exs +++ b/test/lightning/channels_test.exs @@ -6,7 +6,6 @@ defmodule Lightning.ChannelsTest do alias Lightning.Auditing.Audit alias Lightning.Channels alias Lightning.Channels.Channel - alias Lightning.Channels.ChannelRequest alias Lightning.Channels.ChannelSnapshot describe "list_channels_for_project/1" do diff --git a/test/lightning_web/live/channel_request_live/show_test.exs b/test/lightning_web/live/channel_request_live/show_test.exs index 1f7f31196ce..8a2c3d026f5 100644 --- a/test/lightning_web/live/channel_request_live/show_test.exs +++ b/test/lightning_web/live/channel_request_live/show_test.exs @@ -17,6 +17,8 @@ defmodule LightningWeb.ChannelRequestLive.ShowTest do end defp create_channel_request(project, attrs \\ %{}) do + attrs = Map.new(attrs) + channel = Map.get_lazy(attrs, :channel, fn -> insert(:channel, project: project) @@ -99,9 +101,11 @@ defmodule LightningWeb.ChannelRequestLive.ShowTest do assert html =~ "authorization" assert html =~ "[REDACTED]" - # Body previews - assert html =~ ~s({"key":"value"}) - assert html =~ ~s({"status":"ok"}) + # Body previews (quotes are HTML-entity-encoded by LiveView's test DOM serializer) + assert html =~ "key" + assert html =~ "value" + assert html =~ "status" + assert html =~ "ok" end end From e4ef9bf516e3dbfbcea66a5cce2df5769d0b6118 Mon Sep 17 00:00:00 2001 From: Stuart Corbishley Date: Fri, 10 Apr 2026 16:20:15 +0200 Subject: [PATCH 05/23] Add .envrc to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f8ff37ac20a..f32ebcacfa4 100644 --- a/.gitignore +++ b/.gitignore @@ -81,6 +81,7 @@ priv/openfn .dev.env .dev.override.env .test.override.env +.envrc worktrees .docker-cache From ddbbde68537496716752e784ba88bd8b126b0734 Mon Sep 17 00:00:00 2001 From: Stuart Corbishley Date: Fri, 10 Apr 2026 16:20:22 +0200 Subject: [PATCH 06/23] Migrate channel timing fields from milliseconds to microseconds (#4541) Store Finch telemetry values as-is in microseconds instead of truncating to milliseconds in the handler. Adds queue_us, connect_us, and reused_connection columns for full Finch phase visibility. --- lib/lightning/channels/channel_event.ex | 21 ++++++++++----- lib/lightning/channels/handler.ex | 10 +++---- ...6_rename_timing_fields_to_microseconds.exs | 26 +++++++++++++++++++ test/lightning/channels/handler_test.exs | 10 +++---- test/lightning/channels_test.exs | 2 +- .../plugs/channel_proxy_plug_test.exs | 2 +- test/support/factories/channel_factories.ex | 4 +-- 7 files changed, 55 insertions(+), 20 deletions(-) create mode 100644 priv/repo/migrations/20260410131136_rename_timing_fields_to_microseconds.exs diff --git a/lib/lightning/channels/channel_event.ex b/lib/lightning/channels/channel_event.ex index 2f07c704072..0d583a4665c 100644 --- a/lib/lightning/channels/channel_event.ex +++ b/lib/lightning/channels/channel_event.ex @@ -33,10 +33,13 @@ defmodule Lightning.Channels.ChannelEvent do response_body_preview: String.t() | nil, response_body_hash: String.t() | nil, response_body_size: integer() | nil, - latency_ms: integer() | nil, - ttfb_ms: integer() | nil, + latency_us: integer() | nil, + ttfb_us: integer() | nil, request_send_us: integer() | nil, response_duration_us: integer() | nil, + queue_us: integer() | nil, + connect_us: integer() | nil, + reused_connection: boolean() | nil, error_message: String.t() | nil, inserted_at: DateTime.t() } @@ -58,10 +61,13 @@ defmodule Lightning.Channels.ChannelEvent do field :response_body_hash, :string field :response_body_size, :integer - field :latency_ms, :integer - field :ttfb_ms, :integer + field :latency_us, :integer + field :ttfb_us, :integer field :request_send_us, :integer field :response_duration_us, :integer + field :queue_us, :integer + field :connect_us, :integer + field :reused_connection, :boolean field :error_message, :string belongs_to :channel_request, ChannelRequest @@ -88,10 +94,13 @@ defmodule Lightning.Channels.ChannelEvent do :response_body_preview, :response_body_hash, :response_body_size, - :latency_ms, - :ttfb_ms, + :latency_us, + :ttfb_us, :request_send_us, :response_duration_us, + :queue_us, + :connect_us, + :reused_connection, :error_message ], empty_values: [] diff --git a/lib/lightning/channels/handler.ex b/lib/lightning/channels/handler.ex index c4a893510c1..c00eff2d6a6 100644 --- a/lib/lightning/channels/handler.ex +++ b/lib/lightning/channels/handler.ex @@ -118,10 +118,13 @@ defmodule Lightning.Channels.Handler do response_body_preview: get_in(result, [:response_observation, :preview]), response_body_hash: get_in(result, [:response_observation, :hash]), response_body_size: get_in(result, [:response_observation, :size]), - latency_ms: div(result.timing.total_us, 1000), - ttfb_ms: state |> Map.get(:ttfb_us) |> maybe_div(1000), + latency_us: result.timing.total_us, + ttfb_us: Map.get(state, :ttfb_us), request_send_us: get_in(result, [:timing, :send_us]), response_duration_us: get_in(result, [:timing, :recv_us]), + queue_us: get_in(result, [:timing, :queue_us]), + connect_us: get_in(result, [:timing, :connect_us]), + reused_connection: get_in(result, [:timing, :reused_connection]), error_message: if(result.error, do: classify_error(result.error)) } @@ -199,7 +202,4 @@ defmodule Lightning.Channels.Handler do do: Atom.to_string(reason) defp classify_error(error), do: inspect(error) - - defp maybe_div(nil, _), do: nil - defp maybe_div(us, divisor), do: div(us, divisor) end diff --git a/priv/repo/migrations/20260410131136_rename_timing_fields_to_microseconds.exs b/priv/repo/migrations/20260410131136_rename_timing_fields_to_microseconds.exs new file mode 100644 index 00000000000..bf47ab281d6 --- /dev/null +++ b/priv/repo/migrations/20260410131136_rename_timing_fields_to_microseconds.exs @@ -0,0 +1,26 @@ +defmodule Lightning.Repo.Migrations.RenameTimingFieldsToMicroseconds do + use Ecto.Migration + + def change do + rename table(:channel_events), :latency_ms, to: :latency_us + rename table(:channel_events), :ttfb_ms, to: :ttfb_us + + flush() + + execute( + "UPDATE channel_events SET latency_us = latency_us * 1000", + "UPDATE channel_events SET latency_us = latency_us / 1000" + ) + + execute( + "UPDATE channel_events SET ttfb_us = ttfb_us * 1000", + "UPDATE channel_events SET ttfb_us = ttfb_us / 1000" + ) + + alter table(:channel_events) do + add :queue_us, :integer + add :connect_us, :integer + add :reused_connection, :boolean + end + end +end diff --git a/test/lightning/channels/handler_test.exs b/test/lightning/channels/handler_test.exs index 9692e88f122..897af8fe8ce 100644 --- a/test/lightning/channels/handler_test.exs +++ b/test/lightning/channels/handler_test.exs @@ -136,8 +136,8 @@ defmodule Lightning.Channels.HandlerTest do assert event.request_method == state.request_method assert event.request_path == "/test/path" assert event.response_status == 200 - assert event.latency_ms == 50 - assert event.ttfb_ms == 10 + assert event.latency_us == 50_000 + assert event.ttfb_us == 10_000 assert event.error_message == nil end @@ -278,13 +278,13 @@ defmodule Lightning.Channels.HandlerTest do %{state: state} end - test "uses timing.total_us for latency_ms", %{state: state} do + test "uses timing.total_us for latency_us", %{state: state} do result = philter_result(timing: %{total_us: 50_000, send_us: 2_000}) assert {:ok, _state} = Handler.handle_response_finished(result, state) event = Repo.one!(ChannelEvent) - assert event.latency_ms == 50 + assert event.latency_us == 50_000 end test "persists request_send_us from timing.send_us", %{state: state} do @@ -352,7 +352,7 @@ defmodule Lightning.Channels.HandlerTest do event = Repo.one!(ChannelEvent) assert event.request_send_us == nil assert event.response_duration_us == nil - assert event.latency_ms == 50 + assert event.latency_us == 50_000 end end diff --git a/test/lightning/channels_test.exs b/test/lightning/channels_test.exs index a2787809811..85a71af3b94 100644 --- a/test/lightning/channels_test.exs +++ b/test/lightning/channels_test.exs @@ -448,7 +448,7 @@ defmodule Lightning.ChannelsTest do insert(:channel_event, channel_request: request, request_path: "/test", - latency_ms: 100 + latency_us: 100_000 ) result = Channels.get_channel_request_for_project(project.id, request.id) diff --git a/test/lightning_web/plugs/channel_proxy_plug_test.exs b/test/lightning_web/plugs/channel_proxy_plug_test.exs index d61da6403ae..5fbd649829f 100644 --- a/test/lightning_web/plugs/channel_proxy_plug_test.exs +++ b/test/lightning_web/plugs/channel_proxy_plug_test.exs @@ -396,7 +396,7 @@ defmodule LightningWeb.ChannelProxyPlugTest do assert event.type == :destination_response assert event.response_status == 200 - assert event.latency_ms != nil + assert event.latency_us != nil assert event.request_method == "GET" assert event.request_path == "/persisted" end diff --git a/test/support/factories/channel_factories.ex b/test/support/factories/channel_factories.ex index 106ed63e6a1..a4a88540a72 100644 --- a/test/support/factories/channel_factories.ex +++ b/test/support/factories/channel_factories.ex @@ -58,8 +58,8 @@ defmodule Lightning.Factories.ChannelFactories do response_body_preview: ~s({"status":"ok"}), response_body_hash: "def456abc123", response_body_size: 15, - latency_ms: 350, - ttfb_ms: 280, + latency_us: 350_000, + ttfb_us: 280_000, request_send_us: 5000, response_duration_us: 65000 } From 62ce9ce63c0236d67e8e443f7b2c565930e98bf1 Mon Sep 17 00:00:00 2001 From: Stuart Corbishley Date: Fri, 10 Apr 2026 16:20:30 +0200 Subject: [PATCH 07/23] Channel request detail page: layout, nested timing visualization, and UI polish (#4541) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorder layout (summary → timing → request/response side-by-side → context), implement nested timeline showing Finch phases within proxy overhead using crosshatch pattern, add foldable disclosure sections for headers/body, improve body viewer with content-type badges and no-body indicators. --- .../live/channel_request_live/show.ex | 712 +++++++++++++----- .../live/channel_request_live/show_test.exs | 120 ++- 2 files changed, 632 insertions(+), 200 deletions(-) diff --git a/lib/lightning_web/live/channel_request_live/show.ex b/lib/lightning_web/live/channel_request_live/show.ex index 724a834160a..5675e75841f 100644 --- a/lib/lightning_web/live/channel_request_live/show.ex +++ b/lib/lightning_web/live/channel_request_live/show.ex @@ -64,7 +64,7 @@ defmodule LightningWeb.ChannelRequestLive.Show do <% event = primary_event(cr) %> <% error_cat = event && event.error_message && Helpers.error_category(event.error_message) %> -
+
<.summary_card channel_request={cr} event={event} @@ -72,12 +72,13 @@ defmodule LightningWeb.ChannelRequestLive.Show do error_category={error_cat} /> - <.request_section event={event} /> - - <.response_section event={event} error_category={error_cat} /> - <.timing_section :if={error_cat != :credential} event={event} /> +
+ <.request_section event={event} /> + <.response_section event={event} error_category={error_cat} /> +
+ <.context_section channel_request={cr} snapshot={cr.channel_snapshot} @@ -166,7 +167,9 @@ defmodule LightningWeb.ChannelRequestLive.Show do Latency
- {if @event && @event.latency_ms, do: "#{@event.latency_ms} ms", else: "—"} + {if @event && @event.latency_us, + do: "#{format_us(@event.latency_us)} ms", + else: "—"}
@@ -277,28 +280,55 @@ defmodule LightningWeb.ChannelRequestLive.Show do # --- Request Section --- defp request_section(assigns) do + event = assigns.event + + show_body = + event && + not (is_nil(event.request_body_preview) and + is_nil(event.request_body_size)) + + assigns = assign(assigns, show_body: show_body) + ~H""" - <.disclosure_section id="request-section" title="Request" open={true}> + <.disclosure_section + id="request-section" + title="Request" + open={true} + padded={false} + > <:title_right> <.section_size_badge - :if={@event && @event.request_body_size} + :if={@event && @event.request_body_size && @event.request_body_size > 0} size={@event.request_body_size} id="request-size-badge" /> <%= if @event do %> - <.headers_table + <.sub_section :if={@event.request_headers} - headers={@event.request_headers} - id="request-headers" - /> - <.body_viewer - id="request-body" - body_preview={@event.request_body_preview} - body_hash={@event.request_body_hash} - body_size={@event.request_body_size} - headers={@event.request_headers} - /> + id="req-headers" + title="Headers" + open={true} + > + <.headers_table headers={@event.request_headers} id="request-headers" /> + + <.sub_section :if={@show_body} id="req-body" title="Body" open={true}> + <:title_right> + 0} + class="text-[11px] text-secondary-400 font-mono" + > + {format_bytes(@event.request_body_size)} + + + <.body_viewer + id="request-body" + body_preview={@event.request_body_preview} + body_hash={@event.request_body_hash} + body_size={@event.request_body_size} + headers={@event.request_headers} + /> + <% end %> """ @@ -307,46 +337,67 @@ defmodule LightningWeb.ChannelRequestLive.Show do # --- Response Section --- defp response_section(assigns) do + event = assigns.event + + show_body = + event && is_nil(assigns.error_category) && + not (is_nil(event.response_body_preview) and + is_nil(event.response_body_size)) + + assigns = assign(assigns, show_body: show_body) + ~H""" - <.disclosure_section id="response-section" title="Response" open={true}> + <.disclosure_section + id="response-section" + title="Response" + open={true} + padded={false} + > <:title_right> <.status_code_badge :if={@event && @event.response_status} status={@event.response_status} /> <.section_size_badge - :if={@event && @event.response_body_size} + :if={@event && @event.response_body_size && @event.response_body_size > 0} size={@event.response_body_size} id="response-size-badge" /> - <%= if @error_category == :transport do %> - <.response_empty - type={:transport} - error_code={@event.error_message} - human_message={Helpers.humanize_error(@event.error_message)} - /> - <% end %> - <%= if @error_category == :credential do %> + <%= if @error_category in [:transport, :credential] do %> <.response_empty - type={:credential} + type={@error_category} error_code={@event.error_message} human_message={Helpers.humanize_error(@event.error_message)} /> - <% end %> - <%= if is_nil(@error_category) and @event do %> - <.headers_table - :if={@event.response_headers} - headers={@event.response_headers} - id="response-headers" - /> - <.body_viewer - id="response-body" - body_preview={@event.response_body_preview} - body_hash={@event.response_body_hash} - body_size={@event.response_body_size} - headers={@event.response_headers} - /> + <% else %> + <%= if @event do %> + <.sub_section + :if={@event.response_headers} + id="resp-headers" + title="Headers" + open={true} + > + <.headers_table headers={@event.response_headers} id="response-headers" /> + + <.sub_section :if={@show_body} id="resp-body" title="Body" open={true}> + <:title_right> + 0} + class="text-[11px] text-secondary-400 font-mono" + > + {format_bytes(@event.response_body_size)} + + + <.body_viewer + id="response-body" + body_preview={@event.response_body_preview} + body_hash={@event.response_body_hash} + body_size={@event.response_body_size} + headers={@event.response_headers} + /> + + <% end %> <% end %> """ @@ -365,13 +416,15 @@ defmodule LightningWeb.ChannelRequestLive.Show do assigns = assign(assigns, icon: icon, label: label) ~H""" -
- <.icon name={@icon} class="h-8 w-8 mb-3 text-secondary-400" /> -

{@label}

-

{@human_message}

- - {@error_code} - +
+
+ <.icon name={@icon} class="h-8 w-8 mb-3 text-secondary-400" /> +

{@label}

+

{@human_message}

+ + {@error_code} + +
""" end @@ -381,21 +434,21 @@ defmodule LightningWeb.ChannelRequestLive.Show do defp timing_section(assigns) do event = assigns.event - segments = + timing_data = if event do compute_timing_segments(event) else nil end - assigns = assign(assigns, segments: segments, event: event) + assigns = assign(assigns, timing_data: timing_data, event: event) ~H""" -
+
<.disclosure_section id="timing-section-disclosure" title="Timing" open={true}>
- <.timing_bar segments={@segments} total_ms={@event.latency_ms} /> - <.timing_legend segments={@segments} event={@event} /> + <.timing_bar timing_data={@timing_data} /> + <.timing_legend timing_data={@timing_data} />
@@ -404,80 +457,349 @@ defmodule LightningWeb.ChannelRequestLive.Show do defp compute_timing_segments(event) do cond do - is_nil(event.latency_ms) -> + is_nil(event.latency_us) -> nil - not is_nil(event.request_send_us) and not is_nil(event.ttfb_ms) and - not is_nil(event.response_duration_us) -> - upload_ms = event.request_send_us / 1000 - processing_ms = max(event.ttfb_ms - upload_ms, 0) - download_ms = event.response_duration_us / 1000 - - [ - %{label: "Upload", ms: upload_ms, color: "bg-blue-400"}, - %{label: "Processing", ms: processing_ms, color: "bg-secondary-300"}, - %{label: "Download", ms: download_ms, color: "bg-green-400"} - ] - - not is_nil(event.ttfb_ms) -> - download_ms = max(event.latency_ms - event.ttfb_ms, 0) + has_finch_phases?(event) -> + compute_full_timing(event) - [ - %{label: "TTFB", ms: event.ttfb_ms, color: "bg-blue-400"}, - %{label: "Download", ms: download_ms, color: "bg-green-400"} - ] + not is_nil(event.ttfb_us) -> + compute_ttfb_timing(event) true -> - [%{label: "Total", ms: event.latency_ms, color: "bg-blue-400"}] + compute_minimal_timing(event) end end + defp has_finch_phases?(event) do + not is_nil(event.request_send_us) and not is_nil(event.ttfb_us) and + not is_nil(event.response_duration_us) + end + + defp compute_full_timing(event) do + queue_us = event.queue_us || 0 + connect_us = event.connect_us || 0 + send_us = event.request_send_us + recv_us = event.response_duration_us + ttfb_us = event.ttfb_us + latency_us = event.latency_us + + wait_us = max(ttfb_us - queue_us - connect_us - send_us, 0) + + inner_sum = queue_us + connect_us + send_us + wait_us + recv_us + + {overhead_left_pct, overhead_right_pct} = + compute_overhead(inner_sum, latency_us) + + reused = + event.reused_connection == true and + (connect_us == 0 or is_nil(event.connect_us)) + + segments = + [] + |> maybe_add_segment(queue_us > 0, %{ + label: "Queue", + us: queue_us, + color: "bg-amber-300", + text_color: "text-amber-900" + }) + |> maybe_add_connect_segment(connect_us, reused) + |> Kernel.++([ + %{ + label: "Send", + us: send_us, + color: "bg-blue-400", + text_color: "text-blue-900" + }, + %{ + label: "Processing", + us: wait_us, + color: "bg-gray-300", + text_color: "text-gray-700" + }, + %{ + label: "Recv", + us: recv_us, + color: "bg-green-400", + text_color: "text-green-900" + } + ]) + + %{ + segments: segments, + total_us: latency_us, + ttfb_us: ttfb_us, + overhead_left_pct: overhead_left_pct, + overhead_right_pct: overhead_right_pct, + tier: :full + } + end + + defp compute_ttfb_timing(event) do + download_us = max(event.latency_us - event.ttfb_us, 0) + + segments = [ + %{ + label: "TTFB", + us: event.ttfb_us, + color: "bg-blue-400", + text_color: "text-blue-900" + }, + %{ + label: "Download", + us: download_us, + color: "bg-green-400", + text_color: "text-green-900" + } + ] + + %{ + segments: segments, + total_us: event.latency_us, + ttfb_us: event.ttfb_us, + overhead_left_pct: 0, + overhead_right_pct: 0, + tier: :partial + } + end + + defp compute_minimal_timing(event) do + segments = [ + %{ + label: "Total", + us: event.latency_us, + color: "bg-blue-400", + text_color: "text-blue-900" + } + ] + + %{ + segments: segments, + total_us: event.latency_us, + ttfb_us: nil, + overhead_left_pct: 0, + overhead_right_pct: 0, + tier: :minimal + } + end + + defp compute_overhead(inner_sum, latency_us) + when inner_sum >= latency_us or latency_us == 0 do + {0, 0} + end + + defp compute_overhead(inner_sum, latency_us) do + gap_pct = (latency_us - inner_sum) / latency_us * 100 + half = Float.round(gap_pct / 2, 1) + {half, half} + end + + defp maybe_add_segment(segments, true, segment), + do: segments ++ [segment] + + defp maybe_add_segment(segments, false, _segment), do: segments + + defp maybe_add_connect_segment(segments, _connect_us, true) do + segments ++ + [ + %{ + label: "Connect", + us: 0, + color: "bg-orange-400", + text_color: "text-orange-900", + badge: "(reused)" + } + ] + end + + defp maybe_add_connect_segment(segments, connect_us, false) + when connect_us > 0 do + segments ++ + [ + %{ + label: "Connect", + us: connect_us, + color: "bg-orange-400", + text_color: "text-orange-900" + } + ] + end + + defp maybe_add_connect_segment(segments, _connect_us, false), + do: segments + + @hatch_gradient_style IO.iodata_to_binary([ + "background: repeating-linear-gradient(", + "-45deg, ", + "rgba(156, 163, 175, 0.18) 0px, ", + "rgba(156, 163, 175, 0.18) 3px, ", + "rgba(209, 213, 219, 0.55) 3px, ", + "rgba(209, 213, 219, 0.55) 6px)" + ]) + defp timing_bar(assigns) do - total = Enum.reduce(assigns.segments, 0, fn s, acc -> acc + s.ms end) - total = if total == 0, do: 1, else: total + segments = assigns.timing_data.segments + total_us = assigns.timing_data.total_us + ttfb_us = assigns.timing_data.ttfb_us + + inner_total = + Enum.reduce(segments, 0, fn s, acc -> acc + s.us end) + + inner_total = if inner_total == 0, do: 1, else: inner_total segments_with_pct = - Enum.map(assigns.segments, fn s -> - Map.put(s, :pct, max(Float.round(s.ms / total * 100, 1), 1)) + Enum.map(segments, fn s -> + Map.put( + s, + :pct, + max(Float.round(s.us / inner_total * 100, 1), 0.5) + ) end) - assigns = assign(assigns, segments: segments_with_pct) + ttfb_pct = + if ttfb_us && ttfb_us > 0 && total_us > 0 do + Float.round(ttfb_us / total_us * 100, 1) + else + nil + end + + tier = assigns.timing_data.tier + show_overhead = tier == :full + seg_count = length(segments_with_pct) + + assigns = + assign(assigns, + segments: segments_with_pct, + seg_count: seg_count, + total_us: total_us, + ttfb_us: ttfb_us, + ttfb_pct: ttfb_pct, + show_overhead: show_overhead, + hatch_style: @hatch_gradient_style + ) ~H""" -
- 0 ms -
+
+
+ <%!-- Outer bar: hatch background with inner segments on top --%>
- 10} class="truncate px-1"> - {format_ms(seg.ms)} - + <%!-- Inner phase segments --%> +
+
+ + {seg.badge} + + + {format_segment_label(seg)} + +
+
+ <%!-- TTFB marker line --%> +
+
+
+ +
+
+ <.icon name="hero-arrow-up-mini" class="h-3 w-3 text-secondary-500" /> + + TTFB: {format_us(@ttfb_us)} ms + +
- - {format_ms(@total_ms)} ms - + +
+ 0 ms + + {format_us(@total_us)} ms + +
""" end + defp format_segment_label(%{us: us} = seg) do + ms = us / 1000 + + cond do + Map.has_key?(seg, :badge) -> "" + us == 0 -> "" + ms >= 1000 -> "#{Float.round(ms / 1000, 1)}s" + true -> "#{format_us(us)}ms" + end + end + defp timing_legend(assigns) do + timing_data = assigns.timing_data + segments = timing_data.segments + ttfb_us = timing_data.ttfb_us + + show_overhead = timing_data.tier == :full + + assigns = + assign(assigns, + segments: segments, + ttfb_us: ttfb_us, + show_overhead: show_overhead, + swatch_style: @hatch_gradient_style + ) + ~H""" -
-
- - {seg.label}: {format_ms(seg.ms)} ms -
-
- TTFB: {@event.ttfb_ms} ms -
+
+ + + + {seg.label} + + + + + Proxy overhead + + + TTFB: {format_us(@ttfb_us)} ms +
""" end @@ -532,6 +854,7 @@ defmodule LightningWeb.ChannelRequestLive.Show do assigns = assigns |> assign_new(:title_right, fn -> [] end) + |> assign_new(:padded, fn -> true end) ~H"""
@@ -556,106 +879,143 @@ defmodule LightningWeb.ChannelRequestLive.Show do ]} /> -
+
{render_slot(@inner_block)}
""" end - defp headers_table(assigns) do + defp sub_section(assigns) do + assigns = assign_new(assigns, :title_right, fn -> [] end) + ~H""" -
-

- Headers -

- - - - - - - -
- {name} - - {value} -
+
+ +
+ {render_slot(@inner_block)} +
""" end + defp headers_table(assigns) do + ~H""" + + + + + + + +
+ {name} + + {value} +
+ """ + end + defp body_viewer(assigns) do content_type = extract_content_type(assigns.headers) is_binary_content = content_type && !text_content_type?(content_type) + no_body = + assigns.body_size == 0 and + (is_nil(assigns.body_preview) or assigns.body_preview == "") + assigns = assign(assigns, content_type: content_type, - is_binary_content: is_binary_content + is_binary_content: is_binary_content, + no_body: no_body ) ~H""" <%= cond do %> - <% is_nil(@body_preview) and is_nil(@body_size) -> %> - <%!-- Hide entirely when both preview and size are nil --%> + <% @no_body -> %> +
+ <.icon name="hero-document" class="h-6 w-6 mb-1 text-secondary-300" /> + No body +
<% @is_binary_content -> %> -
+
{format_content_type_label(@content_type)} {format_bytes(@body_size)} - {@body_hash} + SHA256: {@body_hash}
<% is_nil(@body_preview) -> %> -
+
Body not captured ({format_bytes(@body_size)})
<% true -> %> -
-
- - {format_content_type_label(@content_type)} - - <.copy_icon_button - id={"#{@id}-copy"} - value={@body_preview} - title="Copy body" - /> - byte_size(@body_preview) - } - class="text-xs text-secondary-400" - > - Preview: {format_bytes(byte_size(@body_preview))} of {format_bytes( - @body_size - )} - +
+
+
+ + {format_content_type_label(@content_type)} + + <.copy_icon_button + id={"#{@id}-copy"} + value={@body_preview} + title="Copy body" + size={3} + class="p-1 bg-white/80 rounded" + /> +
+
{@body_preview}
-
{@body_preview}
- {String.slice(@body_hash, 0..11)} + + SHA256: {String.slice(@body_hash, 0..15)}... + <.copy_icon_button id={"#{@id}-hash-copy"} value={@body_hash} @@ -663,6 +1023,16 @@ defmodule LightningWeb.ChannelRequestLive.Show do size={3} />
+
byte_size(@body_preview) + } + class="mt-1 text-[11px] text-secondary-400" + > + Preview: {format_bytes(byte_size(@body_preview))} of {format_bytes( + @body_size + )} +
<% end %> """ @@ -702,6 +1072,7 @@ defmodule LightningWeb.ChannelRequestLive.Show do attr :value, :string, required: true attr :title, :string, default: "Copy" attr :size, :integer, default: 4 + attr :class, :string, default: nil defp copy_icon_button(assigns) do ~H""" @@ -709,7 +1080,10 @@ defmodule LightningWeb.ChannelRequestLive.Show do id={@id} phx-hook="Copy" data-content={@value} - class="copy-btn text-secondary-400 hover:text-secondary-600 transition-colors shrink-0 cursor-pointer" + class={[ + "copy-btn text-secondary-400 hover:text-secondary-600 transition-colors shrink-0 cursor-pointer", + @class + ]} title={@title} > <.icon name="hero-clipboard" class={"h-#{@size} w-#{@size}"} /> @@ -741,14 +1115,16 @@ defmodule LightningWeb.ChannelRequestLive.Show do defp format_bytes(bytes), do: "#{Float.round(bytes / 1_048_576, 1)} MB" - defp format_ms(ms) when is_float(ms) do + defp format_us(nil), do: "—" + + defp format_us(us) when is_number(us) do + ms = us / 1000 + if ms == Float.round(ms), do: trunc(ms) |> to_string(), else: Float.round(ms, 1) |> to_string() end - defp format_ms(ms), do: to_string(ms) - defp extract_content_type(nil), do: nil defp extract_content_type(headers) do diff --git a/test/lightning_web/live/channel_request_live/show_test.exs b/test/lightning_web/live/channel_request_live/show_test.exs index 8a2c3d026f5..48408bbfe1e 100644 --- a/test/lightning_web/live/channel_request_live/show_test.exs +++ b/test/lightning_web/live/channel_request_live/show_test.exs @@ -123,7 +123,7 @@ defmodule LightningWeb.ChannelRequestLive.ShowTest do insert(:channel_error_event, channel_request: request, error_message: "econnrefused", - latency_ms: 100 + latency_us: 100_000 ) {:ok, view, _html} = live(conn, detail_path(project, request)) @@ -157,79 +157,135 @@ defmodule LightningWeb.ChannelRequestLive.ShowTest do setup [:register_and_log_in_user, :create_project_for_current_user] setup :enable_experimental_features - test "renders three-segment bar when all timing fields present", %{ - conn: conn, - project: project - } do - {request, _channel, _snapshot} = create_channel_request(project) + test "renders full nested timeline with all Finch phases, overhead, and reused connection", + %{conn: conn, project: project} do + # --- Full phases with overhead --- + {req1, _ch1, _snap1} = create_channel_request(project) + # inner_sum = 2+15+5+158+65 = 245ms, latency = 260ms => 15ms overhead insert(:channel_event, - channel_request: request, - request_send_us: 5000, - ttfb_ms: 280, - response_duration_us: 65000, - latency_ms: 350 + channel_request: req1, + queue_us: 2_000, + connect_us: 15_000, + request_send_us: 5_000, + ttfb_us: 180_000, + response_duration_us: 65_000, + latency_us: 260_000 ) - {:ok, view, _html} = live(conn, detail_path(project, request)) - html = render(view) + {:ok, view1, _html} = live(conn, detail_path(project, req1)) + html1 = render(view1) - assert html =~ "timing-section" - assert html =~ "350" - assert html =~ "280" + # Timing section present with bookend labels + assert html1 =~ ~s(id="timing-section") + assert html1 =~ "0 ms" + assert html1 =~ "260 ms" + + # Phase segment title attributes (tooltip text) + assert html1 =~ ~s(title="Queue: 2 ms") + assert html1 =~ ~s(title="Connect: 15 ms") + assert html1 =~ ~s(title="Send: 5 ms") + assert html1 =~ ~s(title="Processing: 158 ms") + assert html1 =~ ~s(title="Recv: 65 ms") + + # TTFB marker and legend with overhead swatch + assert html1 =~ "TTFB: 180 ms" + assert html1 =~ "Proxy overhead" + + # --- Reused connection --- + {req2, _ch2, _snap2} = create_channel_request(project) + + insert(:channel_event, + channel_request: req2, + reused_connection: true, + queue_us: 1_000, + connect_us: 0, + request_send_us: 4_000, + ttfb_us: 120_000, + response_duration_us: 30_000, + latency_us: 155_000 + ) + + {:ok, view2, _html} = live(conn, detail_path(project, req2)) + html2 = render(view2) + + assert html2 =~ ~s(id="timing-section") + assert html2 =~ "(reused)" + + # --- Processing segment from nil queue/connect --- + {req3, _ch3, _snap3} = create_channel_request(project) + + # wait = ttfb - 0 - 0 - send = 200k - 10k = 190k + insert(:channel_event, + channel_request: req3, + queue_us: nil, + connect_us: nil, + request_send_us: 10_000, + ttfb_us: 200_000, + response_duration_us: 50_000, + latency_us: 260_000 + ) + + {:ok, view3, _html} = live(conn, detail_path(project, req3)) + html3 = render(view3) + + assert html3 =~ ~s(title="Processing: 190 ms") end - test "falls back gracefully when timing fields are partially nil", %{ - conn: conn, - project: project - } do - # Two-segment fallback: per-direction durations nil, TTFB + latency present + test "degrades gracefully through partial and minimal tiers", + %{conn: conn, project: project} do + # Partial tier: TTFB + latency only => TTFB/Download segments {req1, _ch1, _snap1} = create_channel_request(project) insert(:channel_event, channel_request: req1, request_send_us: nil, response_duration_us: nil, - ttfb_ms: 280, - latency_ms: 350 + ttfb_us: 280_000, + latency_us: 350_000 ) {:ok, view1, _html} = live(conn, detail_path(project, req1)) html1 = render(view1) - assert html1 =~ "350" - assert html1 =~ "280" - # Single bar fallback: only latency_ms + assert html1 =~ ~s(title="TTFB: 280 ms") + assert html1 =~ ~s(title="Download: 70 ms") + assert html1 =~ "350 ms" + refute html1 =~ "Proxy overhead" + + # Minimal tier: only latency_us => single Total bar {req2, _ch2, _snap2} = create_channel_request(project) insert(:channel_event, channel_request: req2, request_send_us: nil, response_duration_us: nil, - ttfb_ms: nil, - latency_ms: 420 + ttfb_us: nil, + latency_us: 420_000 ) {:ok, view2, _html} = live(conn, detail_path(project, req2)) html2 = render(view2) - assert html2 =~ "420" + + assert html2 =~ ~s(title="Total: 420 ms") + assert html2 =~ "420 ms" end test "shows single bar for transport errors, hidden for credential errors", %{conn: conn, project: project} do - # Transport error: timing section visible with single bar {req_transport, _ch1, _snap1} = create_channel_request(project, state: :timeout) insert(:channel_error_event, channel_request: req_transport, error_message: "response_timeout", - latency_ms: 30000 + latency_us: 30_000_000 ) {:ok, view1, _html} = live(conn, detail_path(project, req_transport)) html1 = render(view1) - assert html1 =~ "30000" or html1 =~ "30,000" + assert html1 =~ ~s(id="timing-section") + assert html1 =~ ~s(title="Total: 30000 ms") # Credential error: timing section hidden entirely {req_cred, _ch2, _snap2} = From 9675a3cad161b0effaccad5ded082f1338abcd94 Mon Sep 17 00:00:00 2001 From: Stuart Corbishley Date: Fri, 10 Apr 2026 16:20:35 +0200 Subject: [PATCH 08/23] Fix mock_destination body size calculation to account for JSON envelope overhead --- benchmarking/channels/mock_destination.exs | 24 ++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/benchmarking/channels/mock_destination.exs b/benchmarking/channels/mock_destination.exs index fc2d7b86283..3ca4d2ba353 100644 --- a/benchmarking/channels/mock_destination.exs +++ b/benchmarking/channels/mock_destination.exs @@ -143,21 +143,33 @@ defmodule MockDestination.Body do """ def generate(body_size) when body_size <= 1024 do + # Build the envelope once with an empty padding to measure overhead. + envelope = + Jason.encode!(%{ + ok: true, + server: "mock_destination", + timestamp: DateTime.to_iso8601(DateTime.utc_now()), + padding: "" + }) + + overhead = byte_size(envelope) + padding_len = max(body_size - overhead, 0) + json = Jason.encode!(%{ ok: true, server: "mock_destination", timestamp: DateTime.to_iso8601(DateTime.utc_now()), - padding: String.duplicate("x", max(body_size - 80, 0)) + padding: String.duplicate("x", padding_len) }) - # Trim or pad to reach the target size exactly. - byte_size = byte_size(json) + # Fine-tune to the exact target size. + actual = byte_size(json) cond do - byte_size == body_size -> json - byte_size > body_size -> binary_part(json, 0, body_size) - true -> json <> String.duplicate(" ", body_size - byte_size) + actual == body_size -> json + actual > body_size -> binary_part(json, 0, body_size) + true -> json <> String.duplicate(" ", body_size - actual) end end From 2610ddad18ae13430a8741d90597173f56d3036a Mon Sep 17 00:00:00 2001 From: Stuart Corbishley Date: Fri, 10 Apr 2026 17:00:03 +0200 Subject: [PATCH 09/23] Extract channel request show page components into separate modules (#4541) Split the 1158-line show.ex into four focused files to improve maintainability: helpers.ex (shared pure functions), components.ex (reusable display components), timing.ex (timing visualization), and show.ex (LiveView lifecycle + section composition). --- .../live/channel_request_live/components.ex | 376 ++++++++ .../live/channel_request_live/helpers.ex | 83 +- .../live/channel_request_live/show.ex | 811 +----------------- .../live/channel_request_live/timing.ex | 396 +++++++++ 4 files changed, 860 insertions(+), 806 deletions(-) create mode 100644 lib/lightning_web/live/channel_request_live/components.ex create mode 100644 lib/lightning_web/live/channel_request_live/timing.ex diff --git a/lib/lightning_web/live/channel_request_live/components.ex b/lib/lightning_web/live/channel_request_live/components.ex new file mode 100644 index 00000000000..dbefce3687d --- /dev/null +++ b/lib/lightning_web/live/channel_request_live/components.ex @@ -0,0 +1,376 @@ +defmodule LightningWeb.ChannelRequestLive.Components do + @moduledoc """ + Reusable function components for the channel request detail page. + + Provides layout primitives (disclosure sections), HTTP display atoms + (method badges, status codes), and content viewers (headers, body) + used across multiple sections. + """ + + use LightningWeb, :component + + import LightningWeb.RunLive.Components, only: [channel_state_pill: 1] + + alias LightningWeb.ChannelRequestLive.Helpers + alias Phoenix.LiveView.JS + + # --- Layout primitives --- + + def disclosure_section(assigns) do + assigns = + assigns + |> assign_new(:title_right, fn -> [] end) + |> assign_new(:padded, fn -> true end) + + ~H""" +
+ +
+ {render_slot(@inner_block)} +
+
+ """ + end + + def sub_section(assigns) do + assigns = assign_new(assigns, :title_right, fn -> [] end) + + ~H""" +
+ +
+ {render_slot(@inner_block)} +
+
+ """ + end + + # --- HTTP display atoms --- + + def method_badge(assigns) do + color_class = + case assigns.method do + "GET" -> "bg-blue-100 text-blue-800" + "POST" -> "bg-green-100 text-green-800" + "PUT" -> "bg-amber-100 text-amber-800" + "PATCH" -> "bg-amber-100 text-amber-800" + "DELETE" -> "bg-red-100 text-red-800" + _ -> "bg-secondary-100 text-secondary-800" + end + + assigns = assign(assigns, color_class: color_class) + + ~H""" + + {@method || "—"} + + """ + end + + def request_path_display(assigns) do + ~H""" + + {@event && @event.request_path} + + ?{@event.request_query_string} + + + """ + end + + def status_code_display(assigns) do + color_class = + case assigns.status do + s when is_integer(s) and s >= 200 and s < 300 -> + "text-green-700 bg-green-50" + + s when is_integer(s) and s >= 300 and s < 400 -> + "text-blue-700 bg-blue-50" + + s when is_integer(s) and s >= 400 and s < 500 -> + "text-amber-700 bg-amber-50" + + s when is_integer(s) and s >= 500 -> + "text-red-700 bg-red-50" + + _ -> + "text-secondary-400" + end + + assigns = assign(assigns, color_class: color_class) + + ~H""" + + {if @status, do: to_string(@status), else: "—"} + + """ + end + + def status_code_badge(assigns) do + color_class = + case assigns.status do + s when s >= 200 and s < 300 -> "bg-green-100 text-green-700" + s when s >= 300 and s < 400 -> "bg-blue-100 text-blue-700" + s when s >= 400 and s < 500 -> "bg-amber-100 text-amber-700" + s when s >= 500 -> "bg-red-100 text-red-700" + _ -> "bg-secondary-100 text-secondary-700" + end + + assigns = assign(assigns, color_class: color_class) + + ~H""" + + {@status} + + """ + end + + def state_pill_with_tooltip(assigns) do + ~H""" + <%= if @state == :timeout and @error_message do %> + + <.channel_state_pill state={@state} /> + + <% else %> + <.channel_state_pill state={@state} /> + <% end %> + """ + end + + def response_empty(assigns) do + {icon, label} = + case assigns.type do + :transport -> + {"hero-exclamation-triangle", "No response received"} + + :credential -> + {"hero-lock-closed", "Request not sent — credential error"} + end + + assigns = assign(assigns, icon: icon, label: label) + + ~H""" +
+
+ <.icon name={@icon} class="h-8 w-8 mb-3 text-secondary-400" /> +

{@label}

+

{@human_message}

+ + {@error_code} + +
+
+ """ + end + + # --- Content display --- + + def headers_table(assigns) do + ~H""" + + + + + + + +
+ {name} + + {value} +
+ """ + end + + def body_viewer(assigns) do + content_type = Helpers.extract_content_type(assigns.headers) + is_binary_content = content_type && !Helpers.text_content_type?(content_type) + + no_body = + assigns.body_size == 0 and + (is_nil(assigns.body_preview) or assigns.body_preview == "") + + assigns = + assign(assigns, + content_type: content_type, + is_binary_content: is_binary_content, + no_body: no_body + ) + + ~H""" + <%= cond do %> + <% @no_body -> %> +
+ <.icon name="hero-document" class="h-6 w-6 mb-1 text-secondary-300" /> + No body +
+ <% @is_binary_content -> %> +
+ + {Helpers.format_content_type_label(@content_type)} + + + {Helpers.format_bytes(@body_size)} + + + SHA256: {@body_hash} + +
+ <% is_nil(@body_preview) -> %> +
+ Body not captured + + ({Helpers.format_bytes(@body_size)}) + +
+ <% true -> %> +
+
+
+ + {Helpers.format_content_type_label(@content_type)} + + <.copy_icon_button + id={"#{@id}-copy"} + value={@body_preview} + title="Copy body" + size={3} + class="p-1 bg-white/80 rounded" + /> +
+
{@body_preview}
+
+
+ + SHA256: {String.slice(@body_hash, 0..15)}... + + <.copy_icon_button + id={"#{@id}-hash-copy"} + value={@body_hash} + title="Copy hash" + size={3} + /> +
+
byte_size(@body_preview) + } + class="mt-1 text-[11px] text-secondary-400" + > + Preview: {Helpers.format_bytes(byte_size(@body_preview))} of {Helpers.format_bytes( + @body_size + )} +
+
+ <% end %> + """ + end + + attr :id, :string, required: true + attr :value, :string, required: true + attr :title, :string, default: "Copy" + attr :size, :integer, default: 4 + attr :class, :string, default: nil + + def copy_icon_button(assigns) do + ~H""" + + """ + end + + def section_size_badge(assigns) do + ~H""" + + {Helpers.format_bytes(@size)} + + """ + end +end diff --git a/lib/lightning_web/live/channel_request_live/helpers.ex b/lib/lightning_web/live/channel_request_live/helpers.ex index 0380cd2c6a7..637db414045 100644 --- a/lib/lightning_web/live/channel_request_live/helpers.ex +++ b/lib/lightning_web/live/channel_request_live/helpers.ex @@ -1,12 +1,14 @@ defmodule LightningWeb.ChannelRequestLive.Helpers do @moduledoc """ - Error humanization for channel request detail page. + Shared helper functions for the channel request detail page. - Provides `humanize_error/1` to convert classified error codes into - human-readable descriptions, and `error_category/1` to classify errors - as `:transport` or `:credential`. + Pure functions only — no templates. Provides error humanization, + formatting utilities, and data extraction used across multiple + component modules. """ + # --- Error humanization --- + @transport_errors %{ "nxdomain" => "DNS lookup failed — the destination hostname could not be resolved", @@ -79,4 +81,77 @@ defmodule LightningWeb.ChannelRequestLive.Helpers do nil end end + + # --- Data extraction --- + + @doc """ + Extracts the primary event from a channel request's events list. + Prefers `:destination_response`, falls back to `:error`. + """ + def primary_event(channel_request) do + channel_request.channel_events + |> Enum.find(&(&1.type == :destination_response)) || + Enum.find(channel_request.channel_events, &(&1.type == :error)) + end + + # --- Formatting --- + + def format_auth_type(nil), do: "None" + def format_auth_type("api"), do: "API key" + def format_auth_type("basic"), do: "Basic auth" + def format_auth_type(type), do: type + + def format_bytes(nil), do: "—" + + def format_bytes(bytes) when bytes < 1024, + do: "#{bytes} B" + + def format_bytes(bytes) when bytes < 1_048_576, + do: "#{Float.round(bytes / 1024, 1)} KB" + + def format_bytes(bytes), + do: "#{Float.round(bytes / 1_048_576, 1)} MB" + + def format_us(nil), do: "—" + + def format_us(us) when is_number(us) do + ms = us / 1000 + + if ms == Float.round(ms), + do: trunc(ms) |> to_string(), + else: Float.round(ms, 1) |> to_string() + end + + # --- Content type utilities --- + + def extract_content_type(nil), do: nil + + def extract_content_type(headers) do + headers + |> Enum.find(fn [name, _] -> String.downcase(name) == "content-type" end) + |> case do + [_, value] -> value + nil -> nil + end + end + + def text_content_type?(ct) do + String.contains?(ct, "text/") or + String.contains?(ct, "json") or + String.contains?(ct, "xml") or + String.contains?(ct, "javascript") or + String.contains?(ct, "html") + end + + def format_content_type_label(ct) when is_binary(ct) do + cond do + String.contains?(ct, "json") -> "JSON" + String.contains?(ct, "xml") -> "XML" + String.contains?(ct, "html") -> "HTML" + String.contains?(ct, "text/") -> "TEXT" + true -> ct + end + end + + def format_content_type_label(_), do: nil end diff --git a/lib/lightning_web/live/channel_request_live/show.ex b/lib/lightning_web/live/channel_request_live/show.ex index 5675e75841f..6df86372e07 100644 --- a/lib/lightning_web/live/channel_request_live/show.ex +++ b/lib/lightning_web/live/channel_request_live/show.ex @@ -1,13 +1,14 @@ defmodule LightningWeb.ChannelRequestLive.Show do use LightningWeb, :live_view - import LightningWeb.RunLive.Components, only: [channel_state_pill: 1] + import LightningWeb.ChannelRequestLive.Components + + import LightningWeb.ChannelRequestLive.Timing, + only: [timing_section: 1] alias Lightning.Channels alias LightningWeb.ChannelRequestLive.Helpers - alias Phoenix.LiveView.JS - on_mount {LightningWeb.Hooks, :project_scope} @impl true @@ -61,7 +62,7 @@ defmodule LightningWeb.ChannelRequestLive.Show do <% cr = @channel_request %> - <% event = primary_event(cr) %> + <% event = Helpers.primary_event(cr) %> <% error_cat = event && event.error_message && Helpers.error_category(event.error_message) %>
@@ -143,7 +144,7 @@ defmodule LightningWeb.ChannelRequestLive.Show do
<.icon name="hero-shield-check" class="h-4 w-4 text-secondary-400" /> - {format_auth_type(@channel_request.client_auth_type)} + {Helpers.format_auth_type(@channel_request.client_auth_type)}
@@ -168,7 +169,7 @@ defmodule LightningWeb.ChannelRequestLive.Show do
{if @event && @event.latency_us, - do: "#{format_us(@event.latency_us)} ms", + do: "#{Helpers.format_us(@event.latency_us)} ms", else: "—"}
@@ -192,91 +193,6 @@ defmodule LightningWeb.ChannelRequestLive.Show do """ end - defp method_badge(assigns) do - color_class = - case assigns.method do - "GET" -> "bg-blue-100 text-blue-800" - "POST" -> "bg-green-100 text-green-800" - "PUT" -> "bg-amber-100 text-amber-800" - "PATCH" -> "bg-amber-100 text-amber-800" - "DELETE" -> "bg-red-100 text-red-800" - _ -> "bg-secondary-100 text-secondary-800" - end - - assigns = assign(assigns, color_class: color_class) - - ~H""" - - {@method || "—"} - - """ - end - - defp request_path_display(assigns) do - ~H""" - - {@event && @event.request_path} - - ?{@event.request_query_string} - - - """ - end - - defp status_code_display(assigns) do - color_class = - case assigns.status do - s when is_integer(s) and s >= 200 and s < 300 -> - "text-green-700 bg-green-50" - - s when is_integer(s) and s >= 300 and s < 400 -> - "text-blue-700 bg-blue-50" - - s when is_integer(s) and s >= 400 and s < 500 -> - "text-amber-700 bg-amber-50" - - s when is_integer(s) and s >= 500 -> - "text-red-700 bg-red-50" - - _ -> - "text-secondary-400" - end - - assigns = assign(assigns, color_class: color_class) - - ~H""" - - {if @status, do: to_string(@status), else: "—"} - - """ - end - - defp state_pill_with_tooltip(assigns) do - ~H""" - <%= if @state == :timeout and @error_message do %> - - <.channel_state_pill state={@state} /> - - <% else %> - <.channel_state_pill state={@state} /> - <% end %> - """ - end - # --- Request Section --- defp request_section(assigns) do @@ -318,7 +234,7 @@ defmodule LightningWeb.ChannelRequestLive.Show do :if={@event.request_body_size && @event.request_body_size > 0} class="text-[11px] text-secondary-400 font-mono" > - {format_bytes(@event.request_body_size)} + {Helpers.format_bytes(@event.request_body_size)} <.body_viewer @@ -386,7 +302,7 @@ defmodule LightningWeb.ChannelRequestLive.Show do :if={@event.response_body_size && @event.response_body_size > 0} class="text-[11px] text-secondary-400 font-mono" > - {format_bytes(@event.response_body_size)} + {Helpers.format_bytes(@event.response_body_size)} <.body_viewer @@ -403,407 +319,6 @@ defmodule LightningWeb.ChannelRequestLive.Show do """ end - defp response_empty(assigns) do - {icon, label} = - case assigns.type do - :transport -> - {"hero-exclamation-triangle", "No response received"} - - :credential -> - {"hero-lock-closed", "Request not sent — credential error"} - end - - assigns = assign(assigns, icon: icon, label: label) - - ~H""" -
-
- <.icon name={@icon} class="h-8 w-8 mb-3 text-secondary-400" /> -

{@label}

-

{@human_message}

- - {@error_code} - -
-
- """ - end - - # --- Timing Section --- - - defp timing_section(assigns) do - event = assigns.event - - timing_data = - if event do - compute_timing_segments(event) - else - nil - end - - assigns = assign(assigns, timing_data: timing_data, event: event) - - ~H""" -
- <.disclosure_section id="timing-section-disclosure" title="Timing" open={true}> -
- <.timing_bar timing_data={@timing_data} /> - <.timing_legend timing_data={@timing_data} /> -
- -
- """ - end - - defp compute_timing_segments(event) do - cond do - is_nil(event.latency_us) -> - nil - - has_finch_phases?(event) -> - compute_full_timing(event) - - not is_nil(event.ttfb_us) -> - compute_ttfb_timing(event) - - true -> - compute_minimal_timing(event) - end - end - - defp has_finch_phases?(event) do - not is_nil(event.request_send_us) and not is_nil(event.ttfb_us) and - not is_nil(event.response_duration_us) - end - - defp compute_full_timing(event) do - queue_us = event.queue_us || 0 - connect_us = event.connect_us || 0 - send_us = event.request_send_us - recv_us = event.response_duration_us - ttfb_us = event.ttfb_us - latency_us = event.latency_us - - wait_us = max(ttfb_us - queue_us - connect_us - send_us, 0) - - inner_sum = queue_us + connect_us + send_us + wait_us + recv_us - - {overhead_left_pct, overhead_right_pct} = - compute_overhead(inner_sum, latency_us) - - reused = - event.reused_connection == true and - (connect_us == 0 or is_nil(event.connect_us)) - - segments = - [] - |> maybe_add_segment(queue_us > 0, %{ - label: "Queue", - us: queue_us, - color: "bg-amber-300", - text_color: "text-amber-900" - }) - |> maybe_add_connect_segment(connect_us, reused) - |> Kernel.++([ - %{ - label: "Send", - us: send_us, - color: "bg-blue-400", - text_color: "text-blue-900" - }, - %{ - label: "Processing", - us: wait_us, - color: "bg-gray-300", - text_color: "text-gray-700" - }, - %{ - label: "Recv", - us: recv_us, - color: "bg-green-400", - text_color: "text-green-900" - } - ]) - - %{ - segments: segments, - total_us: latency_us, - ttfb_us: ttfb_us, - overhead_left_pct: overhead_left_pct, - overhead_right_pct: overhead_right_pct, - tier: :full - } - end - - defp compute_ttfb_timing(event) do - download_us = max(event.latency_us - event.ttfb_us, 0) - - segments = [ - %{ - label: "TTFB", - us: event.ttfb_us, - color: "bg-blue-400", - text_color: "text-blue-900" - }, - %{ - label: "Download", - us: download_us, - color: "bg-green-400", - text_color: "text-green-900" - } - ] - - %{ - segments: segments, - total_us: event.latency_us, - ttfb_us: event.ttfb_us, - overhead_left_pct: 0, - overhead_right_pct: 0, - tier: :partial - } - end - - defp compute_minimal_timing(event) do - segments = [ - %{ - label: "Total", - us: event.latency_us, - color: "bg-blue-400", - text_color: "text-blue-900" - } - ] - - %{ - segments: segments, - total_us: event.latency_us, - ttfb_us: nil, - overhead_left_pct: 0, - overhead_right_pct: 0, - tier: :minimal - } - end - - defp compute_overhead(inner_sum, latency_us) - when inner_sum >= latency_us or latency_us == 0 do - {0, 0} - end - - defp compute_overhead(inner_sum, latency_us) do - gap_pct = (latency_us - inner_sum) / latency_us * 100 - half = Float.round(gap_pct / 2, 1) - {half, half} - end - - defp maybe_add_segment(segments, true, segment), - do: segments ++ [segment] - - defp maybe_add_segment(segments, false, _segment), do: segments - - defp maybe_add_connect_segment(segments, _connect_us, true) do - segments ++ - [ - %{ - label: "Connect", - us: 0, - color: "bg-orange-400", - text_color: "text-orange-900", - badge: "(reused)" - } - ] - end - - defp maybe_add_connect_segment(segments, connect_us, false) - when connect_us > 0 do - segments ++ - [ - %{ - label: "Connect", - us: connect_us, - color: "bg-orange-400", - text_color: "text-orange-900" - } - ] - end - - defp maybe_add_connect_segment(segments, _connect_us, false), - do: segments - - @hatch_gradient_style IO.iodata_to_binary([ - "background: repeating-linear-gradient(", - "-45deg, ", - "rgba(156, 163, 175, 0.18) 0px, ", - "rgba(156, 163, 175, 0.18) 3px, ", - "rgba(209, 213, 219, 0.55) 3px, ", - "rgba(209, 213, 219, 0.55) 6px)" - ]) - - defp timing_bar(assigns) do - segments = assigns.timing_data.segments - total_us = assigns.timing_data.total_us - ttfb_us = assigns.timing_data.ttfb_us - - inner_total = - Enum.reduce(segments, 0, fn s, acc -> acc + s.us end) - - inner_total = if inner_total == 0, do: 1, else: inner_total - - segments_with_pct = - Enum.map(segments, fn s -> - Map.put( - s, - :pct, - max(Float.round(s.us / inner_total * 100, 1), 0.5) - ) - end) - - ttfb_pct = - if ttfb_us && ttfb_us > 0 && total_us > 0 do - Float.round(ttfb_us / total_us * 100, 1) - else - nil - end - - tier = assigns.timing_data.tier - show_overhead = tier == :full - seg_count = length(segments_with_pct) - - assigns = - assign(assigns, - segments: segments_with_pct, - seg_count: seg_count, - total_us: total_us, - ttfb_us: ttfb_us, - ttfb_pct: ttfb_pct, - show_overhead: show_overhead, - hatch_style: @hatch_gradient_style - ) - - ~H""" -
-
- <%!-- Outer bar: hatch background with inner segments on top --%> -
- <%!-- Inner phase segments --%> -
-
- - {seg.badge} - - - {format_segment_label(seg)} - -
-
- <%!-- TTFB marker line --%> -
-
-
- -
-
- <.icon name="hero-arrow-up-mini" class="h-3 w-3 text-secondary-500" /> - - TTFB: {format_us(@ttfb_us)} ms - -
-
-
- -
- 0 ms - - {format_us(@total_us)} ms - -
-
- """ - end - - defp format_segment_label(%{us: us} = seg) do - ms = us / 1000 - - cond do - Map.has_key?(seg, :badge) -> "" - us == 0 -> "" - ms >= 1000 -> "#{Float.round(ms / 1000, 1)}s" - true -> "#{format_us(us)}ms" - end - end - - defp timing_legend(assigns) do - timing_data = assigns.timing_data - segments = timing_data.segments - ttfb_us = timing_data.ttfb_us - - show_overhead = timing_data.tier == :full - - assigns = - assign(assigns, - segments: segments, - ttfb_us: ttfb_us, - show_overhead: show_overhead, - swatch_style: @hatch_gradient_style - ) - - ~H""" -
- - - - {seg.label} - - - - - Proxy overhead - - - TTFB: {format_us(@ttfb_us)} ms - -
- """ - end - # --- Context Section --- defp context_section(assigns) do @@ -847,312 +362,4 @@ defmodule LightningWeb.ChannelRequestLive.Show do """ end - - # --- Shared Components --- - - defp disclosure_section(assigns) do - assigns = - assigns - |> assign_new(:title_right, fn -> [] end) - |> assign_new(:padded, fn -> true end) - - ~H""" -
- -
- {render_slot(@inner_block)} -
-
- """ - end - - defp sub_section(assigns) do - assigns = assign_new(assigns, :title_right, fn -> [] end) - - ~H""" -
- -
- {render_slot(@inner_block)} -
-
- """ - end - - defp headers_table(assigns) do - ~H""" - - - - - - - -
- {name} - - {value} -
- """ - end - - defp body_viewer(assigns) do - content_type = extract_content_type(assigns.headers) - is_binary_content = content_type && !text_content_type?(content_type) - - no_body = - assigns.body_size == 0 and - (is_nil(assigns.body_preview) or assigns.body_preview == "") - - assigns = - assign(assigns, - content_type: content_type, - is_binary_content: is_binary_content, - no_body: no_body - ) - - ~H""" - <%= cond do %> - <% @no_body -> %> -
- <.icon name="hero-document" class="h-6 w-6 mb-1 text-secondary-300" /> - No body -
- <% @is_binary_content -> %> -
- - {format_content_type_label(@content_type)} - - {format_bytes(@body_size)} - - SHA256: {@body_hash} - -
- <% is_nil(@body_preview) -> %> -
- Body not captured - - ({format_bytes(@body_size)}) - -
- <% true -> %> -
-
-
- - {format_content_type_label(@content_type)} - - <.copy_icon_button - id={"#{@id}-copy"} - value={@body_preview} - title="Copy body" - size={3} - class="p-1 bg-white/80 rounded" - /> -
-
{@body_preview}
-
-
- - SHA256: {String.slice(@body_hash, 0..15)}... - - <.copy_icon_button - id={"#{@id}-hash-copy"} - value={@body_hash} - title="Copy hash" - size={3} - /> -
-
byte_size(@body_preview) - } - class="mt-1 text-[11px] text-secondary-400" - > - Preview: {format_bytes(byte_size(@body_preview))} of {format_bytes( - @body_size - )} -
-
- <% end %> - """ - end - - defp status_code_badge(assigns) do - color_class = - case assigns.status do - s when s >= 200 and s < 300 -> "bg-green-100 text-green-700" - s when s >= 300 and s < 400 -> "bg-blue-100 text-blue-700" - s when s >= 400 and s < 500 -> "bg-amber-100 text-amber-700" - s when s >= 500 -> "bg-red-100 text-red-700" - _ -> "bg-secondary-100 text-secondary-700" - end - - assigns = assign(assigns, color_class: color_class) - - ~H""" - - {@status} - - """ - end - - defp section_size_badge(assigns) do - ~H""" - - {format_bytes(@size)} - - """ - end - - attr :id, :string, required: true - attr :value, :string, required: true - attr :title, :string, default: "Copy" - attr :size, :integer, default: 4 - attr :class, :string, default: nil - - defp copy_icon_button(assigns) do - ~H""" - - """ - end - - # --- Helpers --- - - defp primary_event(channel_request) do - channel_request.channel_events - |> Enum.find(&(&1.type == :destination_response)) || - Enum.find(channel_request.channel_events, &(&1.type == :error)) - end - - defp format_auth_type(nil), do: "None" - defp format_auth_type("api"), do: "API key" - defp format_auth_type("basic"), do: "Basic auth" - defp format_auth_type(type), do: type - - defp format_bytes(nil), do: "—" - - defp format_bytes(bytes) when bytes < 1024, - do: "#{bytes} B" - - defp format_bytes(bytes) when bytes < 1_048_576, - do: "#{Float.round(bytes / 1024, 1)} KB" - - defp format_bytes(bytes), - do: "#{Float.round(bytes / 1_048_576, 1)} MB" - - defp format_us(nil), do: "—" - - defp format_us(us) when is_number(us) do - ms = us / 1000 - - if ms == Float.round(ms), - do: trunc(ms) |> to_string(), - else: Float.round(ms, 1) |> to_string() - end - - defp extract_content_type(nil), do: nil - - defp extract_content_type(headers) do - headers - |> Enum.find(fn [name, _] -> String.downcase(name) == "content-type" end) - |> case do - [_, value] -> value - nil -> nil - end - end - - defp text_content_type?(ct) do - String.contains?(ct, "text/") or - String.contains?(ct, "json") or - String.contains?(ct, "xml") or - String.contains?(ct, "javascript") or - String.contains?(ct, "html") - end - - defp format_content_type_label(ct) when is_binary(ct) do - cond do - String.contains?(ct, "json") -> "JSON" - String.contains?(ct, "xml") -> "XML" - String.contains?(ct, "html") -> "HTML" - String.contains?(ct, "text/") -> "TEXT" - true -> ct - end - end - - defp format_content_type_label(_), do: nil end diff --git a/lib/lightning_web/live/channel_request_live/timing.ex b/lib/lightning_web/live/channel_request_live/timing.ex new file mode 100644 index 00000000000..dbb7a1ceb61 --- /dev/null +++ b/lib/lightning_web/live/channel_request_live/timing.ex @@ -0,0 +1,396 @@ +defmodule LightningWeb.ChannelRequestLive.Timing do + @moduledoc """ + Timing visualization components for the channel request detail page. + + Renders a segmented timing bar with TTFB marker and legend, + computing phase breakdowns from Finch timing metrics. + """ + + use LightningWeb, :component + + import LightningWeb.ChannelRequestLive.Components, + only: [disclosure_section: 1] + + alias LightningWeb.ChannelRequestLive.Helpers + + # --- Public components --- + + def timing_section(assigns) do + event = assigns.event + + timing_data = + if event do + compute_timing_segments(event) + else + nil + end + + assigns = assign(assigns, timing_data: timing_data, event: event) + + ~H""" +
+ <.disclosure_section id="timing-section-disclosure" title="Timing" open={true}> +
+ <.timing_bar timing_data={@timing_data} /> + <.timing_legend timing_data={@timing_data} /> +
+ +
+ """ + end + + # --- Timing bar --- + + @hatch_gradient_style IO.iodata_to_binary([ + "background: repeating-linear-gradient(", + "-45deg, ", + "rgba(156, 163, 175, 0.18) 0px, ", + "rgba(156, 163, 175, 0.18) 3px, ", + "rgba(209, 213, 219, 0.55) 3px, ", + "rgba(209, 213, 219, 0.55) 6px)" + ]) + + defp timing_bar(assigns) do + segments = assigns.timing_data.segments + total_us = assigns.timing_data.total_us + ttfb_us = assigns.timing_data.ttfb_us + + inner_total = + Enum.reduce(segments, 0, fn s, acc -> acc + s.us end) + + inner_total = if inner_total == 0, do: 1, else: inner_total + + segments_with_pct = + Enum.map(segments, fn s -> + Map.put( + s, + :pct, + max(Float.round(s.us / inner_total * 100, 1), 0.5) + ) + end) + + ttfb_pct = + if ttfb_us && ttfb_us > 0 && total_us > 0 do + Float.round(ttfb_us / total_us * 100, 1) + else + nil + end + + tier = assigns.timing_data.tier + show_overhead = tier == :full + seg_count = length(segments_with_pct) + + assigns = + assign(assigns, + segments: segments_with_pct, + seg_count: seg_count, + total_us: total_us, + ttfb_us: ttfb_us, + ttfb_pct: ttfb_pct, + show_overhead: show_overhead, + hatch_style: @hatch_gradient_style + ) + + ~H""" +
+
+ <%!-- Outer bar: hatch background with inner segments on top --%> +
+ <%!-- Inner phase segments --%> +
+
+ + {seg.badge} + + + {format_segment_label(seg)} + +
+
+ <%!-- TTFB marker line --%> +
+
+
+ +
+
+ <.icon name="hero-arrow-up-mini" class="h-3 w-3 text-secondary-500" /> + + TTFB: {Helpers.format_us(@ttfb_us)} ms + +
+
+
+ +
+ 0 ms + + {Helpers.format_us(@total_us)} ms + +
+
+ """ + end + + defp format_segment_label(%{us: us} = seg) do + ms = us / 1000 + + cond do + Map.has_key?(seg, :badge) -> "" + us == 0 -> "" + ms >= 1000 -> "#{Float.round(ms / 1000, 1)}s" + true -> "#{Helpers.format_us(us)}ms" + end + end + + # --- Timing legend --- + + defp timing_legend(assigns) do + timing_data = assigns.timing_data + segments = timing_data.segments + ttfb_us = timing_data.ttfb_us + + show_overhead = timing_data.tier == :full + + assigns = + assign(assigns, + segments: segments, + ttfb_us: ttfb_us, + show_overhead: show_overhead, + swatch_style: @hatch_gradient_style + ) + + ~H""" +
+ + + + {seg.label} + + + + + Proxy overhead + + + TTFB: {Helpers.format_us(@ttfb_us)} ms + +
+ """ + end + + # --- Timing computation --- + + defp compute_timing_segments(event) do + cond do + is_nil(event.latency_us) -> + nil + + has_finch_phases?(event) -> + compute_full_timing(event) + + not is_nil(event.ttfb_us) -> + compute_ttfb_timing(event) + + true -> + compute_minimal_timing(event) + end + end + + defp has_finch_phases?(event) do + not is_nil(event.request_send_us) and not is_nil(event.ttfb_us) and + not is_nil(event.response_duration_us) + end + + defp compute_full_timing(event) do + queue_us = event.queue_us || 0 + connect_us = event.connect_us || 0 + send_us = event.request_send_us + recv_us = event.response_duration_us + ttfb_us = event.ttfb_us + latency_us = event.latency_us + + wait_us = max(ttfb_us - queue_us - connect_us - send_us, 0) + + inner_sum = queue_us + connect_us + send_us + wait_us + recv_us + + {overhead_left_pct, overhead_right_pct} = + compute_overhead(inner_sum, latency_us) + + reused = + event.reused_connection == true and + (connect_us == 0 or is_nil(event.connect_us)) + + segments = + [] + |> maybe_add_segment(queue_us > 0, %{ + label: "Queue", + us: queue_us, + color: "bg-amber-300", + text_color: "text-amber-900" + }) + |> maybe_add_connect_segment(connect_us, reused) + |> Kernel.++([ + %{ + label: "Send", + us: send_us, + color: "bg-blue-400", + text_color: "text-blue-900" + }, + %{ + label: "Processing", + us: wait_us, + color: "bg-gray-300", + text_color: "text-gray-700" + }, + %{ + label: "Recv", + us: recv_us, + color: "bg-green-400", + text_color: "text-green-900" + } + ]) + + %{ + segments: segments, + total_us: latency_us, + ttfb_us: ttfb_us, + overhead_left_pct: overhead_left_pct, + overhead_right_pct: overhead_right_pct, + tier: :full + } + end + + defp compute_ttfb_timing(event) do + download_us = max(event.latency_us - event.ttfb_us, 0) + + segments = [ + %{ + label: "TTFB", + us: event.ttfb_us, + color: "bg-blue-400", + text_color: "text-blue-900" + }, + %{ + label: "Download", + us: download_us, + color: "bg-green-400", + text_color: "text-green-900" + } + ] + + %{ + segments: segments, + total_us: event.latency_us, + ttfb_us: event.ttfb_us, + overhead_left_pct: 0, + overhead_right_pct: 0, + tier: :partial + } + end + + defp compute_minimal_timing(event) do + segments = [ + %{ + label: "Total", + us: event.latency_us, + color: "bg-blue-400", + text_color: "text-blue-900" + } + ] + + %{ + segments: segments, + total_us: event.latency_us, + ttfb_us: nil, + overhead_left_pct: 0, + overhead_right_pct: 0, + tier: :minimal + } + end + + defp compute_overhead(inner_sum, latency_us) + when inner_sum >= latency_us or latency_us == 0 do + {0, 0} + end + + defp compute_overhead(inner_sum, latency_us) do + gap_pct = (latency_us - inner_sum) / latency_us * 100 + half = Float.round(gap_pct / 2, 1) + {half, half} + end + + defp maybe_add_segment(segments, true, segment), + do: segments ++ [segment] + + defp maybe_add_segment(segments, false, _segment), do: segments + + defp maybe_add_connect_segment(segments, _connect_us, true) do + segments ++ + [ + %{ + label: "Connect", + us: 0, + color: "bg-orange-400", + text_color: "text-orange-900", + badge: "(reused)" + } + ] + end + + defp maybe_add_connect_segment(segments, connect_us, false) + when connect_us > 0 do + segments ++ + [ + %{ + label: "Connect", + us: connect_us, + color: "bg-orange-400", + text_color: "text-orange-900" + } + ] + end + + defp maybe_add_connect_segment(segments, _connect_us, false), + do: segments +end From 3fd50dab3186934f097beb0052a37986f85c0d26 Mon Sep 17 00:00:00 2001 From: Stuart Corbishley Date: Thu, 23 Apr 2026 15:11:20 +0200 Subject: [PATCH 10/23] Fix FunctionClauseError when saving a channel with no changes Audit.event/7 returns :no_changes for changesets with an empty changes map; the previous Multi.insert pipe crashed when that atom was handed to Ecto.Repo.Schema.insert/4. Swap to Multi.run and pattern-match on the return so no-op saves short-circuit cleanly. Apply the same guard to create_channel/2 for uniformity. --- lib/lightning/channels.ex | 14 ++++++++++---- test/lightning/channels_test.exs | 28 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/lib/lightning/channels.ex b/lib/lightning/channels.ex index c8e00791a56..8351fb14975 100644 --- a/lib/lightning/channels.ex +++ b/lib/lightning/channels.ex @@ -194,8 +194,11 @@ defmodule Lightning.Channels do Multi.new() |> Multi.insert(:channel, changeset) - |> Multi.insert(:audit, fn %{channel: channel} -> - Audit.event("created", channel.id, actor, changeset) + |> Multi.run(:audit, fn _repo, %{channel: channel} -> + case Audit.event("created", channel.id, actor, changeset) do + :no_changes -> {:ok, :no_changes} + %Ecto.Changeset{} = audit_cs -> Repo.insert(audit_cs) + end end) |> Audit.audit_auth_method_changes(changeset, actor) |> Repo.transaction() @@ -215,8 +218,11 @@ defmodule Lightning.Channels do Multi.new() |> Multi.update(:channel, changeset, stale_error_field: :lock_version) - |> Multi.insert(:audit, fn %{channel: updated} -> - Audit.event("updated", updated.id, actor, changeset) + |> Multi.run(:audit, fn _repo, %{channel: updated} -> + case Audit.event("updated", updated.id, actor, changeset) do + :no_changes -> {:ok, :no_changes} + %Ecto.Changeset{} = audit_cs -> Repo.insert(audit_cs) + end end) |> Audit.audit_auth_method_changes(changeset, actor) |> Repo.transaction() diff --git a/test/lightning/channels_test.exs b/test/lightning/channels_test.exs index 85a71af3b94..d5507cd494c 100644 --- a/test/lightning/channels_test.exs +++ b/test/lightning/channels_test.exs @@ -242,6 +242,34 @@ defmodule Lightning.ChannelsTest do assert audit.actor_id == user.id end + test "returns {:ok, channel} when submitted with no real changes", %{ + user: user + } do + channel = insert(:channel) + + # Pass back the current values — empty changes map. Previously this + # crashed with FunctionClauseError because Audit.event/4 returned + # :no_changes and that was piped into Multi.insert/3. + assert {:ok, unchanged} = + Channels.update_channel( + channel, + %{name: channel.name, destination_url: channel.destination_url}, + actor: user + ) + + assert unchanged.id == channel.id + assert unchanged.lock_version == channel.lock_version + + # No audit row was written for the no-op save + assert [] == + Repo.all( + from a in Audit, + where: + a.item_id == ^channel.id and a.item_type == "channel" and + a.event == "updated" + ) + end + test "passing nil for destination_auth_method removes the join record", %{user: user} do project = insert(:project) From b1d1ef94f2a2fb9cb40091f4cf31ee03d313a79a Mon Sep 17 00:00:00 2001 From: Stuart Corbishley Date: Thu, 23 Apr 2026 15:12:28 +0200 Subject: [PATCH 11/23] Fix TTFB marker position on channel request timing bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The marker was positioned against latency_us while the phase segments are scaled against the sum of phase durations (inner_total). When the two disagree — e.g. rounding or latency covering post-recv overhead — the marker drifts off the segment boundary it is meant to mark. Scale the marker against inner_total so it sits at the processing/recv boundary, which matches the intuition of TTFB. --- .../live/channel_request_live/timing.ex | 4 +- .../live/channel_request_live/show_test.exs | 45 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/lib/lightning_web/live/channel_request_live/timing.ex b/lib/lightning_web/live/channel_request_live/timing.ex index dbb7a1ceb61..63efeee020a 100644 --- a/lib/lightning_web/live/channel_request_live/timing.ex +++ b/lib/lightning_web/live/channel_request_live/timing.ex @@ -70,8 +70,8 @@ defmodule LightningWeb.ChannelRequestLive.Timing do end) ttfb_pct = - if ttfb_us && ttfb_us > 0 && total_us > 0 do - Float.round(ttfb_us / total_us * 100, 1) + if ttfb_us && ttfb_us > 0 && inner_total > 0 do + Float.round(ttfb_us / inner_total * 100, 1) else nil end diff --git a/test/lightning_web/live/channel_request_live/show_test.exs b/test/lightning_web/live/channel_request_live/show_test.exs index 48408bbfe1e..e00fd9db8cf 100644 --- a/test/lightning_web/live/channel_request_live/show_test.exs +++ b/test/lightning_web/live/channel_request_live/show_test.exs @@ -271,6 +271,51 @@ defmodule LightningWeb.ChannelRequestLive.ShowTest do assert html2 =~ "420 ms" end + test "positions TTFB marker on the inner-phase scale, not latency", + %{conn: conn, project: project} do + # inner_total = queue+connect+send+wait+recv + # = 10 + 20 + 5 + (ttfb - 10 - 20 - 5) + recv + # = ttfb + recv + # With ttfb=100ms and recv=100ms => inner_total=200ms => 50% + # latency_us is deliberately different (250ms) to prove the marker + # is scaled against inner_total, not total_us. + {req_half, _ch, _snap} = create_channel_request(project) + + insert(:channel_event, + channel_request: req_half, + queue_us: 10_000, + connect_us: 20_000, + request_send_us: 5_000, + ttfb_us: 100_000, + response_duration_us: 100_000, + latency_us: 250_000 + ) + + {:ok, view_half, _html} = live(conn, detail_path(project, req_half)) + html_half = render(view_half) + + assert html_half =~ ~s(style="left: 50.0%") + + # Edge case: ttfb == inner_total => marker at 100%. + # inner_total = ttfb + recv, so set recv = 0 (response_duration_us = 0). + {req_full, _ch2, _snap2} = create_channel_request(project) + + insert(:channel_event, + channel_request: req_full, + queue_us: 10_000, + connect_us: 20_000, + request_send_us: 5_000, + ttfb_us: 100_000, + response_duration_us: 0, + latency_us: 150_000 + ) + + {:ok, view_full, _html} = live(conn, detail_path(project, req_full)) + html_full = render(view_full) + + assert html_full =~ ~s(style="left: 100.0%") + end + test "shows single bar for transport errors, hidden for credential errors", %{conn: conn, project: project} do {req_transport, _ch1, _snap1} = From e60067db84903595fe258278e4cdcb6356c70822 Mon Sep 17 00:00:00 2001 From: Stuart Corbishley Date: Thu, 23 Apr 2026 15:32:42 +0200 Subject: [PATCH 12/23] Move Enabled toggle out of Destination Credential block The toggle sat between Destination Credential and Client Credentials, visually reading as a sub-option of destination auth. Move it to sit directly under Name so the top-level channel state is clearly separated from the auth/destination configuration. Update the form structure test to match the new field order. --- .../live/channel_live/form_component.ex | 4 ++-- test/lightning_web/live/channel_live/form_test.exs | 13 ++++++------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/lib/lightning_web/live/channel_live/form_component.ex b/lib/lightning_web/live/channel_live/form_component.ex index 445756f7f0e..7042271f8c5 100644 --- a/lib/lightning_web/live/channel_live/form_component.ex +++ b/lib/lightning_web/live/channel_live/form_component.ex @@ -132,6 +132,8 @@ defmodule LightningWeb.ChannelLive.FormComponent do
<.input field={f[:name]} label="Name" type="text" phx-debounce="300" /> + <.input field={f[:enabled]} label="Enabled" type="toggle" /> +
<.input field={f[:destination_url]} @@ -176,8 +178,6 @@ defmodule LightningWeb.ChannelLive.FormComponent do
- <.input field={f[:enabled]} label="Enabled" type="toggle" /> -

diff --git a/test/lightning_web/live/channel_live/form_test.exs b/test/lightning_web/live/channel_live/form_test.exs index 7ab177cebae..c862d053c8f 100644 --- a/test/lightning_web/live/channel_live/form_test.exs +++ b/test/lightning_web/live/channel_live/form_test.exs @@ -156,23 +156,22 @@ defmodule LightningWeb.ChannelLive.FormTest do {:ok, view, html} = live(conn, ~p"/projects/#{project.id}/channels/new") - # Fields appear in this order: Name, Destination URL, - # Destination Credential, Enabled, Client Credentials + # Fields appear in this order: Name, Enabled, Destination URL, + # Destination Credential, Client Credentials name_pos = :binary.match(html, "Name") |> elem(0) + enabled_pos = :binary.match(html, "Enabled") |> elem(0) dest_url_pos = :binary.match(html, "Destination URL") |> elem(0) dest_cred_pos = :binary.match(html, "Destination Credential") |> elem(0) - enabled_pos = :binary.match(html, "Enabled") |> elem(0) - client_cred_pos = :binary.match(html, "Client Credentials") |> elem(0) - assert name_pos < dest_url_pos + assert name_pos < enabled_pos + assert enabled_pos < dest_url_pos assert dest_url_pos < dest_cred_pos - assert dest_cred_pos < enabled_pos - assert enabled_pos < client_cred_pos + assert dest_cred_pos < client_cred_pos # Sublabels assert html =~ "The service OpenFn will forward requests to" From 2a7732f35bf2c1affcd212b145ffb19749a63b47 Mon Sep 17 00:00:00 2001 From: Stuart Corbishley Date: Thu, 23 Apr 2026 15:48:58 +0200 Subject: [PATCH 13/23] Add destination_credential_id to channel_requests (#4541) Adds a nullable FK from channel_requests to project_credentials so that each proxied request records which destination credential (if any) authenticated with the upstream. Mirrors the existing client_webhook_auth_method_id column for client-side auth attribution. - Migration: reversible alter + index, on_delete: :nilify_all so the request record survives credential deletion. - Schema: belongs_to :destination_credential + cast list entry. --- lib/lightning/channels/channel_request.ex | 4 ++++ ...tination_credential_id_to_channel_requests.exs | 15 +++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 priv/repo/migrations/20260423133517_add_destination_credential_id_to_channel_requests.exs diff --git a/lib/lightning/channels/channel_request.ex b/lib/lightning/channels/channel_request.ex index 6c974f55d7d..ec84fe76703 100644 --- a/lib/lightning/channels/channel_request.ex +++ b/lib/lightning/channels/channel_request.ex @@ -8,6 +8,7 @@ defmodule Lightning.Channels.ChannelRequest do alias Lightning.Channels.Channel alias Lightning.Channels.ChannelEvent alias Lightning.Channels.ChannelSnapshot + alias Lightning.Projects.ProjectCredential alias Lightning.Workflows.WebhookAuthMethod @type t :: %__MODULE__{ @@ -18,6 +19,7 @@ defmodule Lightning.Channels.ChannelRequest do client_identity: String.t() | nil, client_webhook_auth_method_id: Ecto.UUID.t() | nil, client_auth_type: String.t() | nil, + destination_credential_id: Ecto.UUID.t() | nil, state: :pending | :success | :failed | :timeout | :error, started_at: DateTime.t(), completed_at: DateTime.t() | nil @@ -37,6 +39,7 @@ defmodule Lightning.Channels.ChannelRequest do belongs_to :channel, Channel belongs_to :channel_snapshot, ChannelSnapshot belongs_to :client_webhook_auth_method, WebhookAuthMethod + belongs_to :destination_credential, ProjectCredential has_many :channel_events, ChannelEvent end @@ -50,6 +53,7 @@ defmodule Lightning.Channels.ChannelRequest do :client_identity, :client_webhook_auth_method_id, :client_auth_type, + :destination_credential_id, :state, :started_at, :completed_at diff --git a/priv/repo/migrations/20260423133517_add_destination_credential_id_to_channel_requests.exs b/priv/repo/migrations/20260423133517_add_destination_credential_id_to_channel_requests.exs new file mode 100644 index 00000000000..a3d82ac08cf --- /dev/null +++ b/priv/repo/migrations/20260423133517_add_destination_credential_id_to_channel_requests.exs @@ -0,0 +1,15 @@ +defmodule Lightning.Repo.Migrations.AddDestinationCredentialIdToChannelRequests do + use Ecto.Migration + + def change do + alter table(:channel_requests) do + add :destination_credential_id, + references(:project_credentials, + type: :binary_id, + on_delete: :nilify_all + ) + end + + create index(:channel_requests, [:destination_credential_id]) + end +end From 9ef71d7da8a4c58c6c4075e947ac62f3ff74b4df Mon Sep 17 00:00:00 2001 From: Stuart Corbishley Date: Thu, 23 Apr 2026 15:49:09 +0200 Subject: [PATCH 14/23] Propagate destination_credential_id through proxy handler (#4541) ChannelProxyPlug now resolves the channel's destination project_credential id at request time (alongside the existing auth_header resolution) and threads it through to the Philter handler state. The handler persists it on the ChannelRequest row in the same insert that already captures client auth attribution, so both ids appear on the row atomically. The credential-error path also records the id, since the credential is known even when resolving its body fails. --- lib/lightning/channels/handler.ex | 1 + lib/lightning_web/plugs/channel_proxy_plug.ex | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/lib/lightning/channels/handler.ex b/lib/lightning/channels/handler.ex index c00eff2d6a6..c7eb8a220f9 100644 --- a/lib/lightning/channels/handler.ex +++ b/lib/lightning/channels/handler.ex @@ -63,6 +63,7 @@ defmodule Lightning.Channels.Handler do client_webhook_auth_method_id: Map.get(state, :client_webhook_auth_method_id), client_auth_type: Map.get(state, :client_auth_type), + destination_credential_id: Map.get(state, :destination_credential_id), state: :pending, started_at: state.started_at } diff --git a/lib/lightning_web/plugs/channel_proxy_plug.ex b/lib/lightning_web/plugs/channel_proxy_plug.ex index dcc7e326cf7..63ae73ab521 100644 --- a/lib/lightning_web/plugs/channel_proxy_plug.ex +++ b/lib/lightning_web/plugs/channel_proxy_plug.ex @@ -44,6 +44,7 @@ defmodule LightningWeb.ChannelProxyPlug do :forward_path, :client_identity, :auth_header, + :destination_credential_id, client_auth_types: [] ] end @@ -93,7 +94,8 @@ defmodule LightningWeb.ChannelProxyPlug do forward_path: build_forward_path(rest), client_identity: get_client_identity(conn), auth_header: auth_header, - client_auth_types: client_auth_types + client_auth_types: client_auth_types, + destination_credential_id: destination_credential_id(channel) } conn @@ -108,6 +110,14 @@ defmodule LightningWeb.ChannelProxyPlug do end end + defp destination_credential_id(%{ + destination_auth_method: %{project_credential_id: id} + }) + when is_binary(id), + do: id + + defp destination_credential_id(_channel), do: nil + defp authenticate_client(_conn, %{client_webhook_auth_methods: []}) do {:ok, nil} end @@ -153,7 +163,8 @@ defmodule LightningWeb.ChannelProxyPlug do started_at: DateTime.utc_now(), request_path: req.forward_path, client_identity: req.client_identity, - query_string: conn.query_string + query_string: conn.query_string, + destination_credential_id: req.destination_credential_id } |> put_auth_method(matched_auth) @@ -280,7 +291,8 @@ defmodule LightningWeb.ChannelProxyPlug do |> Plug.Conn.get_resp_header("x-request-id") |> List.first(), forward_path: conn.request_path, - client_identity: get_client_identity(conn) + client_identity: get_client_identity(conn), + destination_credential_id: destination_credential_id(channel) } record_credential_error(conn, req, error_message) @@ -304,6 +316,7 @@ defmodule LightningWeb.ChannelProxyPlug do channel_snapshot_id: req.snapshot.id, request_id: req.request_id, client_identity: req.client_identity, + destination_credential_id: req.destination_credential_id, state: :error, started_at: now, completed_at: now From c921eb21f795a6bb3c1567b9e23bf7426a35117e Mon Sep 17 00:00:00 2001 From: Stuart Corbishley Date: Thu, 23 Apr 2026 15:49:21 +0200 Subject: [PATCH 15/23] Show client and destination auth on channel request detail (#4541) Replaces the single "Auth" line in the request summary card with two labelled fields: - Client auth: matched webhook auth method name plus the auth type (e.g. "Prod API key (API key)"), "None" when no client auth was required, or "(deleted) ()" if the method has since been removed. - Destination auth: the project credential name, "None" when none was configured, or "(deleted)" if the credential has since been removed. get_channel_request_for_project/2 now preloads client_webhook_auth_method and destination_credential (with its credential) so the detail page renders with no additional queries. Tests cover persistence on both the context and proxy paths, the three render cases (both present, client nil, and stale id with nil association), and verify the preloads attach. --- lib/lightning/channels.ex | 12 +- .../live/channel_request_live/helpers.ex | 38 +++++ .../live/channel_request_live/show.ex | 13 +- test/lightning/channels_test.exs | 21 ++- .../live/channel_request_live/show_test.exs | 144 ++++++++++++++++++ .../plugs/channel_proxy_plug_test.exs | 94 ++++++++++++ 6 files changed, 317 insertions(+), 5 deletions(-) diff --git a/lib/lightning/channels.ex b/lib/lightning/channels.ex index 8351fb14975..ed1937ebe7d 100644 --- a/lib/lightning/channels.ex +++ b/lib/lightning/channels.ex @@ -454,7 +454,9 @@ defmodule Lightning.Channels do Returns `nil` if the request doesn't exist, belongs to a different project, or the ID is not a valid UUID. - Preloads: `channel_events`, `channel`, `channel_snapshot`. + Preloads: `channel_events`, `channel`, `channel_snapshot`, + `client_webhook_auth_method`, and `destination_credential` (with its + `credential` for display). """ @spec get_channel_request_for_project(Ecto.UUID.t(), String.t()) :: ChannelRequest.t() | nil @@ -465,7 +467,13 @@ defmodule Lightning.Channels do join: c in Channel, on: cr.channel_id == c.id, where: cr.id == ^uuid and c.project_id == ^project_id, - preload: [:channel_events, :channel, :channel_snapshot] + preload: [ + :channel_events, + :channel, + :channel_snapshot, + :client_webhook_auth_method, + destination_credential: :credential + ] ) |> Repo.one() diff --git a/lib/lightning_web/live/channel_request_live/helpers.ex b/lib/lightning_web/live/channel_request_live/helpers.ex index 637db414045..d5b380a8114 100644 --- a/lib/lightning_web/live/channel_request_live/helpers.ex +++ b/lib/lightning_web/live/channel_request_live/helpers.ex @@ -101,6 +101,44 @@ defmodule LightningWeb.ChannelRequestLive.Helpers do def format_auth_type("basic"), do: "Basic auth" def format_auth_type(type), do: type + @doc """ + Formats the client auth method used for a channel request for display. + + Returns `" ()"` when the matched method is present, + `"(deleted) ()"` when the id is set but the association is + `nil` after preload (method deleted after the request ran), and the raw + auth type / `"None"` when no client auth was configured for the request. + """ + def format_client_auth(%{client_webhook_auth_method_id: nil} = channel_request) do + format_auth_type(channel_request.client_auth_type) + end + + def format_client_auth( + %{client_webhook_auth_method: %{name: name}} = channel_request + ) do + "#{name} (#{format_auth_type(channel_request.client_auth_type)})" + end + + def format_client_auth(channel_request) do + "(deleted) (#{format_auth_type(channel_request.client_auth_type)})" + end + + @doc """ + Formats the destination credential used for a channel request for display. + + Returns the credential name when present, `"(deleted)"` when the id is + still set but the credential has been deleted, and `"None"` when no + destination credential was configured. + """ + def format_destination_auth(%{destination_credential_id: nil}), do: "None" + + def format_destination_auth(%{ + destination_credential: %{credential: %{name: name}} + }), + do: name + + def format_destination_auth(_channel_request), do: "(deleted)" + def format_bytes(nil), do: "—" def format_bytes(bytes) when bytes < 1024, diff --git a/lib/lightning_web/live/channel_request_live/show.ex b/lib/lightning_web/live/channel_request_live/show.ex index 6df86372e07..02a4bc5ef0a 100644 --- a/lib/lightning_web/live/channel_request_live/show.ex +++ b/lib/lightning_web/live/channel_request_live/show.ex @@ -140,11 +140,20 @@ defmodule LightningWeb.ChannelRequestLive.Show do

- Auth + Client auth
<.icon name="hero-shield-check" class="h-4 w-4 text-secondary-400" /> - {Helpers.format_auth_type(@channel_request.client_auth_type)} + {Helpers.format_client_auth(@channel_request)} +
+
+
+
+ Destination auth +
+
+ <.icon name="hero-key" class="h-4 w-4 text-secondary-400" /> + {Helpers.format_destination_auth(@channel_request)}
diff --git a/test/lightning/channels_test.exs b/test/lightning/channels_test.exs index d5507cd494c..9f32906fe49 100644 --- a/test/lightning/channels_test.exs +++ b/test/lightning/channels_test.exs @@ -461,6 +461,17 @@ defmodule Lightning.ChannelsTest do describe "get_channel_request_for_project/2" do test "returns channel request with preloads when project matches" do project = insert(:project) + user = insert(:user) + + webhook_auth_method = + insert(:webhook_auth_method, project: project, auth_type: :api) + + credential = + insert(:credential, user: user, name: "dest-cred", schema: "http") + + project_credential = + insert(:project_credential, project: project, credential: credential) + channel = insert(:channel, project: project) {:ok, snapshot} = Channels.get_or_create_current_snapshot(channel) @@ -469,7 +480,10 @@ defmodule Lightning.ChannelsTest do channel: channel, channel_snapshot: snapshot, state: :success, - started_at: DateTime.utc_now() + started_at: DateTime.utc_now(), + client_webhook_auth_method_id: webhook_auth_method.id, + client_auth_type: "api", + destination_credential_id: project_credential.id ) event = @@ -487,6 +501,11 @@ defmodule Lightning.ChannelsTest do assert length(result.channel_events) == 1 assert hd(result.channel_events).id == event.id + + # Client and destination auth tracking are preloaded (no N+1). + assert result.client_webhook_auth_method.id == webhook_auth_method.id + assert result.destination_credential.id == project_credential.id + assert result.destination_credential.credential.name == "dest-cred" end test "returns nil when channel request belongs to a different project" do diff --git a/test/lightning_web/live/channel_request_live/show_test.exs b/test/lightning_web/live/channel_request_live/show_test.exs index e00fd9db8cf..f16fc493aa7 100644 --- a/test/lightning_web/live/channel_request_live/show_test.exs +++ b/test/lightning_web/live/channel_request_live/show_test.exs @@ -475,6 +475,150 @@ defmodule LightningWeb.ChannelRequestLive.ShowTest do end end + describe "detail page — auth attribution" do + setup [:register_and_log_in_user, :create_project_for_current_user] + setup :enable_experimental_features + + test "renders client auth method name and destination credential name when both present", + %{conn: conn, project: project, user: user} do + webhook_auth_method = + insert(:webhook_auth_method, + project: project, + auth_type: :api, + name: "Prod API key" + ) + + credential = + insert(:credential, user: user, name: "Destination API", schema: "http") + + project_credential = + insert(:project_credential, project: project, credential: credential) + + channel = insert(:channel, project: project) + + {request, _channel, _snap} = + create_channel_request(project, + channel: channel, + client_auth_type: "api" + ) + + request + |> Ecto.Changeset.change(%{ + client_webhook_auth_method_id: webhook_auth_method.id, + destination_credential_id: project_credential.id + }) + |> Lightning.Repo.update!() + + insert(:channel_event, channel_request: request) + + {:ok, view, _html} = live(conn, detail_path(project, request)) + html = render(view) + + # Client auth: method name and auth type label + assert html =~ "Prod API key" + assert html =~ "API key" + + # Destination auth: credential name + assert html =~ "Destination API" + + # Section labels + assert html =~ "Client auth" + assert html =~ "Destination auth" + end + + test "renders 'None' when no client auth configured and credential name when destination set", + %{conn: conn, project: project, user: user} do + credential = + insert(:credential, user: user, name: "Only Destination", schema: "http") + + project_credential = + insert(:project_credential, project: project, credential: credential) + + {request, _channel, _snap} = + create_channel_request(project, client_auth_type: nil) + + request + |> Ecto.Changeset.change(%{ + client_webhook_auth_method_id: nil, + destination_credential_id: project_credential.id + }) + |> Lightning.Repo.update!() + + insert(:channel_event, channel_request: request) + + {:ok, view, _html} = live(conn, detail_path(project, request)) + html = render(view) + + # Client auth column shows "None" + assert html =~ "None" + # Destination credential name still renders + assert html =~ "Only Destination" + end + + test "renders '(deleted)' without crashing when destination credential id is set but association missing", + %{conn: conn, project: project, user: user} do + credential = + insert(:credential, user: user, name: "Will Be Gone", schema: "http") + + project_credential = + insert(:project_credential, project: project, credential: credential) + + {request, _channel, _snap} = create_channel_request(project) + + request + |> Ecto.Changeset.change(%{ + destination_credential_id: project_credential.id + }) + |> Lightning.Repo.update!() + + insert(:channel_event, channel_request: request) + + # Break the FK with raw SQL to simulate a stale read: the id is + # still on the row but the credential row has been hard-deleted. + # `on_delete: :nilify_all` would clear the id under normal Ecto, + # so we bypass it with DELETE on project_credentials directly — + # which will cascade-nilify. The belt-and-suspenders render path + # for "id set, preload nil" is what we're covering here, so we + # then null out the id too and prove rendering still survives + # the absent credential through the helpers. + Lightning.Repo.delete!(project_credential) + + {:ok, view, _html} = live(conn, detail_path(project, request)) + html = render(view) + + # After nilify, helper returns "None". Request still renders. + assert html =~ "None" or html =~ "(deleted)" + + reloaded = + Lightning.Channels.get_channel_request_for_project( + project.id, + request.id + ) + + assert reloaded.destination_credential_id == nil + end + + test "helper renders '(deleted)' for stale in-memory record with id but nil association" do + # Directly test the helper to cover the belt-and-suspenders path + # (id present, association nil) that the schema's on_delete: :nilify_all + # prevents us from producing through the DB. + stale = %Lightning.Channels.ChannelRequest{ + client_webhook_auth_method_id: Ecto.UUID.generate(), + client_auth_type: "api", + client_webhook_auth_method: nil, + destination_credential_id: Ecto.UUID.generate(), + destination_credential: nil + } + + assert LightningWeb.ChannelRequestLive.Helpers.format_client_auth(stale) =~ + "(deleted)" + + assert LightningWeb.ChannelRequestLive.Helpers.format_destination_auth( + stale + ) == "(deleted)" + end + end + describe "navigation" do setup [:register_and_log_in_user, :create_project_for_current_user] setup :enable_experimental_features diff --git a/test/lightning_web/plugs/channel_proxy_plug_test.exs b/test/lightning_web/plugs/channel_proxy_plug_test.exs index 5fbd649829f..9784748890b 100644 --- a/test/lightning_web/plugs/channel_proxy_plug_test.exs +++ b/test/lightning_web/plugs/channel_proxy_plug_test.exs @@ -1125,6 +1125,100 @@ defmodule LightningWeb.ChannelProxyPlugTest do end end + describe "destination auth tracking" do + test "persists destination_credential_id on successful proxy with destination auth", + %{bypass: bypass} do + channel = + create_destination_auth_channel(bypass, "http", %{ + "access_token" => "tok-123" + }) + + project_credential_id = + channel + |> Lightning.Repo.preload(destination_auth_method: :project_credential) + |> get_in([ + Access.key(:destination_auth_method), + Access.key(:project_credential_id) + ]) + + Bypass.expect_once(bypass, "GET", "/dest-track", fn conn -> + Plug.Conn.send_resp(conn, 200, "ok") + end) + + conn(:get, "/channels/#{channel.id}/dest-track") + |> send_to_endpoint() + + request = + Lightning.Repo.one!( + from(r in ChannelRequest, where: r.channel_id == ^channel.id) + ) + + assert request.destination_credential_id == project_credential_id + refute is_nil(project_credential_id) + end + + test "persists destination_credential_id even when credential resolution fails", + %{bypass: _bypass} do + # Channel with a destination auth method but credential missing auth + # fields — destination auth resolution fails, but we still know which + # credential was configured. + project = insert(:project) + user = insert(:user) + + credential = + insert(:credential, schema: "http", name: "bad-cred", user: user) + |> with_body(%{body: %{"baseUrl" => "https://example.com"}}) + + project_credential = + insert(:project_credential, project: project, credential: credential) + + channel = + insert(:channel, + project: project, + destination_url: "http://localhost:9999", + enabled: true, + channel_auth_methods: [ + build(:channel_auth_method, + role: :destination, + webhook_auth_method: nil, + project_credential: project_credential + ) + ] + ) + + resp = + conn(:get, "/channels/#{channel.id}/test") + |> send_to_endpoint() + + assert resp.status == 502 + + request = + Lightning.Repo.one!( + from(r in ChannelRequest, where: r.channel_id == ^channel.id) + ) + + assert request.destination_credential_id == project_credential.id + assert request.state == :error + end + + test "destination_credential_id is nil when no destination auth configured", + %{bypass: bypass, channel: channel} do + Bypass.expect_once(bypass, "GET", "/no-dest-auth", fn conn -> + Plug.Conn.send_resp(conn, 200, "ok") + end) + + conn(:get, "/channels/#{channel.id}/no-dest-auth") + |> send_to_endpoint() + + request = + Lightning.Repo.one!( + from(r in ChannelRequest, where: r.channel_id == ^channel.id) + ) + + assert request.destination_credential_id == nil + end + end + describe "collect_timing integration" do test "persists per-direction timing after successful proxy", %{ bypass: bypass, From cc8c7d64cd256956796a84cc8adebdca0dff0a0c Mon Sep 17 00:00:00 2001 From: Stuart Corbishley Date: Fri, 24 Apr 2026 14:54:58 +0200 Subject: [PATCH 16/23] Regroup channel request summary into Client / Destination / Timing Promotes Channel link and short Request ID into the pill header row and splits the summary card into three labeled sections so client and destination facts sit side-by-side, with timing full-width below. Auth icons use shrink-0 + items-start so they stay consistent when the label wraps in a narrow column. --- .../live/channel_request_live/show.ex | 179 ++++++++++-------- 1 file changed, 102 insertions(+), 77 deletions(-) diff --git a/lib/lightning_web/live/channel_request_live/show.ex b/lib/lightning_web/live/channel_request_live/show.ex index 02a4bc5ef0a..22184c9ff55 100644 --- a/lib/lightning_web/live/channel_request_live/show.ex +++ b/lib/lightning_web/live/channel_request_live/show.ex @@ -96,7 +96,7 @@ defmodule LightningWeb.ChannelRequestLive.Show do defp summary_card(assigns) do ~H"""
-
+
<.method_badge method={@event && @event.request_method} /> <.request_path_display event={@event} /> <.status_code_display status={@event && @event.response_status} /> @@ -104,22 +104,10 @@ defmodule LightningWeb.ChannelRequestLive.Show do state={@channel_request.state} error_message={@event && @event.error_message} /> -
-
-
-
- Destination -
-
- {@channel.destination_url} -
-
-
-
- Channel -
-
+
+ + Channel <.link navigate={ ~p"/projects/#{@channel.project_id}/channels/#{@channel.id}/edit" @@ -128,76 +116,113 @@ defmodule LightningWeb.ChannelRequestLive.Show do > {@channel.name} -
-
-
-
- Client IP -
-
- {@channel_request.client_identity || "—"} -
-
-
-
- Client auth -
-
- <.icon name="hero-shield-check" class="h-4 w-4 text-secondary-400" /> - {Helpers.format_client_auth(@channel_request)} -
-
-
-
- Destination auth -
-
- <.icon name="hero-key" class="h-4 w-4 text-secondary-400" /> - {Helpers.format_destination_auth(@channel_request)} -
-
-
-
- Started -
-
- -
-
-
-
- Completed -
-
- -
-
-
-
- Latency -
-
- {if @event && @event.latency_us, - do: "#{Helpers.format_us(@event.latency_us)} ms", - else: "—"} -
-
-
-
- Request ID -
-
- + + + Request + {String.slice(@channel_request.id, 0..7)} <.copy_icon_button id="copy-request-id" value={@channel_request.id} title="Copy request ID" + size={3} /> -
+
+ +
+
+

+ Client +

+
+
+
+ IP +
+
+ {@channel_request.client_identity || "—"} +
+
+
+
+ Auth +
+
+ <.icon + name="hero-shield-check" + class="h-4 w-4 shrink-0 text-secondary-400 mt-0.5" + /> + {Helpers.format_client_auth(@channel_request)} +
+
+
+
+ +
+

+ Destination +

+
+
+
+ URL +
+
+ {@channel.destination_url} +
+
+
+
+ Auth +
+
+ <.icon + name="hero-key" + class="h-4 w-4 shrink-0 text-secondary-400 mt-0.5" + /> + {Helpers.format_destination_auth(@channel_request)} +
+
+
+
+ +
+

+ Timing +

+
+
+
+ Started +
+
+ +
+
+
+
+ Completed +
+
+ +
+
+
+
+ Latency +
+
+ {if @event && @event.latency_us, + do: "#{Helpers.format_us(@event.latency_us)} ms", + else: "—"} +
+
+
+
+
""" end From e25a6fe716f730633dda9a196ff9f4836e78e026 Mon Sep 17 00:00:00 2001 From: Stuart Corbishley Date: Fri, 24 Apr 2026 15:05:44 +0200 Subject: [PATCH 17/23] Simplify status_code and destination_credential_id branches Collapses multi-clause/guarded forms into cond/case blocks so the intent reads top-to-bottom without duplicated is_integer guards or separate function heads. --- .../live/channel_request_live/components.ex | 22 ++++++------------- lib/lightning_web/plugs/channel_proxy_plug.ex | 13 +++++------ 2 files changed, 13 insertions(+), 22 deletions(-) diff --git a/lib/lightning_web/live/channel_request_live/components.ex b/lib/lightning_web/live/channel_request_live/components.ex index dbefce3687d..5036accc25c 100644 --- a/lib/lightning_web/live/channel_request_live/components.ex +++ b/lib/lightning_web/live/channel_request_live/components.ex @@ -139,21 +139,13 @@ defmodule LightningWeb.ChannelRequestLive.Components do def status_code_display(assigns) do color_class = - case assigns.status do - s when is_integer(s) and s >= 200 and s < 300 -> - "text-green-700 bg-green-50" - - s when is_integer(s) and s >= 300 and s < 400 -> - "text-blue-700 bg-blue-50" - - s when is_integer(s) and s >= 400 and s < 500 -> - "text-amber-700 bg-amber-50" - - s when is_integer(s) and s >= 500 -> - "text-red-700 bg-red-50" - - _ -> - "text-secondary-400" + cond do + is_nil(assigns.status) -> "text-secondary-400" + assigns.status >= 500 -> "text-red-700 bg-red-50" + assigns.status >= 400 -> "text-amber-700 bg-amber-50" + assigns.status >= 300 -> "text-blue-700 bg-blue-50" + assigns.status >= 200 -> "text-green-700 bg-green-50" + true -> "text-secondary-400" end assigns = assign(assigns, color_class: color_class) diff --git a/lib/lightning_web/plugs/channel_proxy_plug.ex b/lib/lightning_web/plugs/channel_proxy_plug.ex index 63ae73ab521..3b37e4eb1f6 100644 --- a/lib/lightning_web/plugs/channel_proxy_plug.ex +++ b/lib/lightning_web/plugs/channel_proxy_plug.ex @@ -110,13 +110,12 @@ defmodule LightningWeb.ChannelProxyPlug do end end - defp destination_credential_id(%{ - destination_auth_method: %{project_credential_id: id} - }) - when is_binary(id), - do: id - - defp destination_credential_id(_channel), do: nil + defp destination_credential_id(channel) do + case channel.destination_auth_method do + %{project_credential_id: id} -> id + _ -> nil + end + end defp authenticate_client(_conn, %{client_webhook_auth_methods: []}) do {:ok, nil} From cd1cc84940aaa2b2c6bc2908bc4793581e0e0d45 Mon Sep 17 00:00:00 2001 From: Stuart Corbishley Date: Fri, 24 Apr 2026 15:18:41 +0200 Subject: [PATCH 18/23] Bump philter to 0.3.0 (#4541) Channel request handler and proxy plug depend on the 0.3.0 timing map (total_us, send_us, recv_us) that replaced duration_us. --- mix.exs | 2 +- mix.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mix.exs b/mix.exs index e9a50a3cd13..d7a31ea32a0 100644 --- a/mix.exs +++ b/mix.exs @@ -162,7 +162,7 @@ defmodule Lightning.MixProject do if path = System.get_env("PHILTER_PATH") do {:philter, path: path} else - {:philter, "~> 0.2.1"} + {:philter, "~> 0.3.0"} end end diff --git a/mix.lock b/mix.lock index b319d9c250a..5d9af735bb5 100644 --- a/mix.lock +++ b/mix.lock @@ -9,7 +9,7 @@ "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, "cachex": {:hex, :cachex, "4.1.1", "574c5cd28473db313a0a76aac8c945fe44191659538ca6a1e8946ec300b1a19f", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:ex_hash_ring, "~> 6.0", [hex: :ex_hash_ring, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "d6b7449ff98d6bb92dda58bd4fc3189cae9f99e7042054d669596f56dc503cd8"}, - "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, + "castore": {:hex, :castore, "1.0.18", "5e43ef0ec7d31195dfa5a65a86e6131db999d074179d2ba5a8de11fe14570f55", [:mix], [], "hexpm", "f393e4fe6317829b158fb74d86eb681f737d2fe326aa61ccf6293c4104957e34"}, "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, "chameleon": {:hex, :chameleon, "2.5.0", "102dd809f78701875efd0a203730dd64296a1f2d29c8efa6b00cc029d58ff39e", [:mix], [], "hexpm", "f3559827d8b4fe53a44e19e56ae94bedd36a355e0d33e18067b8abc37ec428db"}, "circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"}, @@ -103,7 +103,7 @@ "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "peep": {:hex, :peep, "3.5.0", "9f6ead7b0f2c684494200c8fc02e7e62e8c459afe861b29bd859e4c96f402ed8", [:mix], [{:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:plug, "~> 1.16", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "5a73a99c6e60062415efeb7e536a663387146463a3d3df1417da31fd665ac210"}, "petal_components": {:hex, :petal_components, "3.0.1", "58cd70f9c5e4896ed8e41b095f19770fa56ca0855d99790c4a26b5f04fa52283", [:mix], [{:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_ecto, "~> 4.4", [hex: :phoenix_ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.7", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "1195bc30979284f01a5fa2430e370d8378c635e083179c2b2fdbecf21cce05c1"}, - "philter": {:hex, :philter, "0.2.1", "48239f0913745c1a58bf1691993cbf19fc766e20846ccbe961a211870d1f99c3", [:mix], [{:finch, "~> 0.18", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "3bc2ff7a61d08936621544df9b65afae46b4bd4cb9a2412eeace450a762a3ff9"}, + "philter": {:hex, :philter, "0.3.0", "7142e315cd1265365fa9d5c40a48530135c34b79ea160e9cd1eee1fc7acc297e", [:mix], [{:finch, "~> 0.18", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ddb96add693cb6749f26b7663c1319f32d5e898b8ffad7f6a4c1e5e7ed2e225e"}, "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.5", "c4ef322acd15a574a8b1a08eff0ee0a85e73096b53ce1403b6563709f15e1cea", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "26ec3208eef407f31b748cadd044045c6fd485fbff168e35963d2f9dfff28d4b"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, From afa2e4dcb3de50471d1825c9008ffab5c16c9b8a Mon Sep 17 00:00:00 2001 From: Stuart Corbishley Date: Fri, 24 Apr 2026 15:27:32 +0200 Subject: [PATCH 19/23] Update changelog for channel request detail page (#4541) --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f19e179e67f..0bf7bfd7917 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,15 @@ and this project adheres to - Ability to filter work orders and runs via REST API by UUIDs or status; added example curl requests to REST API docs. [#4552](https://github.com/OpenFn/lightning/issues/4552) +- Channel request detail page, reached by clicking a row in the channel history + table. Shows a client / destination / timing summary, a nested timing + visualization with per-phase breakdown and TTFB marker, foldable request and + response headers and body, and humanized transport and credential errors. + Captures richer request metadata (query string, body sizes, per-direction + durations, Finch phase timings) and attributes both the matched client webhook + auth method and the destination project credential on every proxied request. + Feature-gated behind experimental features. + [#4541](https://github.com/OpenFn/lightning/issues/4541) ### Changed @@ -49,6 +58,10 @@ and this project adheres to - Worker plan payload now includes `project_id` so workers can scope callbacks (e.g. the collections API) to the project that owns the run. - bumped local worker to 1.24.0 +- Channel timing fields are now stored in microseconds (previously milliseconds) + and request and response headers are stored as native jsonb on + `channel_events`. Handler adapted to Philter 0.3.0 timing map. + [#4541](https://github.com/OpenFn/lightning/issues/4541) ### Fixed From f47d20c93637de411225c7072724b0e2a346fb8c Mon Sep 17 00:00:00 2001 From: Frank Midigo Date: Thu, 7 May 2026 10:38:44 +0300 Subject: [PATCH 20/23] fix failing layout component --- lib/lightning_web/live/channel_request_live/show.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/lightning_web/live/channel_request_live/show.ex b/lib/lightning_web/live/channel_request_live/show.ex index 22184c9ff55..a5afbcde29c 100644 --- a/lib/lightning_web/live/channel_request_live/show.ex +++ b/lib/lightning_web/live/channel_request_live/show.ex @@ -42,7 +42,7 @@ defmodule LightningWeb.ChannelRequestLive.Show do <:breadcrumbs> - + Date: Thu, 7 May 2026 17:43:03 +0300 Subject: [PATCH 21/23] handle cases when non-binary responses are received --- lib/lightning/channels/handler.ex | 55 ++++++--- lib/lightning_web/plugs/channel_proxy_plug.ex | 14 ++- .../plugs/channel_proxy_plug_test.exs | 110 ++++++++++++++++++ 3 files changed, 154 insertions(+), 25 deletions(-) diff --git a/lib/lightning/channels/handler.ex b/lib/lightning/channels/handler.ex index c7eb8a220f9..f6f0cc60b1b 100644 --- a/lib/lightning/channels/handler.ex +++ b/lib/lightning/channels/handler.ex @@ -134,31 +134,48 @@ defmodule Lightning.Channels.Handler do completed_at: DateTime.utc_now() } - with {:ok, _event} <- - %ChannelEvent{} - |> ChannelEvent.changeset(event_attrs) - |> Repo.insert(), - {:ok, _request} <- - state.channel_request - |> ChannelRequest.changeset(request_update) - |> Repo.update() do - :ok - else - {:error, changeset} -> + try do + with {:ok, _event} <- + %ChannelEvent{} + |> ChannelEvent.changeset(event_attrs) + |> Repo.insert(), + {:ok, _request} <- + state.channel_request + |> ChannelRequest.changeset(request_update) + |> Repo.update() do + :ok + else + {:error, changeset} -> + Logger.warning( + "Failed to persist channel observation for request " <> + "#{state.channel_request.request_id}: #{inspect(changeset.errors)}" + ) + + mark_request_errored(state.channel_request) + end + rescue + # Body fields are stored as :text. Non-UTF-8 upstream responses (gzip, png, …) + # raise a Postgrex.Error on insert. Catch it so the proxy doesn't crash and + # the request is still marked as errored for the audit log. + e in Postgrex.Error -> Logger.warning( - "Failed to persist channel observation for request " <> - "#{state.channel_request.request_id}: #{inspect(changeset.errors)}" + "Postgres rejected channel event for request " <> + "#{state.channel_request.request_id}: #{Exception.message(e)}" ) - state.channel_request - |> ChannelRequest.changeset(%{ - state: :error, - completed_at: DateTime.utc_now() - }) - |> Repo.update() + mark_request_errored(state.channel_request) end end + defp mark_request_errored(channel_request) do + channel_request + |> ChannelRequest.changeset(%{ + state: :error, + completed_at: DateTime.utc_now() + }) + |> Repo.update() + end + defp derive_request_state(result) do cond do match?({:timeout, _}, result.error) -> :timeout diff --git a/lib/lightning_web/plugs/channel_proxy_plug.ex b/lib/lightning_web/plugs/channel_proxy_plug.ex index 3b37e4eb1f6..ab0a159dd1a 100644 --- a/lib/lightning_web/plugs/channel_proxy_plug.ex +++ b/lib/lightning_web/plugs/channel_proxy_plug.ex @@ -222,12 +222,14 @@ defmodule LightningWeb.ChannelProxyPlug do end defp build_strip_headers(client_auth_types) do - Enum.flat_map(client_auth_types, fn - :api -> ["x-api-key"] - :basic -> ["authorization"] - _ -> [] - end) - |> Enum.uniq() + auth_strips = + Enum.flat_map(client_auth_types, fn + :api -> ["x-api-key"] + :basic -> ["authorization"] + _ -> [] + end) + + ["accept-encoding" | auth_strips] |> Enum.uniq() end defp fetch_channel(id) do diff --git a/test/lightning_web/plugs/channel_proxy_plug_test.exs b/test/lightning_web/plugs/channel_proxy_plug_test.exs index 9784748890b..cf04eeff0db 100644 --- a/test/lightning_web/plugs/channel_proxy_plug_test.exs +++ b/test/lightning_web/plugs/channel_proxy_plug_test.exs @@ -368,6 +368,116 @@ defmodule LightningWeb.ChannelProxyPlugTest do end end + describe "GET requests" do + test "GET with accept-encoding gzip persists request, response, and timing", + %{bypass: bypass, channel: channel} do + json_body = ~s({"data":[{"breed":"Abyssinian"},{"breed":"Aegean"}]}) + + Bypass.expect_once(bypass, "GET", "/breeds", fn conn -> + case Plug.Conn.get_req_header(conn, "accept-encoding") do + [enc | _] when enc != "" -> + conn + |> Plug.Conn.put_resp_header("content-type", "application/json") + |> Plug.Conn.put_resp_header("content-encoding", "gzip") + |> Plug.Conn.send_resp(200, :zlib.gzip(json_body)) + + _ -> + conn + |> Plug.Conn.put_resp_header("content-type", "application/json") + |> Plug.Conn.send_resp(200, json_body) + end + end) + + before = DateTime.utc_now() + + resp = + conn(:get, "/channels/#{channel.id}/breeds?limit=10") + |> Plug.Conn.put_req_header("accept-encoding", "gzip, deflate, br") + |> send_to_endpoint() + + after_ = DateTime.utc_now() + + assert resp.status == 200 + + request = + Lightning.Repo.one!( + from(r in ChannelRequest, where: r.channel_id == ^channel.id) + ) + + assert request.state == :success + assert request.request_id != nil + assert request.started_at != nil + assert request.completed_at != nil + assert DateTime.compare(request.started_at, before) in [:eq, :gt] + assert DateTime.compare(request.completed_at, after_) in [:eq, :lt] + + assert DateTime.compare(request.completed_at, request.started_at) in [ + :eq, + :gt + ] + + event = + Lightning.Repo.one!( + from(e in ChannelEvent, where: e.channel_request_id == ^request.id) + ) + + assert event.type == :destination_response + assert event.request_method == "GET" + assert event.request_path == "/breeds" + assert event.request_query_string == "limit=10" + assert event.response_status == 200 + + # Preview stored as readable text — proves the upstream did not gzip. + assert is_binary(event.response_body_preview) + assert event.response_body_preview =~ "breed" + assert String.valid?(event.response_body_preview) + + assert is_integer(event.response_body_size) and + event.response_body_size > 0 + + assert is_binary(event.response_body_hash) + assert is_list(event.request_headers) and event.request_headers != [] + assert is_list(event.response_headers) and event.response_headers != [] + assert is_integer(event.latency_us) and event.latency_us > 0 + end + + test "non-UTF-8 upstream body does not crash proxy; request lands in :error", + %{bypass: bypass, channel: channel} do + # `0x8b` is the second byte of the gzip magic number. We send it directly + # in the body to mimic the exact byte that Postgres rejected in dev. The + # `content-encoding: gzip` header isn't necessary — what matters is that + # the bytes Philter captures into `response_body_preview` aren't UTF-8. + Bypass.expect_once(bypass, "GET", "/binary", fn conn -> + conn + |> Plug.Conn.put_resp_header("content-type", "application/octet-stream") + |> Plug.Conn.send_resp(200, <<0x1F, 0x8B, 0x08, 0x00, 0xFF, 0xFE>>) + end) + + resp = + conn(:get, "/channels/#{channel.id}/binary") + |> send_to_endpoint() + + assert resp.status == 200 + + request = + Lightning.Repo.one!( + from(r in ChannelRequest, where: r.channel_id == ^channel.id) + ) + + assert request.state == :error + assert request.completed_at != nil + + # No event row could be persisted (the insert raised), but the request is + # still recorded so the audit log shows the attempt. + assert Lightning.Repo.aggregate( + from(e in ChannelEvent, + where: e.channel_request_id == ^request.id + ), + :count + ) == 0 + end + end + describe "handler persistence" do test "creates ChannelRequest and ChannelEvent on successful proxy", %{ conn: conn, From fca07473f21d8308be611cc449aba6c37183792d Mon Sep 17 00:00:00 2001 From: Frank Midigo Date: Fri, 8 May 2026 09:47:49 +0300 Subject: [PATCH 22/23] fix failing test --- test/lightning_web/live/channel_request_live/show_test.exs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/test/lightning_web/live/channel_request_live/show_test.exs b/test/lightning_web/live/channel_request_live/show_test.exs index f16fc493aa7..d2b8e8bea4c 100644 --- a/test/lightning_web/live/channel_request_live/show_test.exs +++ b/test/lightning_web/live/channel_request_live/show_test.exs @@ -520,10 +520,6 @@ defmodule LightningWeb.ChannelRequestLive.ShowTest do # Destination auth: credential name assert html =~ "Destination API" - - # Section labels - assert html =~ "Client auth" - assert html =~ "Destination auth" end test "renders 'None' when no client auth configured and credential name when destination set", From e5eff876de91b6b1228e0c563a33b513429faed8 Mon Sep 17 00:00:00 2001 From: Frank Midigo Date: Sat, 9 May 2026 06:31:32 +0300 Subject: [PATCH 23/23] drop non-UTF-8 body previews in ChannelEvent changeset Reverts the accept-encoding strip in ChannelProxyPlug and the Postgrex.Error rescue in the completion handler. Per review, headers shouldn't be modified beyond the existing auth strips, and an insert should still succeed when an upstream returns binary content. Sanitisation now lives in ChannelEvent.changeset/2: any preview that isn't valid UTF-8 is set to nil before insert, so headers, hash, size, and timing still persist. The display layer can surface why the preview is missing later. --- lib/lightning/channels/channel_event.ex | 17 +++ lib/lightning/channels/handler.ex | 55 +++---- lib/lightning_web/plugs/channel_proxy_plug.ex | 14 +- .../plugs/channel_proxy_plug_test.exs | 134 ++++++++++-------- 4 files changed, 114 insertions(+), 106 deletions(-) diff --git a/lib/lightning/channels/channel_event.ex b/lib/lightning/channels/channel_event.ex index 0d583a4665c..74c106b7f6c 100644 --- a/lib/lightning/channels/channel_event.ex +++ b/lib/lightning/channels/channel_event.ex @@ -105,7 +105,24 @@ defmodule Lightning.Channels.ChannelEvent do ], empty_values: [] ) + |> drop_non_utf8_preview(:request_body_preview) + |> drop_non_utf8_preview(:response_body_preview) |> validate_required([:channel_request_id, :type]) |> assoc_constraint(:channel_request) end + + # Body previews are stored as :text (UTF-8 only). Drop the preview when the + # upstream returns binary content (gzip-encoded responses, images, PDFs, …) + # so the rest of the event — headers, hash, size, timing — still persists. + defp drop_non_utf8_preview(changeset, field) do + case get_change(changeset, field) do + preview when is_binary(preview) and preview != "" -> + if String.valid?(preview), + do: changeset, + else: put_change(changeset, field, nil) + + _ -> + changeset + end + end end diff --git a/lib/lightning/channels/handler.ex b/lib/lightning/channels/handler.ex index f6f0cc60b1b..c7eb8a220f9 100644 --- a/lib/lightning/channels/handler.ex +++ b/lib/lightning/channels/handler.ex @@ -134,48 +134,31 @@ defmodule Lightning.Channels.Handler do completed_at: DateTime.utc_now() } - try do - with {:ok, _event} <- - %ChannelEvent{} - |> ChannelEvent.changeset(event_attrs) - |> Repo.insert(), - {:ok, _request} <- - state.channel_request - |> ChannelRequest.changeset(request_update) - |> Repo.update() do - :ok - else - {:error, changeset} -> - Logger.warning( - "Failed to persist channel observation for request " <> - "#{state.channel_request.request_id}: #{inspect(changeset.errors)}" - ) - - mark_request_errored(state.channel_request) - end - rescue - # Body fields are stored as :text. Non-UTF-8 upstream responses (gzip, png, …) - # raise a Postgrex.Error on insert. Catch it so the proxy doesn't crash and - # the request is still marked as errored for the audit log. - e in Postgrex.Error -> + with {:ok, _event} <- + %ChannelEvent{} + |> ChannelEvent.changeset(event_attrs) + |> Repo.insert(), + {:ok, _request} <- + state.channel_request + |> ChannelRequest.changeset(request_update) + |> Repo.update() do + :ok + else + {:error, changeset} -> Logger.warning( - "Postgres rejected channel event for request " <> - "#{state.channel_request.request_id}: #{Exception.message(e)}" + "Failed to persist channel observation for request " <> + "#{state.channel_request.request_id}: #{inspect(changeset.errors)}" ) - mark_request_errored(state.channel_request) + state.channel_request + |> ChannelRequest.changeset(%{ + state: :error, + completed_at: DateTime.utc_now() + }) + |> Repo.update() end end - defp mark_request_errored(channel_request) do - channel_request - |> ChannelRequest.changeset(%{ - state: :error, - completed_at: DateTime.utc_now() - }) - |> Repo.update() - end - defp derive_request_state(result) do cond do match?({:timeout, _}, result.error) -> :timeout diff --git a/lib/lightning_web/plugs/channel_proxy_plug.ex b/lib/lightning_web/plugs/channel_proxy_plug.ex index ab0a159dd1a..3b37e4eb1f6 100644 --- a/lib/lightning_web/plugs/channel_proxy_plug.ex +++ b/lib/lightning_web/plugs/channel_proxy_plug.ex @@ -222,14 +222,12 @@ defmodule LightningWeb.ChannelProxyPlug do end defp build_strip_headers(client_auth_types) do - auth_strips = - Enum.flat_map(client_auth_types, fn - :api -> ["x-api-key"] - :basic -> ["authorization"] - _ -> [] - end) - - ["accept-encoding" | auth_strips] |> Enum.uniq() + Enum.flat_map(client_auth_types, fn + :api -> ["x-api-key"] + :basic -> ["authorization"] + _ -> [] + end) + |> Enum.uniq() end defp fetch_channel(id) do diff --git a/test/lightning_web/plugs/channel_proxy_plug_test.exs b/test/lightning_web/plugs/channel_proxy_plug_test.exs index cf04eeff0db..dc7a9890263 100644 --- a/test/lightning_web/plugs/channel_proxy_plug_test.exs +++ b/test/lightning_web/plugs/channel_proxy_plug_test.exs @@ -368,35 +368,30 @@ defmodule LightningWeb.ChannelProxyPlugTest do end end - describe "GET requests" do - test "GET with accept-encoding gzip persists request, response, and timing", + describe "non-UTF-8 body handling (issue #4541)" do + # `response_body_preview` and `request_body_preview` are stored as :text + # (UTF-8 only). When an upstream returns binary content (gzip, image, PDF, + # ...) the bytes can't be persisted as text. Rather than failing the whole + # insert, we drop the offending preview to nil and persist everything else + # — headers, hash, size, timing. + + test "non-UTF-8 response body is dropped from preview; rest of event persisted", %{bypass: bypass, channel: channel} do - json_body = ~s({"data":[{"breed":"Abyssinian"},{"breed":"Aegean"}]}) - - Bypass.expect_once(bypass, "GET", "/breeds", fn conn -> - case Plug.Conn.get_req_header(conn, "accept-encoding") do - [enc | _] when enc != "" -> - conn - |> Plug.Conn.put_resp_header("content-type", "application/json") - |> Plug.Conn.put_resp_header("content-encoding", "gzip") - |> Plug.Conn.send_resp(200, :zlib.gzip(json_body)) - - _ -> - conn - |> Plug.Conn.put_resp_header("content-type", "application/json") - |> Plug.Conn.send_resp(200, json_body) - end - end) + # `0x8b` is the second byte of the gzip magic number — exactly the byte + # Postgres rejected in the dev reproduction. We send it raw so Finch + # cannot transparently decompress it. + raw_bytes = <<0x1F, 0x8B, 0x08, 0x00, 0xFF, 0xFE>> - before = DateTime.utc_now() + Bypass.expect_once(bypass, "GET", "/binary", fn conn -> + conn + |> Plug.Conn.put_resp_header("content-type", "application/octet-stream") + |> Plug.Conn.send_resp(200, raw_bytes) + end) resp = - conn(:get, "/channels/#{channel.id}/breeds?limit=10") - |> Plug.Conn.put_req_header("accept-encoding", "gzip, deflate, br") + conn(:get, "/channels/#{channel.id}/binary") |> send_to_endpoint() - after_ = DateTime.utc_now() - assert resp.status == 200 request = @@ -404,17 +399,9 @@ defmodule LightningWeb.ChannelProxyPlugTest do from(r in ChannelRequest, where: r.channel_id == ^channel.id) ) + # The request itself succeeded — only the body preview is unstorable. assert request.state == :success - assert request.request_id != nil - assert request.started_at != nil assert request.completed_at != nil - assert DateTime.compare(request.started_at, before) in [:eq, :gt] - assert DateTime.compare(request.completed_at, after_) in [:eq, :lt] - - assert DateTime.compare(request.completed_at, request.started_at) in [ - :eq, - :gt - ] event = Lightning.Repo.one!( @@ -423,58 +410,81 @@ defmodule LightningWeb.ChannelProxyPlugTest do assert event.type == :destination_response assert event.request_method == "GET" - assert event.request_path == "/breeds" - assert event.request_query_string == "limit=10" + assert event.request_path == "/binary" assert event.response_status == 200 - # Preview stored as readable text — proves the upstream did not gzip. - assert is_binary(event.response_body_preview) - assert event.response_body_preview =~ "breed" - assert String.valid?(event.response_body_preview) - - assert is_integer(event.response_body_size) and - event.response_body_size > 0 + # Body preview dropped because it isn't valid UTF-8. + assert event.response_body_preview == nil + # Hash and size are still recorded so the audit log can show that a body + # was returned, even though the bytes couldn't be persisted as text. assert is_binary(event.response_body_hash) + assert event.response_body_size == byte_size(raw_bytes) + + # Headers and timing persist as usual. assert is_list(event.request_headers) and event.request_headers != [] assert is_list(event.response_headers) and event.response_headers != [] assert is_integer(event.latency_us) and event.latency_us > 0 end - test "non-UTF-8 upstream body does not crash proxy; request lands in :error", + test "non-UTF-8 request body is dropped from preview; rest of event persisted", %{bypass: bypass, channel: channel} do - # `0x8b` is the second byte of the gzip magic number. We send it directly - # in the body to mimic the exact byte that Postgres rejected in dev. The - # `content-encoding: gzip` header isn't necessary — what matters is that - # the bytes Philter captures into `response_body_preview` aren't UTF-8. - Bypass.expect_once(bypass, "GET", "/binary", fn conn -> - conn - |> Plug.Conn.put_resp_header("content-type", "application/octet-stream") - |> Plug.Conn.send_resp(200, <<0x1F, 0x8B, 0x08, 0x00, 0xFF, 0xFE>>) + raw_bytes = <<0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A>> + + Bypass.expect_once(bypass, "POST", "/upload", fn conn -> + Plug.Conn.send_resp(conn, 201, "ok") end) resp = - conn(:get, "/channels/#{channel.id}/binary") + conn(:post, "/channels/#{channel.id}/upload", raw_bytes) + |> Plug.Conn.put_req_header("content-type", "application/octet-stream") + |> Plug.Conn.put_req_header("content-length", "#{byte_size(raw_bytes)}") |> send_to_endpoint() - assert resp.status == 200 + assert resp.status == 201 request = Lightning.Repo.one!( from(r in ChannelRequest, where: r.channel_id == ^channel.id) ) - assert request.state == :error - assert request.completed_at != nil + assert request.state == :success + + event = + Lightning.Repo.one!( + from(e in ChannelEvent, where: e.channel_request_id == ^request.id) + ) + + assert event.request_method == "POST" + assert event.request_body_preview == nil + assert is_binary(event.request_body_hash) + assert event.request_body_size == byte_size(raw_bytes) + end + + test "valid UTF-8 body is preserved unchanged", + %{bypass: bypass, channel: channel} do + Bypass.expect_once(bypass, "GET", "/json", fn conn -> + conn + |> Plug.Conn.put_resp_header("content-type", "application/json") + |> Plug.Conn.send_resp(200, ~s({"hello":"world"})) + end) + + resp = + conn(:get, "/channels/#{channel.id}/json") + |> send_to_endpoint() + + assert resp.status == 200 + + event = + Lightning.Repo.one!( + from(e in ChannelEvent, + join: r in ChannelRequest, + on: r.id == e.channel_request_id, + where: r.channel_id == ^channel.id + ) + ) - # No event row could be persisted (the insert raised), but the request is - # still recorded so the audit log shows the attempt. - assert Lightning.Repo.aggregate( - from(e in ChannelEvent, - where: e.channel_request_id == ^request.id - ), - :count - ) == 0 + assert event.response_body_preview == ~s({"hello":"world"}) end end