diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 4cf1439..fb2b575 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -84,6 +84,9 @@ jobs: with: elixir-version: "1.15" otp-version: "26" + - name: mix deps.get + working-directory: elixir + run: mix deps.get - name: Run tests working-directory: elixir run: mix test diff --git a/.gitignore b/.gitignore index cfd2fc5..d67578e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ node_modules/ __pycache__/ *.py[cod] _build/ +deps/ diff --git a/elixir/AGENTS.md b/elixir/AGENTS.md index e5d1ba6..8df26fe 100644 --- a/elixir/AGENTS.md +++ b/elixir/AGENTS.md @@ -11,13 +11,26 @@ Runtime target: Elixir 1.15 or newer. Key files: - `lib/client_signals.ex` — runtime implementation and exported API. +- `lib/client_signals/plug.ex` — `ClientSignals.Plug`, the consumer side: + reads `Fly-Client-*` request headers and attaches them as OTel span + attributes. Only defined when both optional deps are loaded (see below). - `test/client_signals_test.exs` — ExUnit suite, including shared fixture checks. +- `test/client_signals/plug_test.exs` — `ClientSignals.Plug` tests. - `mix.exs` — package metadata. ## Constraints -- No runtime dependencies. +- The core signal-detection API (`lib/client_signals.ex`) has no runtime + dependencies. +- `:plug` and `:opentelemetry_api` are declared as `optional: true` deps + solely so `ClientSignals.Plug` can exist. This is a deliberate, narrow + exception to "no runtime dependencies" — justified because + `ClientSignals.Plug` is a server-side consumer of the headers this + package produces, only used by services that already depend on Plug and + OpenTelemetry (e.g. a Phoenix app), and the whole module is skipped at + compile time (`Code.ensure_loaded?/1` guard) for anyone who doesn't have + both. Do not add further optional or hard dependencies without asking. - Do not shell out for parent-process lookup. - `detect_once/0` must cache the first detected value. - Keep `known_markers/0` aligned with `../spec/markers.json`. diff --git a/elixir/README.md b/elixir/README.md index 4272892..972eef8 100644 --- a/elixir/README.md +++ b/elixir/README.md @@ -45,6 +45,21 @@ ClientSignals.apply_headers(headers, signals, "Acme") `ClientSignals.classify_parent_name/1` are exposed for tests and advanced consumers that need the shared contract helpers. +## Server-side: recording signals on request spans + +If your application already depends on `:plug` and `:opentelemetry_api` +(e.g. a Phoenix app), `ClientSignals.Plug` reads the `Fly-Client-*` +headers off incoming requests and attaches them as `fly.client.*` +attributes on the current OTel span: + +```elixir +# in your endpoint or router +plug ClientSignals.Plug +``` + +This module is only defined when both dependencies are present, so it +has no effect on consumers that only use the header-generation API above. + ## Development ```sh diff --git a/elixir/lib/client_signals/plug.ex b/elixir/lib/client_signals/plug.ex new file mode 100644 index 0000000..e23fb55 --- /dev/null +++ b/elixir/lib/client_signals/plug.ex @@ -0,0 +1,90 @@ +if Code.ensure_loaded?(Plug.Conn) and Code.ensure_loaded?(OpenTelemetry.Tracer) do + defmodule ClientSignals.Plug do + @moduledoc """ + Reads the coarse, self-reported `Fly-Client-*` headers (as produced by + `ClientSignals.headers_for/2`) off an incoming request and attaches + them to the current request's OTel span as `fly.client.*` attributes. + + This gives services a rough human-vs-agent traffic estimate for + capacity planning and product decisions. These values are self-reported + by the caller and must never be used for gating, enforcement, or any + per-request trust decision. + + This module is only defined when both `:plug` and `:opentelemetry_api` + are loaded; both are optional dependencies of `:client_signals`. + """ + + import Plug.Conn + require OpenTelemetry.Tracer, as: Tracer + + @behaviour Plug + + @interactive_header "fly-client-interactive" + @parent_header "fly-client-parent" + @agent_header "fly-client-agent" + @agent_source_header "fly-client-agent-source" + @ci_header "fly-client-ci" + + # Headers are caller-controlled; bound how much of them we keep so a + # malicious or misbehaving client can't bloat span payloads. + @max_value_length 256 + + @impl true + def init(opts), do: opts + + @impl true + def call(conn, _opts) do + attrs = client_signal_attributes(conn) + + if attrs != [] do + Tracer.set_attributes(attrs) + end + + conn + end + + @doc false + def client_signal_attributes(conn) do + [] + |> put_bool_attr(conn, @interactive_header, "fly.client.interactive") + |> put_string_attr(conn, @parent_header, "fly.client.parent") + |> put_string_attr(conn, @agent_header, "fly.client.agent") + |> put_string_attr(conn, @agent_source_header, "fly.client.agent_source") + |> put_bool_attr(conn, @ci_header, "fly.client.ci") + end + + defp put_string_attr(attrs, conn, header, key) do + case get_req_header(conn, header) do + [value | _] -> + case String.trim(value) do + "" -> attrs + trimmed -> [{key, String.slice(trimmed, 0, @max_value_length)} | attrs] + end + + _ -> + attrs + end + end + + defp put_bool_attr(attrs, conn, header, key) do + case get_req_header(conn, header) do + [value | _] -> + case parse_bool(value) do + {:ok, bool} -> [{key, bool} | attrs] + :error -> attrs + end + + _ -> + attrs + end + end + + defp parse_bool(value) do + case value |> String.trim() |> String.downcase() do + v when v in ~w(1 t true) -> {:ok, true} + v when v in ~w(0 f false) -> {:ok, false} + _ -> :error + end + end + end +end diff --git a/elixir/mix.exs b/elixir/mix.exs index fa3d290..b25a9f2 100644 --- a/elixir/mix.exs +++ b/elixir/mix.exs @@ -7,7 +7,7 @@ defmodule ClientSignals.MixProject do version: "0.0.0", elixir: "~> 1.15", start_permanent: Mix.env() == :prod, - deps: [], + deps: deps(), description: "Privacy-safe client signals for CLI HTTP traffic.", package: package() ] @@ -19,6 +19,17 @@ defmodule ClientSignals.MixProject do ] end + # :plug and :opentelemetry_api are optional: the core signal-detection API + # has no runtime dependencies, but including apps that already depend on + # both (e.g. a Phoenix app) can use ClientSignals.Plug, which is only + # defined when both are loaded. See elixir/AGENTS.md. + defp deps do + [ + {:plug, "~> 1.14", optional: true}, + {:opentelemetry_api, "~> 1.4", optional: true} + ] + end + defp package do [ licenses: ["MIT"], diff --git a/elixir/mix.lock b/elixir/mix.lock new file mode 100644 index 0000000..902c625 --- /dev/null +++ b/elixir/mix.lock @@ -0,0 +1,7 @@ +%{ + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "opentelemetry_api": {:hex, :opentelemetry_api, "1.5.0", "1a676f3e3340cab81c763e939a42e11a70c22863f645aa06aafefc689b5550cf", [:mix, :rebar3], [], "hexpm", "f53ec8a1337ae4a487d43ac89da4bd3a3c99ddf576655d071deed8b56a2d5dda"}, + "plug": {:hex, :plug, "1.20.2", "adbee2441232412e37fbb357fd5e4cd533fdd253b29f2e1992262b0f1fb01462", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b16baf55877d60891002ffc1ce0b3ff7d6f30a38a23e02e4d4293c4ac266f136"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, +} diff --git a/elixir/test/client_signals/plug_test.exs b/elixir/test/client_signals/plug_test.exs new file mode 100644 index 0000000..27b4a4c --- /dev/null +++ b/elixir/test/client_signals/plug_test.exs @@ -0,0 +1,99 @@ +defmodule ClientSignals.PlugTest do + use ExUnit.Case, async: true + + alias ClientSignals.Plug, as: ClientSignalsPlug + + defp build_conn(headers) do + conn = Plug.Test.conn(:get, "/") + + Enum.reduce(headers, conn, fn {k, v}, conn -> + Plug.Conn.put_req_header(conn, k, v) + end) + end + + describe "client_signal_attributes/1" do + test "collects all headers when present" do + conn = + build_conn([ + {"fly-client-interactive", "true"}, + {"fly-client-parent", "shell"}, + {"fly-client-agent", "claude-code"}, + {"fly-client-agent-source", "env:CLAUDECODE"}, + {"fly-client-ci", "true"} + ]) + + attrs = ClientSignalsPlug.client_signal_attributes(conn) |> Map.new() + + assert attrs == %{ + "fly.client.interactive" => true, + "fly.client.parent" => "shell", + "fly.client.agent" => "claude-code", + "fly.client.agent_source" => "env:CLAUDECODE", + "fly.client.ci" => true + } + end + + test "returns nothing when no headers are present" do + assert ClientSignalsPlug.client_signal_attributes(build_conn([])) == [] + end + + test "only includes attributes for headers actually present" do + conn = + build_conn([{"fly-client-interactive", "false"}, {"fly-client-parent", "python"}]) + + attrs = ClientSignalsPlug.client_signal_attributes(conn) |> Map.new() + + assert attrs == %{ + "fly.client.interactive" => false, + "fly.client.parent" => "python" + } + end + + test "ignores unparseable boolean values" do + conn = build_conn([{"fly-client-interactive", "maybe"}, {"fly-client-ci", "nope"}]) + + assert ClientSignalsPlug.client_signal_attributes(conn) == [] + end + + test "ignores an empty or whitespace-only string value" do + conn = build_conn([{"fly-client-parent", " "}]) + + assert ClientSignalsPlug.client_signal_attributes(conn) == [] + end + + test "trims whitespace from string and boolean values" do + conn = + build_conn([{"fly-client-parent", " shell "}, {"fly-client-interactive", " true\n"}]) + + attrs = ClientSignalsPlug.client_signal_attributes(conn) |> Map.new() + + assert attrs == %{ + "fly.client.parent" => "shell", + "fly.client.interactive" => true + } + end + + test "truncates string values longer than the max attribute length" do + long_value = String.duplicate("a", 500) + conn = build_conn([{"fly-client-parent", long_value}]) + + attrs = ClientSignalsPlug.client_signal_attributes(conn) |> Map.new() + + assert String.length(attrs["fly.client.parent"]) == 256 + end + end + + describe "call/2" do + test "returns the conn unchanged" do + conn = build_conn([{"fly-client-interactive", "true"}]) + + assert ClientSignalsPlug.call(conn, ClientSignalsPlug.init([])) == conn + end + + test "does not raise when there are no client signal headers" do + conn = build_conn([]) + + assert ClientSignalsPlug.call(conn, ClientSignalsPlug.init([])) == conn + end + end +end