omnifeed reads OMNIFEED_-prefixed environment variables. You usually set only three:
OMNIFEED_API_KEY, OMNIFEED_CRAWL4AI_URL and, for search, OMNIFEED_SEARXNG_URL.
The binary reads every variable below. The Apple container launcher has a few
OMNIFEED_ variables of its own, for container names, host ports and images, which
the binary never sees. They are in apple-container.md.
Egress. Reddit and the generic fallback reach the web through crawl4ai. Six
engines call their upstream directly: Hacker News reads hn.algolia.com, GitHub reads
api.github.com, Bluesky reads public.api.bsky.app, Discourse reads each host in
OMNIFEED_DISCOURSE_HOSTS, Stack Exchange reads api.stackexchange.com, and Twitter/X
reads api.fxtwitter.com (or OMNIFEED_TWITTER_FXTWITTER_URL), cdn.syndication.twimg.com,
api.vxtwitter.com and t.co. If outbound traffic may reach only crawl4ai, those six
break; Twitter/X then
falls back to crawl4ai on the x.com URL. Restrict crawl4ai’s own egress to public
addresses with a network policy or firewall: its browser follows redirects that omnifeed
cannot check before they are fetched (SECURITY.md).
| Variable | Default | Purpose |
|---|---|---|
OMNIFEED_API_KEY |
(unset) | Bearer token for /crawl, /search and /mcp. If unset, omnifeed refuses to start unless OMNIFEED_DEV_NO_AUTH=true. Stdio MCP ignores it. |
OMNIFEED_CRAWL4AI_URL |
(required) | crawl4ai endpoint. Reddit and the generic fallback fetch through it. If empty, omnifeed exits at startup. |
OMNIFEED_CRAWL4AI_TOKEN |
(unset) | Bearer token sent to crawl4ai, matching its CRAWL4AI_API_TOKEN. crawl4ai binds beyond loopback only when a token is set. Unset sends no Authorization header. |
OMNIFEED_SEARXNG_URL |
(unset) | SearXNG base URL, e.g. http://searxng:8080. Unset hides web_search and /search. The instance must enable the json format. |
OMNIFEED_DEV_NO_AUTH |
false |
Run the HTTP transports with no auth when no key is set. Local use only. Ignored if a key is set. |
OMNIFEED_LISTEN_ADDR |
:8080 |
HTTP listen address for /crawl and /search |
OMNIFEED_MCP_LISTEN_ADDR |
:8081 |
MCP HTTP/SSE listen address |
OMNIFEED_MCP_STDIO |
false |
Run MCP over stdio, same as --mcp-stdio |
OMNIFEED_METRICS_ADDR |
:9090 |
Prometheus and health listen address |
OMNIFEED_CRAWL4AI_TIMEOUT |
90s |
Per-call timeout to crawl4ai |
OMNIFEED_CRAWL4AI_KEEP_LINKS |
true |
Keep link anchor text and external links in fetched markdown. false strips them, which loses link-dense content like HN titles. |
OMNIFEED_CRAWL4AI_PRUNE_THRESHOLD |
0.48 |
PruningContentFilter cutoff, 0 to 1, for the generic engine. Raise it to strip more boilerplate from noisy pages. Lower it to keep more. |
OMNIFEED_CRAWL4AI_WAIT_UNTIL |
domcontentloaded |
crawl4ai page-ready signal: domcontentloaded, load, networkidle or commit. domcontentloaded fires before client-side frameworks hydrate, so JS-only SPAs can come back empty. networkidle waits for them and slows every page. |
OMNIFEED_CRAWL4AI_SCAN_FULL_PAGE |
false |
Scroll the full page before extraction by default (crawl4ai scan_full_page). In benchmarks it gained content only on append-style infinite-scroll feeds, tripled latency on every page, and corrupted virtualized pages through an open crawl4ai bug. Prefer the per-request scan_full_page argument on fetch_url or POST /crawl?scan_full_page=true. Either overrides this setting. |
OMNIFEED_CRAWL4AI_SCROLL_DELAY |
0.5 |
Seconds between scroll steps during a full-page scan. Sent only when the scan is on. |
OMNIFEED_CRAWL4AI_REMOVE_OVERLAYS |
false |
Send crawl4ai’s remove_overlay_elements. Leave it off. It deletes every large fixed- or absolute-position element, which empties pages whose content lives in one: Wikipedia and several news fronts return only their <title>. remove_consent_popups handles cookie modals and stays on regardless. |
OMNIFEED_CRAWL4AI_DELAY_BEFORE_HTML |
0.1 |
Seconds to wait after the page-ready signal before extracting HTML (crawl4ai delay_before_return_html), on every crawl. Raise it, e.g. to 1.0, if pages that render late come back thin. |
OMNIFEED_CRAWL4AI_EXCLUDED_SELECTOR |
.sidebar,.toc,#toc,.related,.newsletter,.cookie-banner,[aria-label*='cookie'],svg title,[class*='icon'] title,.highcharts-axis-labels,#didomi-host,#onetrust-consent-sdk,#usercentrics-root,.truste_box_overlay |
CSS selectors the generic engine drops before extraction (crawl4ai excluded_selector). The default matches only page chrome and text that is never content: SVG and icon-font labels, chart axis labels, and consent-manager roots. It can’t remove article text. Your list replaces it. Empty keeps the default. If exclusion empties a page, because its content was a #toc or .sidebar, the crawl retries once without it. |
OMNIFEED_CRAWL4AI_USER_AGENT |
Chrome 156 on Linux | Browser identity sent as crawl4ai user_agent. crawl4ai’s own default is a malformed Chrome/116 string that Cloudflare challenges. Keep the version in step with the Chromium bundled in your crawl4ai image (156 in 0.9.5). A page blocked under this identity is retried once under crawl4ai’s default, because Akamai-fronted sites accept that one and reject the modern string. A Cloudflare challenge is not: Cloudflare rejects that identity. Set it empty to use crawl4ai’s own and skip that retry. |
OMNIFEED_CRAWL4AI_STEALTH |
true |
Send crawl4ai enable_stealth (playwright-stealth patches). |
OMNIFEED_CRAWL4AI_CHALLENGE_WAIT |
8s |
Wait up to this long for a self-clearing Cloudflare challenge (“Just a moment…”) to pass before extraction. Pages without one pay nothing. The wait is a CSS selector (wait_for), because crawl4ai 0.9.5 refuses a JavaScript one from a request. A page with no visible element (an empty page, an XML feed) runs the wait out too, so a wait that runs out is followed by one crawl without it, and the page that comes back decides: still a Cloudflare challenge fails as captcha; anything else is handled as a normal page. Longer waits rarely clear more and make every blocked page that much slower to fail. 0 disables. Max 60s. |
OMNIFEED_CRAWL4AI_MIN_PROSE_CHARS |
100 |
Reject a rendered page as thin_content when its readable text, without link targets, URLs and markdown syntax, is shorter than this. Catches navigation-only shells and empty app frames that would otherwise return as success. 0 disables. |
OMNIFEED_CRAWL4AI_TARGET_ELEMENTS |
(unset, off) | Comma-separated CSS selectors. When set, the generic engine extracts markdown only from matching containers (crawl4ai target_elements). Good on article and repo pages, but a page with no match returns empty content, and the thin-content guard reports an error. Test it on your own URLs first. A starting list: article, main, [role=main], .markdown-body, .post-content, #content. |
OMNIFEED_SEARXNG_TIMEOUT |
15s |
Per-query timeout to SearXNG |
OMNIFEED_SEARXNG_SITE_ENGINES |
(unset, whole pool) | Comma-separated SearXNG engines to query when the caller passes a valid site filter, e.g. privacywall,google cse. SearXNG forwards site: to its engines. Some honor it, some ignore it and return unrelated pages, and some return zero results. List the ones that honor it. Unset queries every engine. |
OMNIFEED_SEARXNG_DELAY |
0 (off) |
Minimum gap between queries to SearXNG. This paces the engines behind SearXNG: each query fans out to every enabled engine, so each engine sees omnifeed’s query rate, and one that finds it bot-like suspends itself or serves a CAPTCHA. A gap alone doesn’t bound a burst, so pair it with OMNIFEED_SEARXNG_QUOTA. |
OMNIFEED_SEARXNG_QUOTA |
0 (off) |
Maximum queries in any rolling OMNIFEED_SEARXNG_QUOTA_WINDOW. Engines enforce two limits. One measured pool answered at a 3s gap from a quiet start, then blocked after about 20 queries in 85s because it counts requests per window. The delay sets the gap and the quota bounds the burst. Measure both on your own pool; the engines’ daily and hourly budgets are in rate limits. |
OMNIFEED_SEARXNG_QUOTA_WINDOW |
90s |
Rolling window for OMNIFEED_SEARXNG_QUOTA. Ignored when the quota is 0. Must be above 0 when the quota is set. |
OMNIFEED_SEARXNG_CONCURRENCY |
0 (off) |
Maximum searches in flight to SearXNG across all replicas. A nonzero OMNIFEED_SEARXNG_DELAY alone serializes admissions, so the whole deployment runs one search at a time. Above 0, the delay spaces sends and this sets the bound. With Redis, slots are TTL leases, so a pod that dies mid-search frees its slot. On one pool (2026-08-24), 16 concurrent ran clean and 24 got the strictest engine suspended for 1200s. Leave margin: overshooting costs a 20-minute engine outage, not one failed query. A single instance needs no Redis. With several, set OMNIFEED_REDIS_URL, or N instances send N times this. |
OMNIFEED_SEARXNG_MAX_WAIT |
15s |
How long a query may wait in the pacing limiter before it gives up. A fanned-out caller that sees context deadline exceeded hit this queue limit, not a slow upstream. Raise it to trade latency for completions. |
OMNIFEED_REDIS_URL |
(unset, off) | Share rate-limiter state through Redis, e.g. redis://user:pass@redis:6379/2, or rediss:// for TLS. Unset, each replica paces alone, so N replicas send up to N times the configured rate, and OMNIFEED_SEARXNG_QUOTA and OMNIFEED_PER_DOMAIN_DELAY apply per pod. Set, the delay and quota count once for the deployment. The Reddit method chain also shares its holds through it (see OMNIFEED_REDDIT_METHODS). If Redis is unreachable, the limiters pace in process and crawls keep working. OMNIFEED_PER_DOMAIN_CONCURRENCY stays per pod by design, so the replica count multiplies it. |
OMNIFEED_REDIS_KEY_PREFIX |
omnifeed:ratelimit |
Key namespace, so several deployments can share one Redis without counting each other’s requests. Every key has a TTL, which matters on a shared noeviction instance. |
OMNIFEED_REDIS_TIMEOUT |
250ms |
Budget for one Redis operation. An acquisition can wait minutes for a quota window, and only each Redis call inside it gets this budget. On a breach the limiter paces in process for a 30s cooldown before trying Redis again, so a dead Redis costs one timeout per cooldown, not one per request. |
OMNIFEED_SEARCH_AUDIT |
off |
Per-search audit log: off, summary or full. It is a data feed, not a log level. Putting it behind debug would force every component’s debug output on, and invite sampling, which biases the per-engine statistics. summary logs one line per search with query_id, query, site, time_range, total, duration_ms and per-engine row counts, plus one line per unresponsive engine with engine and reason_class. full adds one line per engine and result with that engine’s own rank, joined on query_id. Lines stay flat because log stores turn JSON arrays into opaque strings that can’t be grouped or averaged. Both modes log the query, and full logs every result URL, so retention is your call. Lines log at INFO, so startup rejects any mode but off unless OMNIFEED_LOG_LEVEL is debug or info. The omnifeed_search_engine_position_rank and omnifeed_search_engine_unique_results_total metrics carry no queries or URLs and are always recorded. |
OMNIFEED_SEARCH_MAX_RESULTS |
25 |
Cap on the search limit argument, 1 to 100 |
OMNIFEED_SEARCHERS |
searxng,tinyfish |
Search failover order. The first configured searcher answers every query; each next one is the backup of the chain before it. A listed searcher with no URL or key is skipped, so the default stays SearXNG-only until OMNIFEED_TINYFISH_API_KEY is set. Valid names: searxng, tinyfish. An unknown or repeated name fails startup. |
OMNIFEED_SEARXNG_CRITICAL_ENGINES |
(unset) | Comma-separated SearXNG engines whose absence makes a search degraded: it succeeded, but one of these was rate limited, CAPTCHA’d or returned no rows, so the backup tops it up. On a pool that leans on Google, google cse. A critical engine outside OMNIFEED_SEARXNG_SITE_ENGINES doesn’t count on site-scoped searches, which never ask it. Unset never reports degraded. |
OMNIFEED_SEARCH_FALLBACK_MIN_RESULTS |
3 |
A successful search with fewer results than this is thin and is topped up by the backup. A caller limit below it lowers the bar to the limit, so 2 results to limit=2 are complete. 0 disables the thin trigger. |
OMNIFEED_SEARCH_PRIMARY_BUDGET |
12s |
How long a searcher with a backup may take before the backup is asked instead. Without it a hung SearXNG spends its pacing wait plus up to 3 timed-out attempts before the backup starts. Applies only between chained searchers. 0 disables it. |
OMNIFEED_TINYFISH_API_KEY |
(unset, off) | TinyFish Search API key. Set, it adds tinyfish to the searcher chain. Free tier: 30 requests/minute and 12,000/day per key, then HTTP 402 until 00:00 UTC, which omnifeed reports as quota_exhausted with that wait. A 429 is not retried: the limit is per minute, so a retry seconds later can’t succeed. Every query it answers leaves your network, and TinyFish’s terms let it train on usage. |
OMNIFEED_TINYFISH_URL |
https://api.search.tinyfish.ai |
TinyFish Search endpoint. Must be https, since it carries the key; plain http is accepted to a loopback host only. Redirects are never followed, because net/http would copy X-API-Key to the redirect target. |
OMNIFEED_TINYFISH_TIMEOUT |
10s |
Per-attempt timeout to TinyFish. Its measured median was about 2s, worst about 6.5s. |
OMNIFEED_TINYFISH_QUOTA |
25 |
Maximum TinyFish queries per OMNIFEED_TINYFISH_QUOTA_WINDOW, shared across replicas with Redis. The default stays under the free tier’s 30/minute. 0 sends unpaced. |
OMNIFEED_TINYFISH_QUOTA_WINDOW |
1m |
Rolling window for OMNIFEED_TINYFISH_QUOTA |
OMNIFEED_TINYFISH_MAX_WAIT |
5s |
How long a TinyFish query may wait for a pacing slot before it fails with quota_exhausted. 0 means the 5s default, not unbounded. |
OMNIFEED_FETCH_MAX_CHARS |
120000 |
Default cap on markdown returned by fetch_url. 0 is unlimited. Over the cap, the reply ends with a resumable truncation marker. TOON and JSON output from the dedicated engines is never cut; use their own limits. Doesn’t apply to /crawl. See Controlling fetched content size. |
OMNIFEED_GITHUB_TOKEN |
(unset) | Personal access token for the GitHub engine. Anonymous access allows 60 requests per hour per IP. A token raises it to 5000. A repository root costs 3 requests, most other pages 1. Discussions need it: they are read through GraphQL, which refuses anonymous callers, so without a token discussion URLs go to the generic fallback. A fine-grained token with no extra permissions (public repositories, read-only) is enough. |
OMNIFEED_STACKEXCHANGE_KEY |
(unset) | App key for the Stack Exchange engine, registered on Stack Apps. Keyless access allows 300 requests a day per IP, shared with every other keyless client on that IP. A key raises it to 10,000. A question costs 2 requests (question, then answers by votes), an /a/{id} short link 3. Stack Exchange does not treat the key as a secret: it travels in the query string, so it shows in traces of the outgoing call. |
OMNIFEED_DISCOURSE_HOSTS |
meta.discourse.org,discuss.python.org,users.rust-lang.org,internals.rust-lang.org,discuss.pytorch.org |
Hostnames where the Discourse engine claims /t/… topic URLs. Discourse runs on arbitrary domains and the engine can’t detect it, so list the forums you use. Matching is exact and case-insensitive, with no subdomain wildcards. Unlisted forums go through the generic browser fallback, which returns less of the thread. An empty string disables the engine. |
OMNIFEED_TWITTER_ENABLED |
true |
Render X/Twitter post URLs (x.com, twitter.com, fxtwitter.com, fixupx.com, vxtwitter.com, fixvx.com, and t.co links that land on a post) with the Twitter engine. false sends them to the generic fallback, which loads x.com in crawl4ai. See Twitter/X posts. |
OMNIFEED_TWITTER_FXTWITTER_URL |
https://api.fxtwitter.com |
Base URL of the FxTwitter API the engine reads first. Point it at a self-hosted FxEmbed to stop depending on the public instance. |
OMNIFEED_TWITTER_MAX_REPLIES |
20 |
Replies rendered under a post, from the first page FxTwitter returns (about 35). Must be at least 1. |
OMNIFEED_REDDIT_TIMEOUT |
4m |
Wall-clock cap for a Reddit thread expansion |
OMNIFEED_REDDIT_MAX_ROUNDS |
3 |
Default /api/morechildren rounds, capped at 40. 0 here means the default 3. Only a request’s expand: 0 turns expansion off. ?expand=full allows up to 40. The browser method reads it as the most “more replies” requests a thread page may send, capped at 10. |
OMNIFEED_REDDIT_FORMAT |
toon |
Default Reddit output: toon or json |
OMNIFEED_REDDIT_FETCH_LIMIT |
500 |
Reddit limit: max comments in the initial tree |
OMNIFEED_REDDIT_DEPTH |
20 |
Reddit depth: max nesting depth of the initial tree |
OMNIFEED_REDDIT_SORT |
top |
Reddit sort: confidence (best), top, new, controversial, old, random, qa or live |
OMNIFEED_REDDIT_MAX_COMMENTS |
0 |
Cap on total comments after expansion. 0 is unlimited. |
OMNIFEED_REDDIT_MAX_TOP_LEVEL |
0 |
Cap on top-level threads, replies included. 0 is unlimited. |
OMNIFEED_REDDIT_KEEP_CREATED |
true |
Include each comment’s created timestamp |
OMNIFEED_REDDIT_KEEP_DEPTH |
false |
Include each comment’s depth |
OMNIFEED_REDDIT_QUOTA |
0 (off) |
Maximum requests to Reddit in any rolling OMNIFEED_REDDIT_QUOTA_WINDOW. It counts every request, not every crawl: the thread fetch, each /api/morechildren round, a subreddit listing and a share-link resolve each take one, so an expand=full crawl can take 41. With the browser method, the requests the page sends itself also count: each scroll batch of a listing (100 posts cost 1+4) and each “more replies” of a thread. They are charged once the page answers, so the next request waits behind them. Shared across replicas when OMNIFEED_REDIS_URL is set. Reddit’s unauthenticated budget is about 10 requests per minute per IP in practice. On 2026-10-08 Reddit blocked after about 50 requests in 5 minutes, and once at about 3 per minute while other clients shared the egress IP. Recommended: 6 with the default 1m window, or 3 if the IP is shared. A request refused for lack of quota fails with quota_exhausted and its retry_after_s, and nothing is sent. |
OMNIFEED_REDDIT_QUOTA_WINDOW |
1m |
Rolling window for OMNIFEED_REDDIT_QUOTA. Ignored when the quota is 0. Must be above 0 when the quota is set. |
OMNIFEED_REDDIT_SESSION |
(unset) | Value of a logged-in reddit_session cookie. Reddit closed its .json endpoints to logged-out clients in 2026 but still serves them to logged-in sessions. It enables the session method, which calls them directly over HTTP: one request per thread, with live scores and the full comment tree, and no browser. Unset, it reads the rendered page through crawl4ai instead. Use a secondary account, because every fetch counts against it (100 requests per window, from X-Ratelimit-*). The cookie expires about 6 months after login, and Reddit does not renew it on use: log in again before omnifeed_reddit_session_expiry_timestamp_seconds. omnifeed logs a warning at startup when fewer than 14 days are left. Secret: keep it out of logs and version control. |
OMNIFEED_REDDIT_METHODS |
client_id,session,browser |
The order the Reddit engine tries its methods in. A method without its credential is left out (browser needs only crawl4ai). On a failure the next method can get around (403, 429, challenge page, upstream 5xx), the call moves to the next method at once, and the failed one is skipped for OMNIFEED_REDDIT_METHOD_COOLDOWN. A missing thread or omnifeed’s own quota refusal does not move on, and neither does a subreddit Reddit refuses to everyone (private, quarantined, banned) or a page the browser rendered but could not read (layout, a markup change): those fail the same on every method, so nothing is held. An upstream fault (upstream_error: crawl4ai down, Reddit 5xx, a dropped connection) moves on, but never holds the last method left, so a crawl4ai restart costs the failed calls, not a cooldown. When every method is skipped, the call fails at once with quota_exhausted and the shortest wait in retry_after_s, and sends nothing. Reddit’s back-off (Retry-After, X-Ratelimit-Remaining: 0) holds only the method that got it, never the others. Without Redis, the memory is per process, and a failure costs each pod one request per cooldown. With OMNIFEED_REDIS_URL set, a hold recorded by one replica skips the method on every replica, because Reddit’s budget belongs to the credential, not to the pod. A shorter hold never cuts a longer one. If Redis fails, each pod uses its own memory for 30s, then tries Redis again. The keys are <OMNIFEED_REDIS_KEY_PREFIX>:{reddit-method}:<method>, and every operation is a script, so the limiter’s ACL is enough. The shared browser hold assumes that the replicas share one egress IP. |
OMNIFEED_REDDIT_METHOD_COOLDOWN |
10m |
How long a failed Reddit method is skipped before the chain tries it again. A 429 with a longer Retry-After holds it for that long instead. Watch omnifeed_reddit_method_up and omnifeed_reddit_method_calls_total{outcome!="ok"}. |
OMNIFEED_REDDIT_CLIENT_ID |
(unset) | Client ID of a Reddit OAuth app, used with the installed-client grant: no secret and no Reddit account. It enables the client_id method: an app-only token (it lasts 24 h and is renewed on its own), then oauth.reddit.com directly. That gives one request per thread, live scores, the full comment tree, and 1000 requests per window. Short-lived: Reddit stopped taking new API requests on 2026-10-31, starts cutting off unregistered apps on 2027-01-12, and closes the public API in March 2027. |
OMNIFEED_MAX_URLS_PER_REQUEST |
30 |
Cap on urls[] length |
OMNIFEED_CRAWL_MAX_RESPONSE_MB |
256 |
Cap on the page content one POST /crawl response carries, summed over its documents. Documents are never cut: one that would cross the cap comes back as an error document asking to request that URL on its own. 0 disables the cap. |
OMNIFEED_PER_DOMAIN_CONCURRENCY |
2 |
Max concurrent requests to one domain |
OMNIFEED_PER_DOMAIN_DELAY |
1500ms |
Minimum gap between requests to one domain |
OMNIFEED_BLOCK_PRIVATE_IPS |
true |
SSRF protection: refuses hosts that are, or resolve to, addresses that are not globally reachable, non-canonical IPv4 forms, localhost names and hosts that do not resolve. A host whose every address is 0.0.0.0 or ::, the answer a DNS filter gives for a blocked name, is refused the same way but reported as dns_blocked, not invalid_request. Keep it on in production, and restrict crawl4ai’s egress to public addresses too (see SECURITY.md). |
OMNIFEED_ALLOWED_ORIGINS |
(unset) | Comma-separated browser Origin values, e.g. https://app.example.com, allowed to call the HTTP APIs cross-origin. Requests with no Origin, which covers every native MCP client, always pass, as do loopback origins such as the MCP inspector. Anything else gets 403. This is the DNS-rebinding guard the MCP transport spec requires, applied to every HTTP endpoint. |
OMNIFEED_LOG_LEVEL |
info |
debug, info, warn or error |
OMNIFEED_LOG_FORMAT |
json |
json or text |
OMNIFEED_ENABLE_PPROF |
false |
Expose /debug/pprof/* |
OTEL_EXPORTER_OTLP_ENDPOINT |
(unset) | Turns on tracing: base URL of an OTLP/HTTP collector, e.g. http://vtsingle-traces.monitoring.svc:10428/insert/opentelemetry. The exporter appends /v1/traces. Unset means no tracing and no overhead. The standard OpenTelemetry name, so the exporter’s other OTEL_EXPORTER_OTLP_* variables (headers, timeout) apply too. |
OTEL_SERVICE_NAME |
omnifeed |
service.name on every span |
OMNIFEED_CACHE_ENABLED |
true |
Cache successful fetch_url and POST /crawl results. See Response cache. |
OMNIFEED_CACHE_TTL_THREADS |
10m |
How long a result from a dedicated engine (Reddit, Hacker News, Discourse, Bluesky, GitHub, Stack Exchange) stays cached. 0 turns caching off for them. |
OMNIFEED_CACHE_TTL_PAGES |
30m |
How long a generic page (the crawl4ai engine) stays cached. 0 turns caching off for them. |
OMNIFEED_CACHE_MAX_BYTES |
67108864 (64 MiB) |
Size of the in-process LRU, used when OMNIFEED_REDIS_URL is unset, or when Redis refuses the cache’s keys (see Response cache). Approximate: content plus metadata plus a small per-entry overhead. Per pod. |
OMNIFEED_CACHE_MAX_ITEM_BYTES |
4194304 (4 MiB) |
Largest single entry. In Redis it is the gzip-compressed size, in memory the raw size. A bigger result is still returned, just not cached. |
OMNIFEED_CACHE_KEY_PREFIX |
omnifeed:cache |
Redis key namespace for cache entries, separate from the rate limiter’s OMNIFEED_REDIS_KEY_PREFIX. A Redis ACL user must be allowed ~<prefix>:* with GET and SET (see Response cache). |
When an upstream answers 429 or 503 with Retry-After, omnifeed holds that host
for the given duration, capped at 5 minutes, across later requests. With
OMNIFEED_REDIS_URL set, the hold spans replicas. It applies only to upstreams an
engine calls directly, because crawl4ai hides the crawled site’s own Retry-After.
Reddit is the exception: the engine reads Retry-After, X-Ratelimit-Reset and
X-Ratelimit-Remaining itself. On a 429, or when X-Ratelimit-Remaining reaches
0, it holds only the chain method that got the answer, never www.reddit.com,
because a host hold would stall a working credential. The hold is Retry-After,
else X-Ratelimit-Reset, else 60s on a bare 429 (see OMNIFEED_REDDIT_METHODS).
The 429 error carries the wait as retry_after_s.
Callers waiting on a held host time out normally at their own budget: the engine
timeout, about 30s, or MaxWait, 15s, for a search.
If a request has a deadline and the pacing wait is longer than the time left, the
limiter refuses at once. Searches always have one, MaxWait, 15s. Nothing is sent,
no quota is spent, and the caller gets quota_exhausted with the wait:
pacing quota exhausted; retry in 47s. Before this, omnifeed held the query for 15s
and then timed out anyway. On 2026-08-21, 21 searches each waited about 20s and all failed.
quota_exhausted means pacing works as configured, and the caller should retry
later. If it fires more often than the engines require, raise
OMNIFEED_SEARXNG_QUOTA or lower OMNIFEED_SEARXNG_DELAY. A request with no
deadline still queues. Refusals show up as outcome="budget_exceeded" on
omnifeed_domain_limiter_wait_seconds, at about 0 seconds each, unlike
outcome="canceled".
web_search and POST /search answer through one searcher, which may be a chain
(OMNIFEED_SEARCHERS). The first searcher answers every query. Its backup is asked
only when that answer is not good enough:
| Trigger | When | What the caller gets |
|---|---|---|
error |
The primary failed: quota_exhausted, a timeout, an upstream error status |
The backup’s results |
empty |
The primary succeeded with no results, how a silently blocked pool answers | The backup’s results |
thin |
Fewer than OMNIFEED_SEARCH_FALLBACK_MIN_RESULTS results |
Both, interleaved |
degraded |
A critical engine (OMNIFEED_SEARXNG_CRITICAL_ENGINES) was missing |
Both, interleaved |
Interleaving takes the primary’s first result, then the backup’s, and so on, and
drops a URL already taken (scheme, www., a trailing slash and the fragment are
ignored). Each result’s engine says who found it, e.g. tinyfish.
If the backup fails too, or answers a failed primary with no results, the primary’s
answer stands: its results if it had any, its original error if not, so
quota_exhausted still reads quota_exhausted instead of an empty success. Two
searches are never failed over: one whose caller hung up or ran out of deadline, and
one refused for its input (an empty query), which the backup would refuse too. A
primary that runs past OMNIFEED_SEARCH_PRIMARY_BUDGET counts as failed.
A critical engine that SearXNG skips for a filter it doesn’t support (some engines
ignore time_range or language) reads as missing, so those searches go to the
backup too. Watch omnifeed_search_fallbacks_total{trigger="degraded"} against the
backup’s daily quota. Every handoff is counted in
omnifeed_search_fallbacks_total and logged as search served by backup,
search backup found nothing, primary answer stands or
search backup failed, primary answer stands, with trigger, cause and query_id.
With a backup, a long pacing queue costs more than it saves: lower
OMNIFEED_SEARXNG_MAX_WAIT (e.g. 3s) so a burst spills over to the backup instead
of waiting 15s to fail. The chain’s name, e.g. searxng+tinyfish, is the searcher
label on omnifeed_search_requests_total and the searcher key in web_search
_meta.
A long article can overflow an MCP client’s per-response budget. The client then writes the text to a file and the model never reads it. So fetch_url caps markdown at OMNIFEED_FETCH_MAX_CHARS, 120000 by default and 0 for unlimited, and ends a cut reply with a resumable marker:
[omnifeed: content truncated at 120000 of 174233 characters. Call fetch_url again with start_char=120000 to continue.]
Both per-request parameters apply to markdown only:
| Param | Default | Purpose |
|---|---|---|
max_chars |
OMNIFEED_FETCH_MAX_CHARS |
Max characters to return, up to 500000. 0 or omitted uses the server default. |
start_char |
0 |
Character offset to start from. Pass the value from a truncation marker to read the next chunk. |
Offsets and lengths count characters, not bytes, so a window never splits a multibyte character. The marker counts against max_chars, so a reply never exceeds it. The response _meta carries the same numbers as truncated, total_chars and next_start_char. fetch_url declares anthropic/maxResultSizeChars: 500000 in tools/list, which raises Claude Code’s per-tool text limit to match. Other clients ignore it.
omnifeed never truncates TOON or JSON output, which covers every dedicated engine. Its length markers would describe rows that were cut, so those engines use their own element caps, described below.
POST /crawl has no size parameters and never truncates. RAG pipelines chunk and embed whatever they receive, so a cap would drop retrievable text, and one shared offset would break every other URL in a batch.
Generic crawls don’t scroll by default. Scrolling costs seconds on every page, gains content only on append-style infinite feeds, and corrupts virtualized pages. When a feed, listing or gallery comes back missing items, opt in per request with scan_full_page: true on fetch_url or POST /crawl?scan_full_page=true. Both accept false too, which turns the scroll off where OMNIFEED_CRAWL4AI_SCAN_FULL_PAGE turns it on. OMNIFEED_CRAWL4AI_SCROLL_DELAY sets the pause between scroll steps.
AI agents read the TOON (or JSON) body, not the response _meta, so thread headers state how complete the comment list is:
story: total_comments, the live comments in Algolia’s tree before any cap, and truncated, whether a cap dropped some of them.post: total_comments, Reddit’s num_comments (it counts deleted and removed comments, so it can exceed what is fetchable); returned_comments, the comments emitted; hidden_more, the sum of the remaining more gaps’ counts; and truncated, true when a cap dropped comments or any gap is left unexpanded._meta keeps its own fields (comments, truncated_from, total_comments, gaps) unchanged.
Reddit comment trees can be huge. There are two kinds of limit:
OMNIFEED_REDDIT_FETCH_LIMIT as limit, OMNIFEED_REDDIT_DEPTH as depth, and OMNIFEED_REDDIT_SORT as sort. They reduce what Reddit sends, which saves latency and tokens, but they are approximate, and limit and depth bound only the initial fetch. Reddit defines them in its API docs under GET [/r/subreddit]/comments/article: limit is “maximum number of comments to return” and depth is “maximum depth of subtrees”.OMNIFEED_REDDIT_MAX_COMMENTS cuts the flat comment list breadth-first (every top-level comment first, then their replies, then the next depth, never a reply without its parent, output order unchanged), and OMNIFEED_REDDIT_MAX_TOP_LEVEL keeps the first N top-level threads, in sort order, with their replies.Use the Reddit parameters to fetch less. Use the caps for a guaranteed ceiling: OMNIFEED_REDDIT_MAX_ROUNDS expansion adds comments beyond limit, so only the caps bound the total. fetch_url accepts all five per request as limit, depth, sort, max_comments and max_top_level, and a positive value overrides the env default. They apply to threads only. A subreddit listing has no comment tree and takes its post count and time window from the URL: ?limit=, 1 to 100 with a default of 25, and ?t=hour|day|week|month|year|all, which Reddit applies only to top and controversial.
The Algolia item API returns a Hacker News thread’s whole tree in one response, and top-level subtree sizes are skewed. One measured thread had subtrees of 105, 55, 16, 13, 12, 11, 10, 6 and so on across 62 top-level threads. There are no upstream parameters to forward, only omnifeed caps applied after the fetch, all three per request on fetch_url:
| Param | Default | Purpose |
|---|---|---|
max_per_subtree |
unlimited | Max comments kept in each top-level thread, root included. Selection is breadth-first, so a thread keeps its root and shallow replies before its deep tail. |
max_top_level |
unlimited | Keep the first N top-level threads, in HN’s order, with all their replies. |
max_comments |
500 | Ceiling on the flat comment list, applied last. 0 or omitted means 500; a caller can ask for up to 5000 (Algolia already returned the whole tree, so this costs no extra request). The cut is breadth-first across the whole thread. |
Start with max_per_subtree, which fits that skew. On two real threads, max_per_subtree=12 returned about 56% and 47% of the full-tree bytes and kept 9 of 12 and 11 of 14 of the comments a human rated substantive. max_comments is one breadth-first cut over the whole thread: every top-level comment first, then their direct replies, then the next depth. It used to keep the first N comments in pre-order, which spent most of the budget in the biggest branch: at 100 comments that covered 4 of 62 top-level threads, with 72% of its comments in one subtree.
Hacker News has no depth cap on purpose, because depth says nothing about quality there. The most valuable comment in both measured threads sat at depth 7, and adding depth<=5 to a subtree cap lost real content while saving under 1k of 84k characters. depth and sort are Reddit-only and ignored on HN URLs.
Caps never reorder output. Comments stay in HN’s order, and breadth-first selection keeps each comment’s ancestors, so the parent_id chain never breaks.
When a dedicated engine (Reddit, Hacker News, GitHub, Discourse, Bluesky, Stack Exchange) fails, the generic browser engine renders the same URL instead, with three exceptions:
format (json or toon, on fetch_url or
POST /crawl). Passing it means the caller parses the reply, so it gets the
engine’s error rather than markdown in the same success shape. This holds for
every engine and every failure.http_429, http_403,
captcha, bot_block, or omnifeed’s own quota_exhausted. Reddit and
Discourse read the page’s own host (www.reddit.com, the forum), so a browser
render would hit the host that just refused us, or that our pacing is holding
back, and prolong the block. The engine’s error is returned.domain.NoFallback): the Twitter/X
engine already tried the generic render itself, so a second one is skipped.GitHub, Hacker News, Bluesky and Stack Exchange read a separate API host
(api.github.com, hn.algolia.com, the Bluesky AppView, api.stackexchange.com).
Their blocks and quota refusals are about that API host, not the page, so they
still fall back: an anonymous GitHub deployment over its 60 requests per hour
renders github.com instead. (For Stack Exchange the render usually fails too:
Cloudflare answers crawl4ai on the question pages with a 403.) An engine
declares the page’s host by implementing domain.SameHostEngine.
A fallback result is marked. _meta (or the loader’s metadata) carries
fallback_from (the engine that failed) and fallback_reason (its failure kind),
and the body starts with one line:
> Note: the dedicated reddit engine failed (timeout); this is the generic page render instead.
Errors reach MCP callers as a tool result with isError: true whose text is
fetch_url failed: <reason> (HTTP <status>): <cause> [<code>] Retryable after Ns.
(the back-off is stated once; retry_after_s is also in structuredContent).
See errors.md.
x.com walls headless browsers, and crawl4ai fails every twitter.com link (the
anti-bot verdict is a 39-byte page), so the Twitter engine reads public JSON mirrors
directly, in order, and stops at the first that answers:
/2/conversation/{id} for the post, its parents and the first
page of replies, plus /2/thread/{id} when the post belongs to the author’s
self-thread. Covers long posts, X Articles, quotes, media alt text, community notes,
polls and link cards.cdn.syndication.twimg.com/tweet-result). It cuts long posts at
about 275 characters, so for those the engine asks vxTwitter for the full text. If
that fails too, the cut text is served with text_truncated: true in _meta and a
note in the body. vxTwitter alone is tried if syndication fails.https://x.com/i/status/{id}, which X renders for
logged-out readers. X’s own error pages are rejected, not returned.If every source fails, the error says tweet unavailable and why: not found (FxTwitter
and a second source both answered 404: deleted, protected or suspended), blocked or rate
limited, or the last source’s failure. The error is final: the registry does not render
the URL a second time. t.co links are resolved with one HEAD whose redirect is not
followed; the destination passes the SSRF check, then renders as a post or, for any
other site, through the generic engine. A twitter.com URL the engine does not claim, such
as a profile, is rewritten to x.com before crawl4ai sees it.
Output is markdown by default: a header line (@handle (Name) · date · x.com URL ·
likes · reposts · replies · views), the text with t.co links expanded, the quoted post
as a blockquote, media as [image: alt] or [video, 1:23], poll, community note, link
card, the X Article body, the self-thread numbered, and the top replies as
@handle (N likes): text, with the author’s own replies marked [author]. Pass
format: toon or json for the same data structured. _meta carries upstream
(fxtwitter, syndication, syndication+vxtwitter, vxtwitter or crawl4ai),
replies, reply_total, thread_posts and text_truncated. Fetched posts are cached in
process for 15 minutes.
FxTwitter, vxTwitter and the syndication endpoint are third-party or unofficial services X
can break or pressure at any time. A self-hosted FxEmbed
behind OMNIFEED_TWITTER_FXTWITTER_URL removes the first dependency.
Reddit closed its .json endpoints to logged-out clients in 2026. The Reddit engine
reads Reddit through a chain of methods, in the order OMNIFEED_REDDIT_METHODS sets.
ADR 0001 records the measurements and the reasons.
client_id: an OAuth client ID (OMNIFEED_REDDIT_CLIENT_ID). omnifeed calls
oauth.reddit.com over plain HTTP with an app-only token. No Reddit account.session: a logged-in reddit_session cookie (OMNIFEED_REDDIT_SESSION).
omnifeed calls www.reddit.com over plain HTTP. Use a secondary account.browser: crawl4ai renders the thread page logged out, and omnifeed reads the
comments from the page. It is slow and gets rate limited quickly. crawl4ai must run
with CRAWL4AI_EXECUTE_JS_ENABLED=true (omnifeed calls its token-gated
POST /execute_js), and OMNIFEED_CRAWL4AI_TOKEN must match its
CRAWL4AI_API_TOKEN._meta.reddit_method names the method that served the thread or listing.
Sustained scraping can raise your IP’s risk score. If fetches return the block page, slow down, keep
expandmodest, or route the browser through a residential proxy.
The same thread is often fetched again within minutes: a retry job re-reads the threads it already has, and agents re-open URLs they fetched a turn ago. Every repeat of a Reddit thread spends Reddit’s per-IP budget, which is small (on 2026-10-08 it ran out and the egress IP was blocked). So omnifeed caches successful results and serves repeats from the cache.
_meta.fallback_from set) and partial Reddit threads
(_meta.partial, see below) are never cached, so the next call tries again.OMNIFEED_CACHE_TTL_THREADS (10 minutes) for the dedicated
engines, OMNIFEED_CACHE_TTL_PAGES (30 minutes) for generic pages.#fragment dropped, query parameters sorted; the path is kept as-is) plus
every option that reaches the engine: format, expand, limit, depth,
sort, max_comments, max_top_level, max_per_subtree, scan_full_page
and the Reddit defaults. max_chars and start_char are not part of the
key: fetch_url cuts its character window from the whole cached document, so
paging through a long page with start_char costs one upstream fetch.OMNIFEED_REDIS_URL is set, so every replica serves
every other replica’s fetches. Entries are gzip-compressed and expire through
a Redis TTL. Without Redis, an in-process LRU of OMNIFEED_CACHE_MAX_BYTES
per pod.~<OMNIFEED_CACHE_KEY_PREFIX>:* (default ~omnifeed:cache:*) and the
commands GET and SET, on top of whatever the rate limiter needs under
OMNIFEED_REDIS_KEY_PREFIX. A user created for the rate limiter alone
(~omnifeed:ratelimit:*) is refused every cache command with NOPERM. For
example: ACL SETUSER omnifeed ~omnifeed:ratelimit:* ~omnifeed:cache:* +get +set ….<prefix>:probe:<n>, 10-second TTL, never deleted) and reads it back, each
command bounded by OMNIFEED_REDIS_TIMEOUT. If Redis refuses (NOPERM,
NOAUTH, WRONGPASS) or gives any other answer that will not change on
retry (e.g. READONLY, or the value is not read back), the pod logs one
ERROR naming the prefix to grant and uses the in-process LRU instead. If
Redis does not answer or asks to retry (timeout, connection refused, LOADING,
OOM, MISCONF), the pod keeps
the Redis backend and the rule below applies until it does.NOPERM/NOAUTH/WRONGPASS on a later
GET or SET (an ACL tightened under a running pod) does the same: one
ERROR, then the in-process LRU for the rest of the process’s life, starting
with the request that hit the error. Restart the pods after fixing the ACL to
share the cache again. omnifeed_cache_backend{backend="memory"} 1 with
OMNIFEED_REDIS_URL set means this happened.result="error" and the request is
served as a miss, never failed. After a failure, the cache skips Redis for 30
seconds, so a dead Redis costs one OMNIFEED_REDIS_TIMEOUT per 30 seconds,
not one per request.Every response from the cache layer carries _meta.cache (or metadata.cache
on POST /crawl): hit (served from the cache, or shared with an identical
in-flight request), miss (fetched upstream) or bypass. _meta.cached_at is
the time (RFC 3339, UTC) the content was fetched and stored. It is absent when the
response is not in the cache.
Bypass. Pass no_cache: true to fetch_url, or POST /crawl?no_cache=true
(or =1), to skip the cache and fetch fresh. The fresh result still replaces
the cached copy. Use it only when cached_at is too old for the job: every
uncached Reddit fetch spends the per-IP budget.
A thread whose /api/morechildren expansion is cut short, because a round was
blocked, rate limited, timed out or returned something unparseable, is still
returned with the comments loaded so far. omnifeed flags it:
_meta.partial: "true", _meta.partial_reason with the failure kind (e.g.
http_429, timeout, bot_block, parse_error), and
_meta.missing_replies with the count still behind more gaps.note: 212 more replies could not be loaded (http_429). In
JSON it is a top-level "note" field.A thread that simply used up its expand budget is not partial. Partial
threads are never cached.
The browser method has its own partial reasons. Its in-page expansion can be
refused (http_429, http_403) or drop a request (expansion_failed). A page
can also count comments it did not render, or list posts without an id
(browser_incomplete, and such posts are dropped from a listing). Browser gaps
carry no reply counts, so the note then reads only 44 of 72 comments could be
loaded (http_429). A subreddit listing is flagged the same way.
Every Reddit document also carries _meta.reddit_method: the method that served
it (client_id, session or browser).
Each engine has its own time budget. A client calling omnifeed should wait longer than the budget of the engine it is calling, or it gives up on a request that would have succeeded and, for Reddit, spends the rate budget for nothing.
| Engine | Budget | Set by | Notes |
|---|---|---|---|
| Hacker News | 30 s per crawl | fixed (internal/engine/hackernews) |
Wall clock for the whole crawl, including the pacing wait and retries against hn.algolia.com. |
| GitHub, Discourse, Bluesky, Stack Exchange | 30 s per crawl | fixed, per engine | Same shape as Hacker News. |
| 4 min per crawl | OMNIFEED_REDDIT_TIMEOUT |
Wall clock for the whole crawl: pacing wait, share-link resolve, thread fetch and every morechildren round. A round cut off by the deadline returns what was loaded, flagged partial. Each browser call inside it is also bounded by OMNIFEED_CRAWL4AI_TIMEOUT. |
|
| Generic page (crawl4ai) | 90 s per crawl4ai call | OMNIFEED_CRAWL4AI_TIMEOUT |
Inside each call, crawl4ai’s own page_timeout is 60 s (the most crawl4ai accepts over REST), so crawl4ai normally answers before omnifeed’s 90 s run out. There is no overall cap on a generic crawl: a transient failure is retried once, a Playwright navigation race once with networkidle, a blocked page once under the alternate browser identity, a thin page once without the excluded selector, and a page that turns out to be a PDF once through crawl4ai’s PDF strategy, so the worst case is several 90 s calls. |
| SearXNG search | 15 s per attempt | OMNIFEED_SEARXNG_TIMEOUT |
Up to 3 attempts with backoff, after a pacing wait of up to OMNIFEED_SEARXNG_MAX_WAIT (15 s by default). |
Suggested client timeouts:
OMNIFEED_REDDIT_TIMEOUT, raise the
client by the same amount.POST /search: at least 75 s.The loader (/crawl, /search) and MCP listeners stop writing a response after
300 s, so waiting longer than that gains nothing, and an
OMNIFEED_REDDIT_TIMEOUT above about 4m30s gets cut by the server first. A
cached result returns in milliseconds whatever the engine.
Raw code, JSON, markdown and plain text have nothing for a browser to render, and Chromium’s page-idle wait makes them slow: a raw githubusercontent.com file takes 30 to 39 s in the browser and about 200 ms direct. When a URL’s extension looks raw (.md, .txt, .json, source files), the generic engine sends a HEAD request. If the server confirms a non-HTML text type, a plain GET fetches the body and returns it unchanged. Anything uncertain, such as a failed probe, text/html, binary bytes or blocked egress, falls back to the browser. With OMNIFEED_BLOCK_PRIVATE_IPS on, direct fetches refuse private and reserved addresses when dialing, so DNS rebinding can’t bypass URL validation. This needs outbound access to the target sites, not just crawl4ai. Without it the probe fails and everything goes through crawl4ai.
On OMNIFEED_METRICS_ADDR (default :9090):
/livez: 200 while the process runs, 503 once shutdown starts./readyz (aliases /healthz, /health): 200 unless shutting down. It does not check crawl4ai or SearXNG: an outage of either fails the calls that need it, with a classified, retryable error, while the cache, the Reddit quota and the API engines keep serving. Pulling every pod out of the Service turned a partial outage into a total one. Dependency state is the omnifeed_dependency_up metric instead, probed every 30 s.Every response from the loader (:8080) and MCP (:8081) servers carries X-Request-Id. A caller’s own X-Request-Id is reused when it is 1 to 128 characters from A-Z a-z 0-9 . _ : -; otherwise omnifeed generates one. MCP tool results return it as _meta.request_id (on stdio, one is generated per message). Log lines written for that request carry request_id, and trace_id and span_id when tracing is on, so one id joins the caller’s logs, omnifeed’s logs and the trace.
MCP log lines worth knowing:
mcp client initialized: client_name, client_version, client (the metric label), protocol_version.mcp tool call completed: tool, args, client, duration_ms, chars, and when they apply engine, cache, fallback_from, partial, truncated and, for web_search, query_id, which joins the search audit lines (OMNIFEED_SEARCH_AUDIT). It is also in the web_search result _meta.mcp tool call failed: the same plus code, the error code the caller received.Off unless OTEL_EXPORTER_OTLP_ENDPOINT is set. Then omnifeed exports OpenTelemetry traces over OTLP/HTTP, sampling parent-based and always-on, and propagates W3C traceparent:
traceparent. Spans are named by route (POST /mcp); /livez, /readyz and /metrics are not traced.traceparent. Direct fetches of target sites (the raw-text bypass, Twitter redirects) use their own guarded client and are not traced.mcp.tool (mcp.tool, mcp.client), engine.dispatch and engine.fallback (engine, fallback.from, fallback.reason, error.reason), limiter.wait (limiter.scope, engine, limiter.outcome), crawl4ai.rung (rung: base, pdf, networkidle, alt_fingerprint, no_selector, pdf_retry; attempt), cache.get and cache.set (cache.result).Spans never carry a URL, a query or a path. Hosts appear as server.address. Before export, url.full, url.query, url.path and caller addresses are dropped, and URLs inside error texts are cut to scheme://host. On shutdown, omnifeed flushes buffered spans after the servers drain.
Served at /metrics on OMNIFEED_METRICS_ADDR, default :9090, alongside the Go and process collectors. The Go collector adds the runtime GC, scheduler and GC-CPU series (go_gc_*, go_sched_*, go_cpu_classes_gc_*). Don’t alert on go_sched_latencies_seconds: Go 1.26 and later misreport it (golang/go#79391).
| Metric | Type | Labels | What it measures |
|---|---|---|---|
omnifeed_build_info |
gauge | version |
Always 1; the running version |
omnifeed_http_requests_total |
counter | server, route, code |
HTTP requests by server (loader, mcp, obs), route (/crawl, /search, /mcp, /mcp/sse, /livez, /readyz, /metrics, anything else other) and status code |
omnifeed_http_request_seconds |
histogram | server, route |
Time until the handler returns. The SSE streams (GET /mcp, /mcp/sse) are counted but not timed: their duration is the connection’s lifetime |
omnifeed_http_in_flight |
gauge | server |
Requests being served, open SSE streams included |
omnifeed_mcp_tool_calls_total |
counter | tool, outcome, client |
MCP tools/call by tool, outcome (ok, error, invalid_params) and client: claude-code, opencode, openwebui, thedigestif, cursor or other, read from the MCP clientInfo (initialize on stdio, per-request _meta in the 2026-07-28 era) or else the User-Agent |
omnifeed_mcp_tool_seconds |
histogram | tool, outcome |
MCP tool latency, cache hits included |
omnifeed_panics_total |
counter | where |
Recovered panics: http, mcp_tool, loader_crawl, github_fanout, background. Any increase is a bug; the request failed instead of the process |
omnifeed_dependency_up |
gauge | dependency |
1 when the last 30-second probe of crawl4ai, searxng or redis succeeded, 0 when it failed. Only configured dependencies have a series. Not a readiness gate, see Health |
omnifeed_requests_total |
counter | engine, tenant, status, reason, cache |
Crawl requests, with a bounded failure reason, ok on success. cache is hit, miss, bypass (no_cache) or off (cache disabled). Errors are never served from the cache, so they carry miss, bypass or off |
omnifeed_request_seconds |
histogram | engine, status, reason |
End-to-end crawl latency. Cache hits are excluded: they never reach an engine, and their microseconds would drag the quantiles down as the hit rate rose |
omnifeed_request_attempts_total |
counter | upstream, attempt |
HTTP attempts by the retrying client, first or retry |
omnifeed_upstream_seconds |
histogram | upstream, op, status |
Upstream round-trip per attempt, from start until the body is read: crawl4ai/crawl, crawl4ai/execute_js, searxng/search, searxng/config, github/api, hackernews/api, discourse/api, stackexchange/api |
omnifeed_domain_limiter_wait_seconds |
histogram | engine, outcome |
Time blocked acquiring the per-domain limiter, semaphore plus delay. outcome="canceled": the wait died in the queue. outcome="budget_exceeded": the wait exceeded the caller’s remaining deadline, so nothing queued; about 0 seconds, with reason="quota_exhausted" on the request metric |
omnifeed_ratelimit_backend_errors_total |
counter | op |
Failed Redis operations in the distributed limiter. On acquire the limiter paces in process. release and penalize failures cost only pacing accuracy. reddit_method_read, reddit_method_hold and reddit_method_clear are the Reddit method chain’s shared holds: on a failure each pod keeps its holds in memory for 30s |
omnifeed_ratelimit_penalties_total |
counter | upstream |
Upstream Retry-After headers, on 429 or 503, turned into a hold on that host. upstream="reddit" counts Reddit’s own back-off headers read inside the browser. upstream="stackexchange" counts the Stack Exchange API’s backoff field and the wait a throttle_violation names, which it sends in the JSON body instead of a header. This often precedes a CAPTCHA or block |
omnifeed_ratelimit_degraded |
gauge | scope |
1 while pacing falls back to per-pod limits because Redis is unreachable, 0 while shared. Exists only when OMNIFEED_REDIS_URL is set, published at 0 on startup. One series per limiter scope, domain for crawling, searxng for queries and reddit for the Reddit request quota when set, reddit_methods for the Reddit method chain’s shared holds, each degrading and recovering on its own |
omnifeed_response_chars |
histogram | engine |
Engine output length before any max_chars truncation, successful crawls only |
omnifeed_engine_fallbacks_total |
counter | from_engine, reason |
Dedicated-engine failures re-crawled by the generic fallback. Block and rate reasons appear only for separate-host engines (GitHub, Hacker News, Bluesky, Stack Exchange), see Dedicated-engine fallback |
omnifeed_searxng_unresponsive_engines_total |
counter | engine, error |
Engines SearXNG reported unresponsive, per search. error is one of timeout, captcha, suspended, too_many_requests, access_denied, error, unknown |
omnifeed_searxng_engine_results_total |
counter | engine |
Result rows per SearXNG engine. A blocked engine keeps answering 200 with zero results, so its series goes flat while the rest of the pool moves. Alert on that divergence. Starts at 0 at startup for known engines (see below). Pair it with absent_over_time() all the same: an engine nothing names at startup has no series until a response names it |
omnifeed_searxng_queries_total |
counter | scoped |
Queries sent to SearXNG after the limiter, the rate the engines see. Compare with omnifeed_search_requests_total to see what pacing refused |
omnifeed_searxng_engine_zero_results_total |
counter | engine |
Searches where one engine returned no rows while the search had results: a silent block, per engine. Excludes engines SearXNG reported unresponsive. Counts only engines seen answering before, so pair with absent_over_time() |
omnifeed_searxng_empty_searches_total |
counter | scoped |
Searches with zero results and no unresponsive-engine report. scoped="true" is a site: query, where silent blocks concentrate |
omnifeed_reddit_expansion_rounds |
histogram | none | /api/morechildren rounds per Reddit crawl |
omnifeed_reddit_method_calls_total |
counter | method, outcome |
Reddit calls per chain method. outcome is ok; incomplete (a browser answer that holds less than the page counts, served with partial_reason=browser_incomplete); a verdict that returns to the caller without failover: not_found, forbidden (a subreddit refused to everyone), layout (a rendered page the browser could not read: Reddit changed its markup), error; or the failure kind that moved the call to the next method (http_403, http_429, captcha, bot_block, upstream_error). method="chain" with outcome="all_held" is a call that found every method on hold and sent nothing: Reddit is out of reach. It is apart from omnifeed_requests_total{reason="quota_exhausted"}, which our own pacing refusals share |
omnifeed_reddit_ratelimit_remaining |
gauge | method |
X-Ratelimit-Remaining of the last answer Reddit sent to a direct method: the requests left in the current window. Watch it on session, whose budget the account owner’s own browsing also spends |
omnifeed_reddit_method_last_success_timestamp_seconds |
gauge | method |
When a method last answered (ok or incomplete), per pod; 0 until it has. A fallback method behind a working one is never called, so this ages without meaning a fault |
omnifeed_reddit_method_up |
gauge | method |
1 while a configured method is in use, 0 while it is skipped after a failure or a back-off. With Redis, a hold from another replica sets it to 0 too, read at startup as well as on each call. It returns to 1 only after a call through the method succeeds, not when the cooldown ends |
omnifeed_reddit_inpage_requests_total |
counter | kind |
Requests a browser page sent to Reddit itself, on top of its page load: kind="listing" for scroll batches, kind="thread" for “more replies”. They are charged to OMNIFEED_REDDIT_QUOTA. Page loads plus these are what Reddit counts against the IP |
omnifeed_reddit_session_expiry_timestamp_seconds |
gauge | none | When the reddit_session cookie expires. 0 when none is set |
omnifeed_search_requests_total |
counter | searcher, status, reason, scoped |
Search queries as callers sent them, refused ones included, unlike omnifeed_searxng_queries_total. scoped="true" is a site: query. sum() queries ignore the added label. Queries matching an exact label set don’t |
omnifeed_search_fallbacks_total |
counter | from, to, trigger, reason, outcome |
Searches handed to a backup searcher. trigger is error, empty, thin or degraded. reason is the primary’s classified failure on trigger="error" (quota_exhausted is our own pacing, not a SearXNG fault) and ok otherwise. outcome is served, empty (the backup found nothing), failed, or canceled (the caller hung up while the backup ran). See Search failover |
omnifeed_search_request_seconds |
histogram | searcher, status |
Search latency |
omnifeed_search_engine_position_rank |
histogram | engine |
The rank each engine gave each row it returned. 1 to 3 is a result a caller reads, 20 and above is filler |
omnifeed_search_engine_unique_results_total |
counter | engine |
Results no other engine returned, which shows whether an engine earns its slot |
omnifeed_cache_requests_total |
counter | result |
Response cache lookups: hit (served from the cache or shared with an identical in-flight fetch), miss (fetched upstream), bypass (no_cache), error (the cache backend failed, served as a miss). hit / (hit + miss) is the hit rate |
omnifeed_cache_bytes |
gauge | none | Approximate bytes in the in-process cache. Only with the in-memory backend: the Redis backend is shared and not measured per pod |
omnifeed_cache_backend |
gauge | backend |
1 on the response cache’s active backend (redis or memory), 0 on the other. memory while OMNIFEED_REDIS_URL is set means Redis refused the cache’s keys and this pod fell back to its in-process LRU |
omnifeed_cache_backend_errors_total |
counter | op, kind |
Response-cache Redis errors by op (get, set, probe) and kind: noperm (ACL or auth refusal: the pod switches to memory), transient (timeout, refused connection, LOADING…: 30-second cooldown, then retry), other (startup probe only: an answer the cache cannot use, e.g. READONLY) |
Per-engine SearXNG series start at 0. increase() and rate() in Prometheus and VictoriaMetrics do not reliably count a counter’s first sample. A series created on a pod’s first search, at say 20 rows, reads as no increase at all, so after every restart the first results per engine went missing. That made an “engine silent” alert such as sum(increase(omnifeed_searxng_engine_results_total{engine="google cse"}[6h])) == 0 fire after deploys. At startup omnifeed therefore creates every per-engine series at 0: omnifeed_searxng_engine_results_total, omnifeed_searxng_engine_zero_results_total, omnifeed_search_engine_unique_results_total, omnifeed_search_engine_position_rank (_count 0) and omnifeed_searxng_unresponsive_engines_total for each error value. This happens at once for the engines in OMNIFEED_SEARXNG_SITE_ENGINES. For every engine SearXNG reports enabled in GET /config in the general category, it happens in the background. It is retried with backoff for up to 5 minutes, because SearXNG may start later, and never delays readiness. Only general counts because omnifeed sends no categories, so SearXNG runs only general engines. Engines in other categories (images, music, maps…) would only add series that never move. If /config does not answer, omnifeed logs one WARN. Engines then get their series the first time a response names them, as before.