Packages
EndPointBlank Elixir client library: authorization plus request/response/error/log ingestion (with masking, batching, timeouts, and a bounded queue) — also used for self-monitoring.
Current section
Files
Jump to
Current section
Files
end_point_blank_elixir
README.md
README.md
# EndPointBlank (Elixir)
EndPointBlank client for Elixir / Phoenix apps — endpoint tracking and authorization, request/response/error/log reporting, and client-side data masking, all reporting back to the EndPointBlank API.
## Installation
This package is published to the public [hex.pm](https://hex.pm/packages/end_point_blank_elixir)
repository:
```elixir
def deps do
[
{:end_point_blank_elixir, "~> 0.7.0"}
]
end
```
Pin to the patch level (`~> 0.7.0`, not `~> 0.7`): before 1.0, breaking
changes ship in minor releases, so `~> 0.7` would accept a future 0.8.0.
Or depend on a release tag of the git repo directly:
```elixir
def deps do
[
{:end_point_blank_elixir, git: "https://github.com/EndPointBlank/end_point_blank_elixir.git", tag: "v0.7.0"}
]
end
```
The library starts its own supervision tree (`EndPointBlank.Application`) as
soon as it's listed as a dependency — no extra child spec to add to your app.
## Quick start
Configure credentials (typically in `application.ex`'s `start/2`, before your
endpoint starts) and wire in the two plugs:
```elixir
EndPointBlank.configure(
client_id: "my-client-id",
client_secret: "my-client-secret",
app_name: "my-app",
environment: "production"
)
```
```elixir
defmodule MyAppWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :my_app
# ... other plugs ...
plug EndPointBlank.Plug.ReportInteraction
plug MyAppWeb.Router
end
```
```elixir
defmodule MyAppWeb.Router do
use MyAppWeb, :router
pipeline :api do
plug :accepts, ["json"]
plug EndPointBlank.Plug.Authorized
end
end
```
With just this, every request/response pair is reported to EndPointBlank, and
every request is authorized against your configured application before it
reaches your controllers.
## Configuration
All settings live in a singleton `EndPointBlank.Config` store (started by
`EndPointBlank.Application`), set via `EndPointBlank.configure/1`, and read
back via `EndPointBlank.Config.get/0`. Six of them also fall back to
`ENDPOINTBLANK_*` environment variables so you can run without any
Elixir-side configuration at all (e.g. purely env-driven deployments).
Writes go through an Agent, which serialises them. Reads do not: `Config.get/0`
is a lock-free ETS lookup performed in the calling process, because it sits in
the hot path of every inbound request and every outbound write. Nothing your
app does can queue behind a config write, and a busy config process cannot
take a request down with it. If the store is unavailable — the application is
not started, or the config process is down — `Config.get/0` **raises**. It
does not fall back to a blank config: that would mean authorizing against
`nil` credentials and shipping telemetry to the default base URL because a
process happened to be down.
**Precedence** (per setting, resolved fresh on every `Config.get/0` call —
the env var is never cached): **explicit `configure/1` value > `ENDPOINTBLANK_*`
env var > built-in default**.
| Setting | Config key | Env var | Default |
|---|---|---|---|
| API client ID | `:client_id` | `ENDPOINTBLANK_CLIENT_ID` | `nil` |
| API client secret | `:client_secret` | `ENDPOINTBLANK_CLIENT_SECRET` | `nil` |
| Authorization/update API base URL | `:base_url` | `ENDPOINTBLANK_BASE_URL` | `"https://in.endpointblank.com"` |
| Request/response/log/error ingestion base URL | `:log_base_url` | `ENDPOINTBLANK_LOG_BASE_URL` | `"https://log.endpointblank.com"` |
| Application identifier sent with every payload | `:app_name` | `ENDPOINTBLANK_APP_NAME` | `nil` |
| Deployment environment (e.g. `"production"`) | `:environment` | `ENDPOINTBLANK_ENV` | `nil` |
| App version string (e.g. a git SHA), sent as `app_version` on endpoint registration | `:application_version` | — (`configure/1` only) | `nil` |
| Custom 1-arity API-version detector, `fn conn -> version end` | `:version_finder` | — (`configure/1` only) | `nil` |
| Access-token TTL in seconds (sent to `GenerateAccessToken`) | `:token_ttl` | — (`configure/1` only) | `nil` |
| Post-rule masking hook, `fn payload, record_type -> payload end` | `:mask_hook` | — (`configure/1` only) | `nil` |
| Write mode: `:direct` (synchronous HTTP per payload) or `:delayed` (batched background queue) | `:log_mode` | — (`configure/1` only) | `:direct` |
| Max concurrent writes `EndPointBlank.Writers.DelayedWriter` performs per flush | `:worker_count` | — (`configure/1` only) | `4` |
| Authorization-cache TTL in seconds (`EndPointBlank.AuthCache`) | `:cache_ttl` | — (`configure/1` only) | `300` |
| Whether the per-request `scheme`/`host`/`port` report honors `x-forwarded-proto`/`-host`/`-port` (see [Reported base URL](#reported-base-url)) | `:trust_proxy_headers` | — (`configure/1` only) | `true` |
| Ordered list of masking rule maps (see [Data masking](#data-masking)) | `:masking_rules` | — (`configure/1` only) | `[]` |
### Configure example (all settings)
```elixir
EndPointBlank.configure(
base_url: "https://in.endpointblank.com",
log_base_url: "https://log.endpointblank.com",
client_id: "my-client-id",
client_secret: "my-client-secret",
app_name: "my-app",
environment: "production",
application_version: System.get_env("GIT_SHA"),
log_mode: :delayed,
token_ttl: 3600,
cache_ttl: 300,
trust_proxy_headers: true,
version_finder: fn conn -> Plug.Conn.get_req_header(conn, "x-api-version") |> List.first() end
)
```
### Reported base URL
Every request payload carries the base URL the *caller* used, as three separate
fields — `scheme`, `host` and `port`. A field that cannot be resolved is omitted
rather than sent as null. EndPointBlank uses these to fill in an application
environment's base URL for you, instead of asking someone to type it.
By default the library honors `x-forwarded-proto`, `x-forwarded-host` and
`x-forwarded-port`, reading the **last** comma-separated hop, straight off
`conn.req_headers`. Plug has no notion of a trusted proxy unless your
application installs `Plug.RewriteOn` itself, which is why this client could not
previously see through a load balancer at all. It resolves the headers the same
way the Ruby, JS, Python and Java clients do, so all five answer identically for
the same request.
**Turn this off if your application is reachable directly, with no proxy in
front of it** — or if you would simply rather report nothing than report
something a caller could influence:
```elixir
EndPointBlank.configure(trust_proxy_headers: false)
```
With it off, the `x-forwarded-*` headers are ignored entirely and `scheme`,
`host` and `port` come from the conn and the `host` header only.
It defaults to `true` because the alternative is worse for almost everyone. Most
production deployments sit behind an ALB, nginx, Caddy or an Ingress, and a
client that ignored the forwarded headers there would not report *nothing* — it
would confidently report an internal hostname on an internal port. `host` is
caller-controlled either way (`conn.host` comes from the `host` header), and
none of these three values is ever used as an identity or authorization key, so
the worst case is a wrong *suggestion* that an admin has to approve.
### 12-factor / env-var example
Only these six settings have an env-var fallback; everything else must be set
via `EndPointBlank.configure/1` (there's no `:log_mode` or `:masking_rules`
env var, for example):
```bash
export ENDPOINTBLANK_CLIENT_ID="my-client-id"
export ENDPOINTBLANK_CLIENT_SECRET="my-client-secret"
export ENDPOINTBLANK_APP_NAME="my-app"
export ENDPOINTBLANK_ENV="production"
export ENDPOINTBLANK_BASE_URL="https://in.endpointblank.com"
export ENDPOINTBLANK_LOG_BASE_URL="https://log.endpointblank.com"
```
With just the env vars set, you can skip `EndPointBlank.configure/1` entirely
(or call it with only the settings that don't have an env fallback, like
`log_mode:`).
## Usage
### Authorization
`EndPointBlank.Plug.Authorized` calls the EndPointBlank `/api/authorize`
endpoint for the current request and halts with a `401` (authorization
denied) or `503` (service unavailable) JSON response on failure. It can be
used as a controller plug or in a router pipeline:
```elixir
defmodule MyAppWeb.BooksController do
use Phoenix.Controller
plug EndPointBlank.Plug.Authorized
...
end
```
```elixir
pipeline :api do
plug :accepts, ["json"]
plug EndPointBlank.Plug.Authorized
end
```
Under the hood it:
- Resolves the route pattern via `EndPointBlank.Phoenix.RoutePatternFinder`
(falls back to `conn.request_path` if no Phoenix router is present) and the
API version via `EndPointBlank.VersionFinder`.
- Authenticates to intake with `Authorization: Basic <client_id:client_secret>`
(`EndPointBlank.Authorization.basic_header/0`). This call never presents a
Bearer token — intake already holds this service's credential, so minting
one to present it back would be a hop that buys nothing, and with no Bearer
there is nothing that can go stale for a `401` to retry.
- Caches successful authorizations for up to `:cache_ttl` seconds
(`EndPointBlank.AuthCache`), keyed on the caller's own auth header, path,
HTTP method, and `app_name` — repeat calls skip the network round trip.
- Stores the `source_application_environment_id` from the response's `data`
list in `EndPointBlank.RequestStore` for the rest of the request lifecycle
(it's attached to response/log/error payloads).
`EndPointBlank.UnauthorizedError` is available for your own code to raise on
authorization failures. `EndPointBlank.Plug.ReportInteraction` (below)
specifically re-raises it *without* sending it to the error-reporting
endpoint, since the authorization flow already reports the denial itself.
### Calling another EndPointBlank-protected service
`EndPointBlank.Authorization.header/1` is also a public building block for
your own outbound calls to *other* services protected by EndPointBlank — not
just the intake calls above. Pass the URL you are about to call, **not a
hostname**, with any query string or fragment stripped first — intake
normalizes the base URL and matches it against registered base URLs by
longest path prefix, so you do not need to know how the target registered
itself:
```elixir
EndPointBlank.Authorization.header("https://api.example.com/orders")
# "Bearer <token>" (minting one via EndPointBlank.AccessTokens if none is
# held yet) or "Basic <client_id:client_secret>" if no token could be
# obtained.
```
`EndPointBlank.AccessTokens` caches one token per application environment,
keyed on the canonical base URL intake resolves the request to — not on the
URL you passed — so a service that calls several targets holds a token for
each. Called with no argument (or `nil`), `header/1` always returns the Basic
form; that is what every call this SDK makes to intake itself uses.
#### Finding out why a token could not be minted
`AccessTokens.token/1` answers `nil` for every failure, and `header/1` falls
back to Basic — deliberately, so an intake outage costs a fallback rather than
the request. But not every failure is an outage: intake answers **401** when
the API credential itself has been rejected, and that is permanent until
someone re-issues it. `AccessTokens.last_failure/1` reports the last failure
for a URL so a caller can tell them apart and alarm on the one that will not
fix itself:
```elixir
case EndPointBlank.AccessTokens.last_failure("https://api.example.com/orders") do
nil -> :ok
# Permanent — retrying changes nothing.
:credential_rejected -> alarm("re-issue the EndPointBlank credential")
{:request_rejected, status} -> alarm("intake refused the request: #{status}")
# Transient — worth trying again.
{:server_error, _status} -> :ok
{:transport_error, _reason} -> :ok
end
```
`{:server_error, status}` also covers a 2xx the SDK cannot read an access
token out of — an undecodable body, or one carrying no `token` or no
`base_url`. The status is the real one intake sent; *why* a 2xx was unusable
is in the log line rather than in the return value.
Classification is on the HTTP status alone; the body never overrides a status
that was actually received. A 401 whose body is not JSON — which is what a
proxy or gateway in front of intake answers — is still `:credential_rejected`.
`{:transport_error, reason}` means no HTTP status was obtained at all.
A successful mint clears the record, and only the 64 most recently failed
URLs are held — ask about a URL you just called and it will be there.
`EndPointBlank.Commands.GenerateAccessToken.generate_result/1`
is the same distinction one layer down, for callers that mint directly:
`{:ok, payload}` or `{:error, reason}` with the same reasons.
`generate/1` still answers payload-or-`nil`.
### Request/response/log reporting
`EndPointBlank.Plug.ReportInteraction` reports every request/response pair
and any unhandled exception. Place it early in your endpoint, before routing:
```elixir
defmodule MyAppWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :my_app
plug EndPointBlank.Plug.ReportInteraction
plug MyAppWeb.Router
end
```
It generates a per-request UUID (`EndPointBlank.RequestStore`), writes the
request immediately via `EndPointBlank.Writers.RequestWriter`, registers a
`before_send` callback that writes the response via
`EndPointBlank.Writers.ResponseWriter`, and — for any exception that
propagates up (other than `EndPointBlank.UnauthorizedError`) — reports it via
`EndPointBlank.Writers.ExceptionWriter` before re-raising, so your normal
error handling / `Plug.ErrorHandler` still runs.
Request and response bodies are JSON-encoded and truncated to 1024 bytes
before being sent.
For structured application logs, call `EndPointBlank.Writers.LogWriter`
directly from anywhere in your app (it picks up the current request's UUID
from `RequestStore` automatically, if any):
```elixir
EndPointBlank.Writers.LogWriter.info("Fetching books list")
EndPointBlank.Writers.LogWriter.warn("Slow query", %{duration_ms: 820})
EndPointBlank.Writers.LogWriter.error("Payment provider timeout", %{provider: "stripe"})
EndPointBlank.Writers.LogWriter.fatal("Out of retries", %{job_id: job.id})
```
All four writers (`RequestWriter`, `ResponseWriter`, `ExceptionWriter`,
`LogWriter`) dispatch through `EndPointBlank.Writers`, honoring `:log_mode`:
- `:direct` (default) — sends synchronously via `EndPointBlank.Writers.DirectWriter`.
- `:delayed` — enqueues onto `EndPointBlank.Writers.DelayedWriter`, a
`GenServer` that batches up to 4 payloads per flush, per endpoint key, and
flushes once a second. Each key's queue is capped at 1,000 payloads; under a
sustained intake outage the oldest payloads for that key are dropped (and a
warning logged) rather than growing memory unbounded.
A batch that fails costs that batch and nothing more. Every way a flush can
fail — a raise anywhere under `DirectWriter.write/2`, or a `GenServer.call`
timeout that exits its caller — is caught, logged at `error` level once per
failing flush (with a running count of consecutive failures), and backed off:
the flush interval doubles from 1 s up to a 30 s ceiling while failures
continue, and resets on the first clean flush. Telemetry delivery can degrade;
it cannot take your application's supervision tree with it.
All outbound HTTP goes through `EndPointBlank.Http.post/3`, which retries up
to 3 times (200 ms apart) on network error, with a 3 s connect timeout and a
5 s receive timeout per attempt, so a hung intake can never block the caller
indefinitely.
### Endpoint registration (Phoenix)
Register your Phoenix router's endpoints (and any per-action version
metadata) with EndPointBlank at application startup:
```elixir
defmodule MyApp.Application do
use Application
def start(_type, _args) do
EndPointBlank.configure(client_id: "...", client_secret: "...", app_name: "my-app")
EndPointBlank.Phoenix.EndpointRegistrar.register(MyAppWeb.Router)
children = [MyAppWeb.Endpoint]
Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
end
end
```
Declare per-action version metadata on a controller with
`EndPointBlank.Phoenix.Versioned`:
```elixir
defmodule MyAppWeb.BooksController do
use Phoenix.Controller
use EndPointBlank.Phoenix.Versioned
version_of :index, ["v1", "v2"]
version_of :index, ["v0"]
def index(conn, _params), do: ...
end
```
`version_of/2` takes an action and the list of versions it serves; repeated
calls for the same action merge, deduplicated, in declaration order. Lifecycle
state (Current, Deprecated, ...) is **not** declared in code — it is managed in
the EndPointBlank portal, so changing it does not require a deploy.
`EndpointRegistrar.register/1` introspects `router.__routes__/0`, merges in
any `version_of` metadata, and POSTs the versioned endpoints (path, HTTP
method, and the list of versions) to `<base_url>/api/application_updates`,
alongside the app name, hostname, environment and application version. Routes
whose action has no `version_of` declaration are not registered.
### Data masking
Mask sensitive data **before it leaves your app**. Configure an ordered list
of rules; each rule targets one field and masks by a JSONPath, a regex, or
both. (Server-side intake also masks independently, so this is defense in
depth.)
```elixir
EndPointBlank.configure(
masking_rules: [
# Replace any "ssn" field at any depth in the request body.
%{target: "request_body", path: "$..ssn", replacement_value: "***"},
# Keep first/last 4 of a card number in error messages via backreferences.
%{target: "error_message", regex: "(\\d{4})-\\d{4}-\\d{4}-(\\d{4})", replacement_value: "$1-****-****-$2"}
],
# Optional: runs after the rules; last chance to transform the payload.
mask_hook: fn payload, record_type -> payload end
)
```
Rules are maps with atom keys.
**Rule fields**
- `target` — exactly one of `"request_body"`, `"request_headers"`, `"path"`, `"response_body"`, `"error_message"`.
- `path` — an optional JSONPath (supported subset: `$`, `.name`, `['name']`, `[n]`, `.*` / `[*]`,
and `..name` for recursive descent). Keys are case-sensitive.
- `regex` — an optional regular expression.
- `replacement_value` — the replacement string (default `"..."`).
**Semantics — path scopes, regex matches within.** With only a `path`, the selected node is replaced
entirely. With only a `regex`, every matching string is replaced. With both, the regex is applied
only within the path-selected node(s). When a `regex` is present, `replacement_value` supports
backreferences: `$1`, `$2`, … insert capture groups (`$0` the whole match; `$$` for a literal `$`).
Stacktraces and log messages are never masked.
A bad regex or an unparseable path makes that rule a no-op rather than raising — masking never
breaks the request it's protecting.
## Framework integration
The SDK ships two `Plug` modules and a Phoenix-only registrar/versioning
pair; nothing requires Phoenix specifically except
`EndPointBlank.Phoenix.RoutePatternFinder`, `EndPointBlank.Phoenix.Versioned`,
and `EndPointBlank.Phoenix.EndpointRegistrar` (any plain-`Plug` app can still
use `Plug.Authorized` / `Plug.ReportInteraction`, just without route-pattern
resolution or endpoint registration).
Full endpoint wiring:
```elixir
defmodule MyAppWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :my_app
# ... session, static, etc. ...
plug EndPointBlank.Plug.ReportInteraction
plug MyAppWeb.Router
end
```
```elixir
defmodule MyAppWeb.Router do
use MyAppWeb, :router
pipeline :api do
plug :accepts, ["json"]
plug EndPointBlank.Plug.Authorized
end
scope "/api", MyAppWeb do
pipe_through :api
resources "/books", BooksController
end
end
```
```elixir
defmodule MyApp.Application do
use Application
def start(_type, _args) do
EndPointBlank.configure(
client_id: System.fetch_env!("ENDPOINTBLANK_CLIENT_ID"),
client_secret: System.fetch_env!("ENDPOINTBLANK_CLIENT_SECRET"),
app_name: "my-app",
environment: Application.get_env(:my_app, :environment)
)
EndPointBlank.Phoenix.EndpointRegistrar.register(MyAppWeb.Router)
children = [MyAppWeb.Endpoint]
Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
end
end
```
## Development
```bash
mix deps.get
mix test
mix compile --warnings-as-errors
```
Layout:
```
lib/end_point_blank.ex # configure/1, version/0
lib/end_point_blank/config.ex # settings + ENDPOINTBLANK_* env fallback
lib/end_point_blank/authorization.ex # Authorization header builder
lib/end_point_blank/auth_cache.ex # ETS-backed authorization result cache
lib/end_point_blank/access_tokens.ex # per-application-environment access-token cache, keyed on base URL
lib/end_point_blank/request_store.ex # per-process request-scoped state
lib/end_point_blank/version_finder.ex # API-version detection from a conn
lib/end_point_blank/masking.ex # + masking/json_path.ex
lib/end_point_blank/http.ex # shared HTTP client w/ retries + timeouts
lib/end_point_blank/commands/ # EndpointAuthorize, EndpointUpdate, GenerateAccessToken
lib/end_point_blank/writers/ # Direct/Delayed writers + Request/Response/Log/ExceptionWriter
lib/end_point_blank/plug/ # Authorized, ReportInteraction
lib/end_point_blank/phoenix/ # EndpointRegistrar, Versioned, RoutePatternFinder
test/ # ExUnit test suite
```
`mix docs` (via `ex_doc`, dev-only dependency) builds API reference docs into `doc/`.
## License
Proprietary. See `mix.exs` (`LicenseRef-Proprietary`).
## Links
- Source: https://github.com/EndPointBlank/end_point_blank_elixir