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

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 a **private** Hex organization (not on the public
`hex.pm` index). Add the dependency and pass your organization at fetch time:
```elixir
def deps do
[
{:end_point_blank_elixir, "~> 0.3", organization: "your-hex-org"}
]
end
```
Then authenticate the Hex CLI against your org once per machine/CI runner
(`mix hex.organization auth your-hex-org`) before `mix deps.get`.
If you don't have Hex org access yet, depend on the git repo directly:
```elixir
def deps do
[
{:end_point_blank_elixir, git: "https://github.com/EndPointBlank/end_point_blank_elixir.git", tag: "v0.6.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 are held in a singleton `EndPointBlank.Config` agent (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).
**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` |
| Reserved for future writer pooling; not currently read by any writer | `: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 resulting `source_application_environment_id` in
`EndPointBlank.RequestStore` for the rest of the request lifecycle (it's
attached to request/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.
### 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 every 100 ms, per
endpoint key. 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.
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"], state: "Current"
version_of :index, ["v0"], state: "Deprecated"
def index(conn, _params), do: ...
end
```
`EndpointRegistrar.register/1` introspects `router.__routes__/0`, merges in
any `version_of` metadata, and POSTs the endpoint list (path, HTTP method,
and `%{state => [versions]}`) to your `base_url`.
### 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`) — this package is published only to a private Hex organization.
## Links
- Source: https://github.com/EndPointBlank/end_point_blank_elixir