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 repository:

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:

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:

EndPointBlank.configure(
client_id: "my-client-id",
client_secret: "my-client-secret",
app_name: "my-app",
environment: "production"
)
defmodule MyAppWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :my_app
# ... other plugs ...
plug EndPointBlank.Plug.ReportInteraction
plug MyAppWeb.Router
end
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/0raises. 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.

SettingConfig keyEnv varDefault
API client ID:client_idENDPOINTBLANK_CLIENT_IDnil
API client secret:client_secretENDPOINTBLANK_CLIENT_SECRETnil
Authorization/update API base URL:base_urlENDPOINTBLANK_BASE_URL"https://in.endpointblank.com"
Request/response/log/error ingestion base URL:log_base_urlENDPOINTBLANK_LOG_BASE_URL"https://log.endpointblank.com"
Application identifier sent with every payload:app_nameENDPOINTBLANK_APP_NAMEnil
Deployment environment (e.g. "production"):environmentENDPOINTBLANK_ENVnil
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):trust_proxy_headers— (configure/1 only)true
Ordered list of masking rule maps (see Data masking):masking_rules— (configure/1 only)[]

Configure example (all settings)

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:

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):

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:

defmodule MyAppWeb.BooksController do
use Phoenix.Controller
plug EndPointBlank.Plug.Authorized
...
end
pipeline :api do
plug :accepts, ["json"]
plug EndPointBlank.Plug.Authorized
end

Under the hood it:

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:

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:

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:

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):

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:

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:

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:

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.)

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

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:

defmodule MyAppWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :my_app
# ... session, static, etc. ...
plug EndPointBlank.Plug.ReportInteraction
plug MyAppWeb.Router
end
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
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

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).