horizon.yaml Reference

horizon.yaml is the single configuration file for the Horizon BFF. Validation runs at startup and again on every hot reload. A file that fails validation is rejected; the BFF keeps the previously valid config rather than serving with broken settings.

This page is the top-level map. Each subsection has its own detail page:

Section Purpose Details
server HTTP listener and static asset path. server
templates Template source mode: OAP-backed (live) or local read-only bundle (readonly). below
security Which outbound link domains a dashboard template may point at. security
audit Optional durable record of who signed in, when and from where. Off by default; Postgres-backed. login-audit
oap OAP query / admin / Zipkin URLs, timeouts, basic-auth, MQE override. oap
auth Active backend (local or LDAP), local users, LDAP binding, break-glass, single sign-on, API tokens. auth
rbac Role definitions, permission grants, landing route per role. rbac
session Cookie name, TTL, secure flag. session
debugLog Wire-level request/response log for troubleshooting. debugLog
query Per-request query limits (layer-landing service cap, Overview top-N). below
sourceMaps In-memory source-map budgets + static mount for the Browser Logs tab. Browser Logs & Source Maps
ai AI assistant: provider, model, credentials, prompt overrides, history cap. AI Assistant
mcp Whether an external agent may read this Horizon over the Model Context Protocol. MCP
oauth Horizon as an authorization server, so an MCP client can sign its operator in through a browser. Off by default. MCP
performance How hard the BFF fans queries out to OAP, plus render caps and the largest page a list may display. below
layers Layers to hide from the sidebar. below

Top-level shape

server: { host, port, staticDir?, publicUrl?, trustProxy? }   # publicUrl = the PUBLIC base URL

templates: { mode? }          # live (default) | readonly

security:
  trustedLinkDomains?: string[]   # outbound link allow-list for templates

oap:
  queryUrl: string
  adminUrl: string
  zipkinUrl?: string
  timeoutMs?: number
  auth?: { username, password }
  mqe?: { host?, port? }

auth:
  backend: local | ldap
  local?: { users: [{ username, passwordHash, roles? }] }
  ldap?: { ... }
  breakGlass?: { username, passwordHash, roles? }
  sso?:                        # single sign-on — Horizon as the CLIENT
    providers: [{ id, displayName?, kind?, issuer?, clientId, clientSecret?,
                  allowedDomains? }]      # HOW you sign in, per provider
    roles?: { defaultRoles?, roleByEmail?, roleByDomain? }   # WHAT you get, ONE table
  tokensFile?: string          # path to API tokens for callers with no browser

rbac:
  enabled?: boolean
  roles?: { <name>: [verb, ...] }
  landingByRole?: { <name>: "/route" }

session: { ttlMinutes?, cookieName?, cookieSecure? }
debugLog: { enabled?, file?, maxBodyChars?, redactAuthHeaders? }
query:   { landingServiceCap?, overviewTopN? }
sourceMaps: { enabled?, maxFileBytes?, maxTotalBytes?, maxFileCount?, bootMountDir? }

ai:
  enabled?: boolean            # off by default
  provider?: openai-compatible | bedrock
  model?: string
  baseUrl?: string             # openai-compatible only
  region?: string              # bedrock only; blank → AWS_REGION / AWS_DEFAULT_REGION
  apiKey?: string              # secret — set via env interpolation only
  systemPrompt?: string        # blank → bundled default
  starters?: [string, ...]     # blank → bundled defaults
  history?: { maxMb? }

mcp:
  enabled?: boolean            # ON by default
  name?: string                # what this deployment calls itself to an agent

oauth:                         # authorization server — OFF by default
  enabled?: boolean
  issuer?: string              # defaults to server.publicUrl
  signingKey?: string          # secret — env only, 32+ chars (openssl rand -base64 32)
  accessTokenMinutes?: number
  refreshTokenDays?: number    # 0 disables refresh tokens
  clientMetadataHosts?: [string, ...]   # hosts whose client-metadata URLs are accepted

performance:
  bulk:
    topology:  { nodeBulkSize?, edgeBulkSize?, concurrency? }
    infra3d:   { metricBulkSize?, metricConcurrency?, topologyConcurrency?, templateConcurrency? }
    landing:   { bulkSize?, concurrency? }
    dashboard: { bulkSize? }
  limits:
    topologyMaxNodes?: number
    topologyMaxEdges?: number
    maxPageSize: { traces?, logs?, browserLogs?, events? }

