EndPointBlank (Ruby)

The Ruby client for EndPointBlank: API endpoint tracking, endpoint authorization, error/request/response/log reporting, and client-side data masking — with a framework-agnostic core that runs in plain Ruby or Sinatra, plus a Rails adapter that auto-loads (railtie + middleware) when Rails is present.

Capabilities

  • Endpoint tracking — every request/response passing through the Rack middleware is reported.
  • Authorization — outbound calls to other EndPointBlank-protected services are signed (Basic client-credential or cached Bearer token), and inbound requests can be authorized against the EndPointBlank service before your action runs.
  • Error, request, response, and log reporting — background, queued, non-blocking delivery to the EndPointBlank intake API.
  • Client-side data masking (EndPointBlank::Masking / masking_rules) — strip or redact sensitive fields from payloads before they leave your process, as defense in depth on top of server-side masking.
  • Framework-agnostic coreEndPointBlank::Middleware::Rack::ReportInteraction and the writers work directly against Rack env/::Rack::Request, so the gem behaves correctly under plain Ruby, Sinatra, or any Rack app. When ::Rails is defined, a Railtie auto-inserts the middleware and wires up Rails.logger; nothing extra needs loading.

Installation

This gem is not yet published on RubyGems.org. Until it is, install it from git.

Add to your Gemfile:

gem "end_point_blank", github: "EndPointBlank/end_point_blank_rails"

(Once released to RubyGems, this collapses to gem "end_point_blank".)

Then:

bundle install

Quick start

EndPointBlank.configure do |config|
  config.client_id     = "your-client-id"
  config.client_secret  = "your-client-secret"
  config.app_name      = "my-service"
end

That's it for a Rails app — the railtie auto-inserts the reporting middleware, and every request processed by your app is tracked. For plain Ruby / Sinatra, see Framework integration below to wire up the Rack middleware yourself.

To send your first log line:

EndPointBlank::Writers::LogWriter.info("service started", { pid: Process.pid })

Configuration

EndPointBlank.configure { |c| ... } yields the EndPointBlank::Configuration singleton. Every setting listed below can be set explicitly in that block, and most also fall back to an ENDPOINTBLANK_* environment variable, then to a built-in default.

Precedence: explicit configure value > ENDPOINTBLANK_* environment variable > default.

configure setting Env var fallback Default Notes
client_id ENDPOINTBLANK_CLIENT_ID nil Used to build the Basic authorization header.
client_secret ENDPOINTBLANK_CLIENT_SECRET nil Paired with client_id.
base_url ENDPOINTBLANK_BASE_URL https://in.endpointblank.com Base for access-token, authorize, and endpoint-update APIs.
log_base_url ENDPOINTBLANK_LOG_BASE_URL https://log.endpointblank.com Base for error/request/response/log reporting APIs.
app_name ENDPOINTBLANK_APP_NAME Rails.application.name.underscore if Rails is defined, else nil Identifies your app to EndPointBlank.
env_name ENDPOINTBLANK_ENV RACK_ENV, then APP_ENV, then Rails.env if defined, else "production" (resolved per-request by SessionConfiguration.env_name, not read directly off Configuration) The environment name reported with each request/response payload.
logger A ::Logger.new($stdout, level: ::Logger::INFO), or Rails.logger under Rails (set by the railtie) Any object with .debug/.info/.warn/.error/.fatal works.
worker_count 4 Currently unused by the delayed writer (which always spins up 2 threads); reserved.
token_ttl nil Optional TTL (seconds) requested when generating a Bearer access token.
cache_ttl 300 TTL (seconds) for the authorization decision cache.
trust_proxy_headers true Whether the per-request scheme/host/port report honors X-Forwarded-Proto/-Host/-Port. See Reported base URL.
masking_rules [] Ordered list of masking rule hashes — see Data masking.
mask_hook nil Optional ->(payload, record_type_string) { payload } run after masking_rules.
version_finder nil Optional ->(request) { "1" } overriding EndPointBlank::Commands::VersionFinder's default header/param/path detection.
application_version nil Reserved for reporting your app's own version.

