Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ node_modules/
__pycache__/
*.py[cod]
_build/
deps/
15 changes: 14 additions & 1 deletion elixir/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
15 changes: 15 additions & 0 deletions elixir/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
90 changes: 90 additions & 0 deletions elixir/lib/client_signals/plug.ex
Original file line number Diff line number Diff line change
@@ -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
Comment thread
dangra marked this conversation as resolved.

@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
13 changes: 12 additions & 1 deletion elixir/mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
]
Expand All @@ -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"],
Expand Down
7 changes: 7 additions & 0 deletions elixir/mix.lock
Original file line number Diff line number Diff line change
@@ -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"},
}
99 changes: 99 additions & 0 deletions elixir/test/client_signals/plug_test.exs
Original file line number Diff line number Diff line change
@@ -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
Loading