CLI & Agent

Use the BlazeRules multi-instance local ingest agent and configure agent instances from YAML.

BlazeRules ships a command-line local ingest agent that pulls records from HTTP, a tailed file, or stdin and runs them through the engine. This page covers building it and using its instances: configuration.

🚧

These are convenience binaries, not the product

BlazeRules is a library, not a service. The CLI tools exist to make local testing and small ingest setups easy; they are not a hosted runtime, and they don't change how the engine evaluates rules. For production you normally embed the engine (C++ API / Python API).

The ingest agent

There is no blazerules agent subcommand

The agent is a separate binary, blazerules_agent. The main blazerules CLI has no agent subcommand — running blazerules agent errors and points you to the standalone blazerules_agent binary documented on this page.

blazerules_agent runs one or more instances, each reading from an input source, batching records, evaluating them against a rule set, optionally de-duplicating, and writing decisions out. Build it with the agent flag:

cmake -S . -B cmake-build-release -G Ninja
cmake --build cmake-build-release --target blazerules_agent -j

For the full list of flags, defaults, and valid values, use API and CLI Values Reference.

How an instance flows

flowchart LR
  input["input: http / file_tail / stdin"] --> batch["batch: batch_size + flush_ms"]
  batch --> engine["engine: load_rules(rules)"]
  engine --> dedupe["dedupe: key_fields within ttl_seconds"]
  dedupe --> output["output: ndjson / stdout"]

Each instance loads its own rules: file and accumulates incoming records until it has batch_size of them or flush_ms elapses, then evaluates the batch in one call. When dedupe.enabled is set, records repeating the same key_fields within ttl_seconds are dropped before evaluation.

input.type or --input selects the adapter: http, file_tail, or stdin. source / --source is a metadata label attached to wrapped log records and decisions; it does not select the input adapter.

The http input is unauthenticated

The http adapter exposes POST /v1/logs with no authentication. Bind input.host to 127.0.0.1 for local use, or run it on a trusted network behind an authenticating reverse proxy — never expose it directly to untrusted callers. The agent prints a warning if it binds to 0.0.0.0.

instances: configuration reference

The agent reads a top-level instances: block (a list) from a YAML file. Each entry accepts:

KeyTypeRequiredMeaning
namestringyesInstance label.
rulespathyesRule YAML this instance evaluates against.
batch_sizeintyesRecords buffered before a forced flush.
flush_msintyesMax milliseconds to wait before flushing a partial batch.
eval_shardsintnoPool of cloned engines for parallel evaluation (default 1). Stateless rulesets only — auto-downgrades to one engine if dedupe is on or the ruleset uses windows / an unbound schema.
http_threadsintfor httphttplib's own accept/read thread pool size (default max(4, cores)); doesn't add evaluation or write concurrency.
http_queue_depthintfor httpDepth of httplib's own request queue feeding http_threads (default 256); a full queue closes the connection immediately with no HTTP response.
eval_queue_depthintfor httpDepth of the bounded queue feeding eval-worker threads (default 64); a full queue returns HTTP 429.
sink_queue_depthintfor httpDepth of the bounded queue feeding sink-worker threads (default 64); a full queue returns HTTP 503.
sink_workersintfor httpThreads that write evaluated batches to the decision log (default 1); parallelizes Arrow encoding, not just the final write.
max_request_mbintfor httpHard cap on one HTTP request body in MiB (default 256); larger requests get HTTP 413.
ack_modeenumfor httpdurable (default) acks after the write; evaluated acks right after evaluation, before the write.
servicestringnoFree-form service label attached to output.
sourcestringnoFree-form source label attached to output.
input.typeenumyeshttp | file_tail | stdin.
input.hoststringfor httpBind host (e.g. 127.0.0.1).
input.portintfor httpBind port.
input.pathpathfor file_tailFile to tail.
output.typeenumyesndjson | stdout | arrow.
output.pathpath or s3://…/prefix/for ndjson/arrowDestination file, or an s3:// prefix for stateless rolled part-object output (see below).
output.dead_letter_pathpathnoWrite malformed/skipped records as a dead-letter NDJSON file (always local NDJSON).
modelslistnoONNX models for model_score rules, each {name, path} (or name=path).
s3_roll_mbintnoFor s3:// output: roll a part after ~this many MiB (default 64).
s3_flush_secondsintnoFor s3:// output: roll + upload cadence in seconds (default 10).
dedupe.enabledboolnoTurn de-duplication on.
dedupe.key_fieldslistwith dedupeFields whose combination identifies a duplicate.
dedupe.ttl_secondsintwith dedupeWindow during which repeats are dropped.