layers:  { excluded?: [{ key, reason? }] }

The ai block’s fields, env-var forms, and provider recipes are documented on AI Assistant.

ai and mcp are independent. ai configures the model this Horizon talks to for its own assistant panel, and is off until you give it one. mcp lets an external agent — Claude Code, Codex, Claude Desktop — read this Horizon through the Model Context Protocol, with the model staying on the caller’s side; it needs no provider and no key, so it is on by default. Turn it off with enabled: false (or HORIZON_MCP_ENABLED=false) and POST /api/mcp answers 503.

oauth is a third, separate thing again: it makes Horizon an authorization server — the role Google plays when an application offers “Sign in with Google”, except here Horizon is the one issuing and the login page shown is its own. It lets an MCP client send its operator through a browser login instead of being handed a token. It is off by default and needs two values to start — a public base URL (from server.publicUrl, or oauth.issuer to override it) and signingKey, a secret. Both are required: with either missing the server stays off and its endpoints answer 404, and a warning names what is missing at boot. See MCP.

Environment variable interpolation

${VAR} and ${VAR:default} are expanded before YAML parsing.

  • ${VAR} — fail-loud. Expands to the env var; if unset, expands to empty string and the schema decides whether empty is valid. Use for secrets so a missing env var stops startup.
  • ${VAR:default} — fail-soft. Expands to the env var, or the literal default if unset. Use for optional non-secret values.
oap:
  auth:
    password: "${HORIZON_OAP_PW}"             # fails loud if unset
ldap:
  bindPassword: "${HORIZON_LDAP_PW:}"         # empty if unset (works for anonymous bind)

Bootstrap rules

The BFF validates the file shape at startup and on every hot reload. Schema errors still reject the file; auth bootstrap gaps are softer so a first-run container can render the login page with a setup-required banner.

Auth gaps that boot with a warning but reject login:

  1. auth.backend: local and auth.local.users is empty.
  2. auth.backend: ldap and auth.ldap block is missing.
  3. auth.backend: ldap and auth.ldap.groupMappings is empty.

There is no “default admin/admin” fallback.

Warnings (do not block startup)

These combinations are legal but risky or ineffective; the BFF boots and logs a warning:

  • auth.backend: ldap but auth.local.users populated → local users will be ignored.
  • auth.breakGlass configured while auth.backend: local → break-glass only exists as an LDAP-outage fallback, so the block is unused.
  • debugLog.enabled: true with redactAuthHeaders: false → outbound OAP basic-auth credentials are written to the wire log in clear text.
  • debugLog.enabled: true at startup → a reminder that every outbound OAP request/response is being appended to the wire log (very verbose).
  • session.cookieSecure: false outside development → session cookies are sent over plain HTTP. Fine for localhost; set it to true (behind HTTPS) in production.

Hot reload behavior

The config is re-read on file change and the new values take effect without a restart. An edit that fails validation does not apply: the BFF logs an error listing each failing field path, and the previous valid config keeps serving until the file is fixed.

Applied live:

  • Auth backend selection (re-evaluated on next login).
  • RBAC roles and policy (re-evaluated on next route call).
  • OAP URLs and credentials (used on next outbound call).
  • Session TTL (applies to every session immediately, already-signed-in ones included — see Sessions).
  • sourceMaps.enabled, sourceMaps.maxTotalBytes, sourceMaps.maxFileCount — applied on the next source-map upload / resolve / list. Lowering a budget trims the in-memory uploaded set then (least-recently-used first). It does not shrink maps already loaded from the static mount — see below. One caveat on enabled: turning it on in a live file enables uploads and resolves, but the static mount is scanned only at startup, so a BFF that booted with sourceMaps.enabled: false has an empty mounted set until it restarts.
  • debugLog in full — enabled, file and the redaction flags. Changing file rotates the wire log to the new path, finishing the write in flight on the old one.
  • security.trustedLinkDomains — applied the next time a template’s outbound link is published or read back.

