Skip to content

Observability

Forze is already observability-rich inside — a runtime tracer, a transaction tracer, structured logs. One call pushes that out to OpenTelemetry: a span and metrics for every operation, tagged with identity and correlation, so your operations show up in any tracing or metrics backend.

OpenTelemetry is a core dependency — the logging layer already uses it — so this is built in. You only bring an exporter.

Instrument every operation

Wrap the registry once, before you freeze it:

from forze.application.execution import instrument_operations

registry = instrument_operations(build_orders_registry())
frozen = registry.freeze()

That's the whole integration. It emits through the global OTel providers by default; pass tracer= / meter= to target your own.

What you get

Every operation produces a span, named by its operation key and nested under whatever span is already active (an incoming HTTP request, say). A failure records the exception and sets the span status to ERROR — then re-raises it unchanged. The span carries:

Attribute Value
forze.operation / forze.operation.kind the operation key and query / command
forze.execution_id / forze.correlation_id / forze.causation_id the invocation metadata
forze.tenant_id / forze.principal_id the bound tenant and principal

Alongside each span, two metricsforze.operations (a counter) and forze.operation.duration (a histogram, in ms) — labelled by operation, kind, and outcome.

Trace every outbound call

The operation span tells you that an operation ran and how long it took; to see where the time went — which database read, which cache call, which outbound request — span the ports too. In a hexagonal app every external call is a port, so opting the resolved ports into a span turns that seam into a complete outbound-I/O trace under each operation. It rides on the dependency registry, alongside instrument_operations on the operation registry:

from forze.application.execution import DepsRegistry

deps = DepsRegistry.from_modules(postgres_module, redis_module).with_otel_port_spans()

Freeze deps into the runtime as usual. Every document / cache / queue / search / HTTP call now opens a CLIENT span named surface.op (document_command.create, cache_query.get), nested under the operation span:

Attribute Value
forze.port.domain / forze.port.surface the port family and its dependency surface
forze.port.op the method called — create, get, publish
forze.port.route / forze.port.phase the specification name and phase, when present

The name stays low-cardinality (surface.op); the specification name rides as the route attribute, never in the name. The span sits inside the resilience policy, so a retried call is one span per attempt and a call shed by an open breaker or a full bulkhead emits none — the trace shows the work that actually reached the backend. Failure status follows the exception kind: an infrastructure, internal, or configuration fault reds the span, while a domain failure the caller can handle — not-found, conflict, precondition — leaves it clean, exactly as a 404 does not red an HTTP client span. Streaming methods (a cursor, a consume loop, a subscription) pass through un-spanned — a lone span around a long-lived stream is the wrong shape; per-message spans belong in the consumer.

For a log line per outbound call instead of (or alongside) a span, add deps.with_port_logging(). It logs every port call uniformly — surface, route, op, and duration_ms under forze.integrations.<domain> — at trace on success (so it costs nothing in production unless you turn trace on), debug on an expected domain failure, and warning with a traceback on an unexpected one.

Follow the trace across boundaries

Spans and port spans cover one operation inside one process. When the work crosses a boundary the trace would normally break — so Forze carries the W3C traceparent across the two crossings OpenTelemetry's transport instrumentation can't bridge on its own.

An event published now and consumed later keeps its trace when you set propagate_trace=True on the outbox integration: the publishing operation's trace context is captured as the event is staged, travels with it through the broker, and the consumer rebuilds it so the handler's spans link back to the publish span. On a relational backend, add a nullable traceparent column first — exactly as you do for hlc_ordering.

An outbound HTTP request carries it automatically: the HTTP adapter injects the active trace context into every request (a no-op when no span is active), honouring your application's configured propagator. Nothing to enable.

The traceparent is untrusted metadata

It arrives over the broker or the wire, so it influences trace parenting only — never identity, tenancy, or deduplication. Treat it as a hint for the tracing backend, nothing more.

Inbound HTTP runs the other way: a request arriving at FastAPI is picked up by the standard opentelemetry-instrumentation-fastapi, which creates the server span your operation span nests under — so Forze does not re-extract it.

Resilience metrics

The resilience layer makes decisions worth watching — retries, rejections, breaker trips, bulkhead backoff. instrument_resilience exports them as always-on metrics, independent of any tracing gate, so a production process with tracing off still reports them:

from forze.application.execution import instrument_resilience

instrument_resilience(ctx.resilience())  # once, when the scope is up
Metric What it carries
forze.resilience.events (counter) every event — retry attempts, timeouts, rate-limit and bulkhead rejections, breaker transitions — labelled by event, policy, and route
forze.resilience.breaker.state (gauge) breaker phase per policy/route: 0 closed, 1 half-open, 2 open
forze.resilience.bulkhead.queue_depth (gauge) calls queued behind each bulkhead, sampled at collection
forze.resilience.bulkhead.limit (gauge) the current adaptive-bulkhead concurrency limit
forze.resilience.hedge.delay (gauge) the effective adaptive hedge delay (P² quantile estimate), in seconds

Two reading notes: breaker_open counts the open transition and every admission shed while open, so its rate tracks shed load; and a breaker that never tripped reports no state at all — closed by absence.

Tenant pool metrics

Routed clients keep one connection pool per tenant in a bounded LRU, and evicting a pool is expensive — the next request rebuilds the connection from scratch. instrument_tenant_pools exports the churn counters:

from forze.application.execution import instrument_tenant_pools

instrument_tenant_pools({"postgres": pg, "redis": redis})

Per client (labelled forze.client): forze.tenancy.pool.size and ….capacity gauges, plus cumulative ….created, ….disposed, and ….evicted_explicit counters. The alert worth setting: a sustained creation rate while size sits at capacity means the LRU is thrashing — hot tenants' pools evicted by cold one-off traffic, each rebuild paying full connection establishment. The fix is usually a larger max_cached_tenants; the metric tells you when.

Document L1 metrics

The in-process L1 exports its counters the same way:

from forze.application.integrations.document import instrument_document_l1

instrument_document_l1()

Per document (labelled forze.document): forze.cache.l1.size / ….capacity gauges and cumulative ….hits / ….misses / ….evictions counters. The hit rate validates that the L1 is earning its staleness budget, and sustained evictions at full capacity with a sagging hit rate is the scan-pollution signature — the signal to switch the eviction policy to the in-box W-TinyLFU store or raise capacity.

Configure logging in one call

bootstrap_logging wires the whole logging surface: the framework's own loggers (forze.*, forze_kits.*, and the forze.integrations.* adapters), any integration loggers you name, third-party stdlib loggers routed through the same formatter, and the uncaught-exception hook.

from forze import bootstrap_logging
from forze_postgres import FORZE_POSTGRES_LOGGER_NAMES
from forze_redis import FORZE_REDIS_LOGGER_NAMES

bootstrap_logging(
    level="info",
    render_mode="json",  # "console" for local dev
    logger_names=[FORZE_POSTGRES_LOGGER_NAMES, FORZE_REDIS_LOGGER_NAMES],
    third_party=["uvicorn", "sqlalchemy.engine"],
)

configure_logging remains the lower-level entry point when you want to wire the pieces yourself. Either way, pass otel_config=... and the active span's trace_id and span_id are injected into every log line — because instrument_operations starts that span, your structured logs line up with the operation trace automatically, no extra wiring.

Log levels and the verbosity budget

The framework holds one rule: at level="info" it emits almost nothing per request. A quiet default is a feature — you turn detail up when investigating, rather than filtering noise out in steady state. Each level has a fixed meaning:

Level What the framework logs here Steady-state volume
trace per-row / per-message / per-port-call detail none in production — the trace gate is one integer compare unless you configure level="trace"
debug per-operation internals, cache hits, dedup skips opt-in
info lifecycle events only: startup, shutdown, saga pivot, relay batch summaries rare
warning degraded-but-continuing: retries exhausted, breaker open, a callback failed rare, deduped
error an unhandled server-side fault (a bug) should be ~zero
critical data loss or an unrecoverable condition ~zero

A domain failure is never an error. A validation, not-found, conflict, or precondition outcome is the application working as designed — it logs at debug or not at all, exactly as it leaves an HTTP span clean. Only an unhandled fault reaches error.

Tame high-volume logs

Two per-event controls collapse the events that would otherwise flood a log, without dropping the first occurrence you actually need to see. They are a no-op for events that don't opt in:

# keep 1 in 100 of a uniformly high-volume event
logger.debug("cache miss", _sample=100)

# emit a flapping condition at most once per window (default 60s)
logger.warning("upstream degraded", _dedup_key="upstream-degraded")

_sample=N keeps one in every N events sharing the same logger and message; _dedup_key emits at most one event per key per window (_dedup_window= overrides the seconds). The control keys are stripped before rendering. This is on by default (configure_logging(enable_sampling=True)).

For per-request access logs — the largest steady-state source — the FastAPI and MCP middlewares are quiet by default: successful requests are sampled 1-in-N and error responses are always logged. The FastAPI middleware additionally drops health and readiness probe paths (DEFAULT_HEALTH_PATHS); MCP messages have no such path, so its default sampler applies no path exclusion. Configure either with access_log=AccessLogSampler(...): mode="full" logs every request, mode="off" disables them, and sample_rate / exclude tune the rate and the excluded subjects (request paths for FastAPI, method names for MCP).

Sensitive data is scrubbed

Log output runs through a redaction pass that masks sensitive keys (password, token, secret, api-key, cookie, authorization, …) and secret-shaped values (bearer tokens, JWTs, connection DSNs) in both extras and the message text. Extend it for deployment-specific patterns once at startup:

from forze.base.scrubbing.policy import register_sensitive_patterns

register_sensitive_patterns(keys=["x_internal_token"])

Disable with configure_logging(sanitize_logs=False) only when you fully control the sink and its retention.

Naming: where a log line comes from

Every logger is namespaced so you can raise or lower detail per area without touching the rest. Core framework logs sit under forze.* (forze.application, forze.domain); pre-built wiring under forze_kits.*; each integration under its own forze_<name>.* (forze_postgres.adapters, forze_redis.kernel). Generic adapter machinery shared across integrations logs under forze.integrations.<domain> (forze.integrations.cache, forze.integrations.document) — filter the whole group with forze.integrations.*, or a single domain on its own.

Bring your own exporter

Forze emits to the global tracer and meter providers; your application owns the SDK and exporter choice — OTLP, Prometheus, console, whatever your backend speaks. The OTel API and SDK ship with Forze, so you add only the exporter package and the few lines of standard OTel setup that point the providers at it.

The signals you watch in production are also what you assert against before shipping — see Testing.