Note: there is also a bare environment accessor on Configuration, but it is not read by any code path in this gem (the real per-request environment name is env_name, described above) — do not rely on it.

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 gem honors X-Forwarded-Proto, X-Forwarded-Host and X-Forwarded-Port, reading the last comma-separated hop. It does this on its own, without consulting Rails' or Rack's trusted-proxy configuration, so that all five EndPointBlank clients 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 { |c| c.trust_proxy_headers = false }

With it off, the X-Forwarded-* headers are ignored entirely and scheme, host and port come from the connection 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 (it has always come 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.

configure block example

EndPointBlank.configure do |config|
  config.client_id      = "abc123"
  config.client_secret  = "s3cr3t"
  config.base_url       = "https://in.endpointblank.com"
  config.log_base_url   = "https://log.endpointblank.com"
  config.app_name       = "checkout-service"
  config.env_name       = "staging"
  config.logger         = Logger.new($stdout)
end

12-factor / env-var example

With no configure block at all (or a partial one), the same values can come entirely from the environment:

export ENDPOINTBLANK_CLIENT_ID=abc123
export ENDPOINTBLANK_CLIENT_SECRET=s3cr3t
export ENDPOINTBLANK_BASE_URL=https://in.endpointblank.com
export ENDPOINTBLANK_LOG_BASE_URL=https://log.endpointblank.com
export ENDPOINTBLANK_APP_NAME=checkout-service
export ENDPOINTBLANK_ENV=staging

Usage

Authorization

EndPointBlank::Authorization.header(base_url = nil) builds the outbound Authorization header used by the gem's own HTTP calls: a cached Bearer token covering base_url when one is available (via EndPointBlank::AccessTokens), otherwise Basic credentials built from client_id / client_secret -- which covers both giving no target and a token that could not be obtained.

EndPointBlank::Authorization.header # => "Basic ..."

# Pass the URL you are about to call, NOT a hostname.
# Strip any query string or fragment first -- intake rejects both.
EndPointBlank::Authorization.header("https://api.example.com/orders") # => "Bearer ..." if a token is cached

The argument is the URL you are about to call. intake matches it against registered base URLs by longest path prefix, so you need not know how the target registered itself -- header for https://api.example.com/orders/42 reuses a token already cached for https://api.example.com/orders. EndPointBlank::AccessTokens caches one token per base URL intake resolves to, not one per process, so a service that calls several targets holds a token for each. A URL that does not match character-for-character (a different case, a query string, an unregistered path) simply misses and mints a new token -- it never guesses.

Under Rails, protect an inbound endpoint by including the Authorized concern in a controller — it calls EndPointBlank::Commands::EndpointAuthorize.authorize(request) before the action, and raises EndPointBlank::UnauthorizedError (which you can rescue with rescue_from EndPointBlank::UnauthorizedError in ApplicationController) on a non-201 response:

class OrdersController < ApplicationController
  include EndPointBlank::Rails::Authorized
end

EndPointBlank::Commands::EndpointAuthorize.authorize sends the request's path, HTTP method, inbound Authorization header, app name, resolved endpoint version, and remote IP to #{base_url}/api/authorize, authenticating itself to intake with Basic, and caches a positive (201) result for cache_ttl seconds via EndPointBlank::Commands::AuthenticationCache. It never mints or presents a Bearer token for this call: intake already holds this service's own credential, so exchanging one to present it back would buy nothing.

Behavior change: target_hostname on the authorize call now comes from the Host header only. It previously came from request.host, which reads the last X-Forwarded-Host hop. If your app sits behind a proxy that rewrites Host (nginx's default; Caddy and most ALBs preserve it) and you registered the external hostname in the portal, either update the registered hostname to the internal one the app now reports, or configure the proxy to preserve Host. Deployments where Host and X-Forwarded-Host agree are unaffected.

Error reporting

Exceptions raised while EndPointBlank::Middleware::Rack::ReportInteraction is on the stack are reported automatically (see Framework integration). To report one manually:

begin
  risky_operation!
rescue => e
  EndPointBlank::Writers::ExceptionWriter.write(e)
  raise
end

Request/response/log reporting

Requests and responses are written automatically by the Rack middleware. Application logs are sent explicitly:

EndPointBlank::Writers::LogWriter.info("cache warmed", { keys: 42 })
EndPointBlank::Writers::LogWriter.warn("slow query", { duration_ms: 820 })
EndPointBlank::Writers::LogWriter.error("payment webhook rejected", { code: "sig_mismatch" })
EndPointBlank::Writers::LogWriter.fatal("out of workers")

All writers (RequestWriter, ResponseWriter, ExceptionWriter, LogWriter) enqueue their payload onto a bounded, in-memory queue (DelayedWriter, capacity 1000, drop-oldest under sustained backpressure) drained by two background threads that POST batches via excon. Delivery is fire-and-forget and never raises into your request cycle.

Data masking

Mask sensitive data client-side, before it leaves your process. 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 do |config|
  config.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.
  config.mask_hook = ->(payload, record_type) { payload }
end

Rules are hashes with symbol (or string) 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 source string.
  • 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 leaf 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/data are never masked (there is no log entry in the masking field map).

Framework integration

Rails

Nothing to wire up manually. When ::Rails is defined, lib/end_point_blank.rb requires EndPointBlank::Rails::Railtie, which:

  • inserts EndPointBlank::Middleware::Rack::ReportInteraction into the middleware stack right after ActionDispatch::DebugExceptions, so every request/response is reported and exceptions are captured before Rails' own exception rendering; and
  • sets Configuration.instance.logger ||= Rails.logger, so EndPointBlank.logger writes through Rails.logger unless you've already configured your own.

Optional concerns for controllers:

class ApplicationController < ActionController::Base
  rescue_from EndPointBlank::UnauthorizedError do |e|
    render json: { error: e.message }, status: e.status
  end
end

class OrdersController < ApplicationController
  include EndPointBlank::Rails::Authorized   # authorize inbound requests before each action
  include EndPointBlank::Rails::Versioned

  version ["v1", "v2"], only: [:index]
end

app_name falls back to Rails.application.name.underscore automatically, so Rails apps typically only need to configure client_id / client_secret (and app_name only to override the Rails-derived default).

Plain Ruby / Sinatra

There's no Rails to auto-load anything, so insert the Rack middleware yourself and set app_name and env_name explicitly (via configure or ENDPOINTBLANK_APP_NAME / ENDPOINTBLANK_ENV, since there's no Rails.application.name / Rails.env to infer them from):

require "sinatra"
require "end_point_blank"

EndPointBlank.configure do |config|
  config.client_id     = ENV.fetch("ENDPOINTBLANK_CLIENT_ID")
  config.client_secret = ENV.fetch("ENDPOINTBLANK_CLIENT_SECRET")
  config.app_name       = "my-sinatra-app"   # or set ENDPOINTBLANK_APP_NAME and omit this
  config.env_name       = "production"       # or set ENDPOINTBLANK_ENV / RACK_ENV and omit this
  config.logger         = Logger.new($stdout)
end

use EndPointBlank::Middleware::Rack::ReportInteraction

get "/" do
  "ok"
end

The middleware calls EndPointBlank::Rack::EnvStore.set(env), reports the request via RequestWriter, invokes the app, and — in an ensure — reports the response via ResponseWriter and clears the env store, reporting any raised exception via ExceptionWriter along the way. It reads/writes plain Rack request objects (::Rack::Request), so it works identically under any Rack-compatible server or framework, not only Sinatra.

Development

bundle install
bundle exec rspec
bundle exec rubocop

bundle exec rspec runs the full suite, including specs that assert the framework-agnostic core behaves correctly with ::Rails undefined (spec/no_rails_spec.rb, spec/generate_access_token_no_rails_spec.rb, spec/route_pattern_finder_and_version_finder_no_rails_spec.rb).

License

No LICENSE file or spec.license is currently present in this repository. Treat usage as proprietary/all-rights-reserved until a license is added, or confirm terms with the repository owners.