These changes require a process restart:

  • server.host, server.port — the listener already bound.
  • server.staticDir — the static root is registered with the HTTP server at startup.
  • server.trustProxy — the HTTP server is constructed once with this value, so a live edit is ignored until restart.
  • The whole audit block — the store is opened and the writer started at boot, so turning the login audit on, off, or repointing it takes effect on the next restart.
  • templates.mode — the template source is chosen at boot (the OAP seed either ran or was skipped). Editing it in a live file logs a warning and keeps the boot-time mode until restart.
  • Nothing about OAP capability detection. The schema-introspection and trace-API probes are cached for five minutes and keyed by oap.queryUrl, so an OAP upgrade is picked up within that window and repointing OAP re-probes at once — neither needs a restart.
  • sourceMaps.bootMountDir — the static source-map directory is scanned once at startup, so a new directory (and newly-dropped .map files) needs a restart. The count of maps loaded from that mount is fixed by the startup scan as well: lowering sourceMaps.maxFileCount afterwards trims only the in-memory uploaded set, never the already-mounted maps — restart to re-scan a mount against a lower count.
  • Raising sourceMaps.maxFileBytes — the multipart upload size limit is fixed at startup; lowering it applies live.

Template source mode

templates:
  mode: live   # default; or: readonly

templates.mode selects where dashboard / overview templates live:

  • live (default) — at boot, Horizon seeds any missing bundled templates into OAP through the OAP 11 /ui-management/templates* REST API, then reads and writes templates through that API. Admin edits (layer dashboards, overview templates, translations) persist in OAP’s ui_template storage, independent of the Horizon instance.
  • readonly — templates render from the local bundle only. Horizon does not call an OAP template-management API and the template admin surface is read-only. OAP’s query API (metrics / traces / logs) is still used and health-checked either way. This is required on OAP 10.x: OAP 10 has persistent template management through legacy query-port GraphQL, but Horizon implements only the OAP 11 /ui-management/templates* REST protocol. In live mode that protocol mismatch leaves the store unreachable, and Horizon blocks layer-driven pages (most visibly Traces) rather than render a layer whose published template it cannot read. See Compatibility → OAP Version.

Env form: HORIZON_TEMPLATES_MODE. Changing the mode requires a BFF restart — see Hot reload behavior.

Query limits

query:
  landingServiceCap: 100   # default
  overviewTopN: 100        # default

query.landingServiceCap bounds how many services a layer landing runs column-metric MQE for, per request. The service picker always lists every service in the layer, but only fetches metric columns for up to this many — and when a layer has more, the BFF runs one cheap single-metric pass (the landing’s order-by column over every service) to pick the true top-N, then fetches the full columns for just those. Services below the cap still appear in the picker, showing low in the order-by column (and for the others, which were never probed) — every service stays browsable and selectable. The picker header reads “metrics: top N” so the metric trim is never silent.

  • Default 100. Most layers have fewer services and render in full.
  • Raise it (e.g. 300, 500) if your OAP and storage backend can take the larger fan-out and you want metrics for more services at once.
  • Lower it to protect a modest deployment from heavy landings.

What it bounds. The cap limits the full-column MQE fan-out (the expensive part — every configured column × service). When a layer exceeds it, the true top-N is found by a single cheap pass that evaluates only the order-by column for every service — so on a very large layer that one ranking pass still scales with the service count (it’s one metric, batched through a bounded-concurrency pool, not the full column set). The cap is therefore a bound on the expensive fan-out, not a hard ceiling on total OAP traffic. If you need a hard ceiling on a pathological layer, lower the cap and pair it with a tighter OAP rate limit.

Hot-reloadable — a change takes effect on the next landing request.

query.overviewTopN is the N for the Overview dashboards’ KPI tiles: each tile aggregates a whole layer with a server-side top_n(<metric>, N, …) rollup, so OAP does the ranking and one query per tile comes back regardless of how many services the layer holds. The default 100 covers every layer on a normal deployment — raise it only if a single layer holds more than 100 services and the tail matters to the aggregate; lower it to cheapen the rollup on a constrained backend. Hot-reloadable — applies on the next Overview request.

Performance tuning

performance:
  bulk:
    topology:  { nodeBulkSize: 150, edgeBulkSize: 200, concurrency: 4 }
    infra3d:   { metricBulkSize: 6, metricConcurrency: 4, topologyConcurrency: 4, templateConcurrency: 8 }
    landing:   { bulkSize: 6, concurrency: 8 }
    dashboard: { bulkSize: 6 }
  limits:
    topologyMaxNodes: 5000
    topologyMaxEdges: 15000
    maxPageSize: { traces: 100, logs: 100, browserLogs: 100, events: 200 }