Stateless output to S3

Set an instance's output.path (or the single-instance --output-path) to an s3://bucket/prefix/ and the agent writes rolled part objects to that prefix — bounded local staging, background upload via the same aws CLI used for s3:// rules/models, custom endpoint honored. A final part is flushed on SIGINT/SIGTERM, so rolling deploys lose nothing. Point the dashboard at the same prefix with --decision-log-dir s3://… to view it. Region/endpoint come from AWS_REGION/AWS_ENDPOINT_URL or the --aws-region / --aws-endpoint-url flags. See the S3 recipe for a full example.

Parallel evaluation for stateless rulesets

--eval-shards N (or an instance's eval_shards:, default 1) evaluates each batch across a pool of N cloned engines instead of one, raising throughput for CPU-bound rulesets. It only helps stateless rulesets. The pool is built lazily on the first batch — once the schema is bound and the ruleset compiled — and the agent prints one line to its stderr describing what it did: instance '<name>': eval sharding ON with N engines (stateless ruleset) when sharding engages, or --eval-shards ignored (...) when it falls back to a single engine. It falls back whenever dedupe is enabled, the ruleset uses windows, or the schema is still unbound, because those need shared state.

📘

stdin is supported too

The example below shows http and file_tail inputs. stdin is also a valid input.type — pipe NDJSON into the agent and route decisions to stdout for quick local experiments.

A real two-instance configuration

This is the instances: block from the repository's agent.yaml:

instances:
  - name: payments-http
    rules: rules.yaml
    batch_size: 4096
    flush_ms: 50
    service: payments-api
    source: http-json
    input:
      type: http
      host: 127.0.0.1
      port: 9480
    output:
      type: ndjson
      path: decisions-payments.ndjson
    dedupe:
      enabled: true
      key_fields: [event_id]
      ttl_seconds: 86400
  - name: checkout-log-tail
    rules: rules.yaml
    batch_size: 2048
    flush_ms: 250
    service: checkout
    source: pod-stdout
    input:
      type: file_tail
      path: app.log
    output:
      type: stdout
    dedupe:
      enabled: true
      key_fields: [event_id]
      ttl_seconds: 3600

payments-http accepts JSON over HTTP on 127.0.0.1:9480, batches up to 4096 records (or every 50 ms), and appends decisions to decisions-payments.ndjson, de-duplicating by event_id for a day. checkout-log-tail tails app.log, batches more loosely (2048 / 250 ms), and prints decisions to stdout with a one-hour dedupe window.

Tuning the HTTP pipeline

flowchart LR
  accept["accept: http-threads / http-queue-depth"] --> evalq["eval queue: eval-queue-depth"]
  evalq --> evalw["eval workers: eval-shards"]
  evalw --> sinkq["sink queue: sink-queue-depth"]
  sinkq --> sinkw["sink workers: sink-workers"]
  sinkw --> log["decision log"]

These flags only matter for --input http (or input.type: http) — stdin and file_tail read and evaluate synchronously on the instance's own thread and never touch the queues above. For an HTTP instance, a single accept loop hands each new connection to httplib's own thread pool (--http-threads, default max(4, cores)), which reads the request line, headers, and body and invokes the route handler; connections wait in an internal queue up to --http-queue-depth deep (default 256) once every thread is busy. Once a request's body is fully read, it becomes one task on the bounded eval queue (--eval-queue-depth, default 64), which max(1, --eval-shards) eval-worker threads pull from — or exactly one thread regardless of --eval-shards, whenever dedupe is enabled (see "Parallel evaluation for stateless rulesets" above for when sharding does and doesn't apply). Once evaluated, the batch becomes one task on a second bounded queue (--sink-queue-depth, default 64) feeding --sink-workers threads (default 1), which are the only threads that call into the decision-log writer.

Each stage fails differently once it's saturated:

FlagDefaultIf exceeded
--http-queue-depth256The connection is closed immediately — no HTTP response is sent, and nothing is logged (the agent doesn't register an httplib error logger).
--eval-queue-depth64HTTP 429 with {"ok":false,"error":"evaluation queue is full"}.
--sink-queue-depth64HTTP 503 with {"ok":false,"error":"output queue is full"}.
--max-request-mb256HTTP 413, checked against the declared (or actual streamed) body size before the request reaches the eval queue.

--http-threads scales with core count by default, but --eval-shards does not — out of the box, an HTTP instance on a 16-core box still evaluates on a single engine until you explicitly raise --eval-shards. That's because --http-threads only accepts and reads connections; it doesn't evaluate rules or write decisions. --eval-shards is the flag that actually parallelizes parsing and rule evaluation across cores, and — as above — only shards stateless rulesets, auto-downgrading to one engine otherwise. --sink-workers is independent again: for --output arrow, encoding a batch into Arrow array builders happens outside the output-file lock and runs fully in parallel across sink workers — only the final WriteRecordBatch and flush are serialized behind one mutex — so raising --sink-workers is a genuine throughput lever rather than added contention on a single-threaded write.

--ack-mode controls response timing rather than concurrency. durable (the default) only responds once a sink worker has actually written the batch's decisions, so a 200 means the record is durably in the decision log. evaluated responds as soon as an eval worker hands the batch to the sink queue, before it's written — lower latency, but if the agent crashes (or the write itself fails) between ack and write, that batch's decision-log entry can be lost even though the record was evaluated; on a write failure the agent still logs instance '<name>': evaluation error: <message> to its own stderr, but a caller that already received 200 never learns of it over HTTP.

As a starting point: leave --http-threads at its default, size --eval-shards to the cores you want doing rule evaluation, and raise --sink-workers if --sink-queue-depth keeps filling (visible as a rising rate of 503s). Queue-depth flags are shock absorbers for bursty traffic, not throughput levers by themselves — raising them buys burst tolerance at the cost of latency and memory, not more evaluation or write capacity.

📘

--batch-size does nothing for HTTP throughput

--batch-size only paces stdin/file_tail flushing. On the HTTP path, httplib already hands each POST body to the eval queue as a single task, and the common cases — a plain NDJSON body, a JSON array, or Arrow IPC — evaluate the whole body in one engine call regardless of --batch-size. The one exception is an instance with dedupe enabled: there, a POST's lines are canonicalized one at a time for the duplicate-key check and re-chunked into --batch-size-sized engine calls, but that's still one task end to end, so it doesn't change how many requests are handled concurrently. Tuning --batch-size for HTTP throughput is a no-op either way — use the flags above instead.

CLI flags

Run a multi-instance config:

./cmake-build-release/blazerules_agent --config agent.yaml

Or run one instance directly from flags:

./cmake-build-release/blazerules_agent \
  --name payments-http \
  --rules rules.yaml \
  --input http \
  --host 127.0.0.1 \
  --port 9480 \
  --batch-size 4096 \
  --flush-ms 50 \
  --output ndjson \
  --output-path decisions-payments.ndjson \
  --service payments-api \
  --source http-json \
  --dedupe-key event_id \
  --dedupe-ttl-seconds 86400

Single-instance flags are --rules, --input stdin|file_tail|http, --path, --host, --port, --batch-size, --flush-ms, --eval-shards, --http-threads, --http-queue-depth, --eval-queue-depth, --sink-queue-depth, --sink-workers, --max-request-mb, --ack-mode, --output stdout|ndjson|arrow, --output-path, --dead-letter-path, repeated --model name=path, --service, --source, --aws-region, --aws-endpoint-url, repeated --dedupe-key, and --dedupe-ttl-seconds. Every binary also accepts --version, which prints the version string and exits.

--output arrow writes decisions as a compact binary Arrow IPC stream to --output-path instead of NDJSON. It carries the same columns (ts_ms, instance, batch_row, ruleset_version, matched, decision, score, risk_band, winning_rule_id), is several times smaller on disk, and reads directly in pyarrow/DuckDB/pandas (pyarrow.ipc.open_stream(path).read_all()).

Watching a running agent

Evaluation errors on stderr

New in 0.5.0, the agent surfaces evaluation failures on its own terminal. When a record batch fails to evaluate — for example a missing lookup file, a schema mismatch, or an unreadable model — the agent returns HTTP 500 from POST /v1/logs and now writes instance '<name>': evaluation error: <message> to its own stderr, throttled to about once per second so a persistently broken config stays visible without flooding the log. Earlier versions surfaced these failures only in the per-request 500 body, so a misconfigured agent looked silent from the terminal. If the decision log stays empty, watch the agent's stderr for these lines.

Input-throughput sidecar

Whenever --output-path (or an instance's output.path) is set, the agent maintains a companion <output-path>.stats file, rewritten at most once per second, holding a single JSON object like {"ts_ms": …, "input_bytes": …, "input_records": …}. Both counters are cumulative since the instance started, and the dashboard reads this sidecar to show input throughput alongside the decision log.

Next steps


Did this page help you?