The performance block tunes how hard Horizon drives your OAP and storage backend. Every default equals the built-in value, so the whole block is optional — omit it and Horizon behaves exactly as it does without it. Every value is also clamped to a hard ceiling: a number above the ceiling is pulled back down to it (config can only lower the load below a built-in limit, never raise it past one). Hot-reloadable — a change takes effect on the next request of that kind.

The rule of thumb: raise these on a beefy OAP with a fast storage backend that can absorb more parallel queries (you’ll fill pages and maps faster); lower them on a modest deployment where a busy OAP rejects or slows under the burst.

performance.bulk — query fan-out

These govern how Horizon batches and parallelizes its metric queries to OAP. Each family has a bulk size (how many metric expressions ride in one OAP request — fewer, larger requests vs. more, smaller ones) and most have a concurrency (how many of those requests are in flight at once).

Section Tunes Defaults
bulk.topology The service-map family (topology, instance topology, deployment, endpoint dependency) node/edge metric fan-out. nodeBulkSize: 150, edgeBulkSize: 200, concurrency: 4
bulk.infra3d The 3D Infrastructure Map’s metric, topology, and template loading. metricBulkSize: 6, metricConcurrency: 4, topologyConcurrency: 4, templateConcurrency: 8
bulk.landing The per-layer landing’s service-column metric batches. bulkSize: 6, concurrency: 8
bulk.dashboard A dashboard’s widget metric fan-out. bulkSize: 6
  • Raise concurrency / *Concurrency to load a large topology, 3D map, landing, or dashboard faster when OAP has headroom. Lower it (toward 1) if OAP rejects or slows under the burst of parallel requests.
  • Bulk sizes trade request count against request size: a larger bulk means fewer, fatter OAP requests. OAP rejects an oversized request, so each bulk size is capped — leave it at the default unless you have a specific reason to change it.
  • For the 3D map specifically, these knobs are also described in context on the 3D Infrastructure Map page.

performance.limits — render & record caps

Field Caps Default
topologyMaxNodes The render valve for a service map — a graph with more nodes than this is rejected with a “narrow the scope” notice rather than drawn as an unreadable hairball. 5000
topologyMaxEdges The same valve on edges. 15000
maxPageSize.traces The largest page the Traces list will show — records displayed at once, not a page count. The page-size picker on the page maxes at this same value, so a client can’t out-ask the dropdown. Each read fetches one record beyond the page, which is how the list knows whether there is a next page. 100
maxPageSize.logs The same displayed-page cap for Logs. 100
maxPageSize.browserLogs The same displayed-page cap for Browser Logs. 100
maxPageSize.events The same displayed-page cap for Events. Defaults deeper than the other feeds because events are grouped for display (one deployment produces many per-instance rows), so a raw page has to carry more records to fill a screen. 200
  • topologyMaxNodes / topologyMaxEdges are a readability and safety valve, not a data limit — if your deployment legitimately has a graph this large, raising them lets it render (at the cost of a denser scene and a heavier draw). Lower them if you’d rather force operators to scope down sooner.
  • maxPageSize.* bound how many rows one Traces / Logs / Browser-Logs / Events page shows, and with it how much each list read pulls from storage (one record beyond the page — that extra row is how the list knows another page exists). Some storage backends fail or slow on large list queries — lower these to keep list pages cheap on a constrained backend; raise them (up to the ceiling) if your backend serves big result sets comfortably and operators want more rows per page.

Excluded layers

layers:
  excluded:            # defaults when the block is omitted:
    - key: FAAS              # deprecated
      reason: Deprecated.
    - key: VIRTUAL_GATEWAY   # not planned
      reason: Not planned to set up.

layers.excluded hides specific layers from the sidebar even when OAP reports them in listLayers. Keys are OAP layer keys (UPPER_SNAKE), matched case-insensitively. reason is a note for whoever reads the file — it is not shown in the UI; an excluded layer simply doesn’t appear.

  • Defaults: FAAS and VIRTUAL_GATEWAY are excluded when the block is omitted. Set excluded: [] to surface every layer OAP reports.
  • This is the only hide list — there is no hard-coded one. The other way a layer disappears is an admin explicitly disabling its template on the Layer dashboards page.

Cross-references

  • A field that affects user-visible behavior at runtime is also visible on Admin → Auth Status (/admin/auth-status) for live verification — see Admin Pages.
  • The wire-level effect of any oap.* change is visible in horizon-wire.jsonl when debugLog.enabled: true — see debugLog.