Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/api/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ Structured logging, event constants, correlation tracking, and log sinks.

::: synthorg.observability.sinks

## Sink Config Builder

::: synthorg.observability.sink_config_builder

## Enums

::: synthorg.observability.enums
Expand Down
15 changes: 12 additions & 3 deletions docs/design/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -1398,12 +1398,21 @@ the console stream for `docker logs` access.

### Runtime Settings

Two observability settings are runtime-editable via `SettingsService`:
Four observability settings are runtime-editable via `SettingsService`:

- `root_log_level` (enum: debug/info/warning/error/critical) -- changes the root logger level
- `enable_correlation` (boolean) -- toggles correlation ID injection
- `sink_overrides` (JSON) -- per-sink overrides keyed by sink identifier (`__console__` for the
console sink, file path for file sinks). Each value is an object with optional fields:
`enabled` (bool), `level` (string), `json_format` (bool), `rotation` (object with `max_bytes`,
`backup_count`, `strategy`). The console sink cannot be disabled (`enabled: false` is rejected).
- `custom_sinks` (JSON) -- additional file sinks as a JSON array. Each entry requires `file_path`
and optionally: `level` (default info), `json_format` (default true), `rotation` (object),
`routing_prefixes` (array of logger name prefix strings for targeted routing).

Console sink level can also be overridden via `SYNTHORG_LOG_LEVEL` env var.

Full sink CRUD via SettingsService (add/remove/reconfigure sinks at runtime) is planned as a
future enhancement.
Changes take effect without restart -- the `ObservabilitySettingsSubscriber` rebuilds the entire
logging pipeline via `configure_logging()` (idempotent) when any of the four observability
settings change (`root_log_level`, `enable_correlation`, `sink_overrides`, or `custom_sinks`).
Custom sink file paths cannot collide with default sink paths (reserved even if disabled).
47 changes: 16 additions & 31 deletions src/synthorg/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
from synthorg.settings.subscribers import (
BackupSettingsSubscriber,
MemorySettingsSubscriber,
ObservabilitySettingsSubscriber,
ProviderSettingsSubscriber,
)
from synthorg.tools.invocation_tracker import ToolInvocationTracker # noqa: TC001
Expand Down Expand Up @@ -399,25 +400,10 @@ async def on_shutdown() -> None:
return [on_startup], [on_shutdown]


# ── 2-Phase Initialisation ────────────────────────────────────────
#
# Phase 1 (construct): Litestar bakes middleware, CORS, and routes
# into the app at construction time -- these read directly from
# RootConfig and are immutable after construction. Bootstrap-only
# settings (server_host, server_port, api_prefix, cors_allowed_origins,
# rate_limit_exclude_paths, auth_exclude_paths) are therefore NOT
# resolved through SettingsService.
#
# Phase 2 (on_startup): After persistence connects and migrations
# run, SettingsService + ConfigResolver become available. Runtime-
# editable settings (rate_limit_max_requests, rate_limit_time_unit,
# jwt_expiry_minutes, min_password_length) are resolved through
# ConfigResolver.get_api_config() by consumers that need current
# values post-startup.
#
# Note: Litestar's rate-limit middleware reads max_requests and
# time_unit at construction; runtime DB changes are visible only
# to code calling get_api_config(), not to the middleware itself.
# 2-Phase Init: Phase 1 (construct) bakes immutable middleware/CORS/routes
# from RootConfig. Phase 2 (on_startup) wires SettingsService + ConfigResolver
# for runtime-editable settings. Litestar rate-limit middleware reads config at
# construction; runtime DB changes only affect code calling get_api_config().


def _bootstrap_app_logging(effective_config: RootConfig) -> RootConfig:
Expand Down Expand Up @@ -628,7 +614,11 @@ def create_app( # noqa: PLR0913
startup_time=time.monotonic(),
)

bridge = _build_bridge(message_bus, channels_plugin)
bridge = (
MessageBusBridge(message_bus, channels_plugin)
if message_bus is not None
else None
)
backup_service = build_backup_service(
effective_config,
resolved_db_path=resolved_db_path,
Expand Down Expand Up @@ -717,16 +707,6 @@ def create_app( # noqa: PLR0913
)


def _build_bridge(
message_bus: MessageBus | None,
channels_plugin: ChannelsPlugin,
) -> MessageBusBridge | None:
"""Create message bus bridge if bus is available."""
if message_bus is None:
return None
return MessageBusBridge(message_bus, channels_plugin)


def _build_settings_dispatcher(
message_bus: MessageBus | None,
settings_service: SettingsService | None,
Expand All @@ -743,7 +723,12 @@ def _build_settings_dispatcher(
settings_service=settings_service,
)
memory_sub = MemorySettingsSubscriber()
subs: list[SettingsSubscriber] = [provider_sub, memory_sub]
log_dir = config.logging.log_dir if config.logging is not None else "logs"
observability_sub = ObservabilitySettingsSubscriber(
settings_service=settings_service,
log_dir=log_dir,
)
subs: list[SettingsSubscriber] = [provider_sub, memory_sub, observability_sub]
if backup_service is not None:
subs.append(
BackupSettingsSubscriber(
Expand Down
12 changes: 12 additions & 0 deletions src/synthorg/observability/events/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,15 @@
SETTINGS_SERVICE_SWAPPED: Final[str] = "settings.service.swapped"
SETTINGS_SERVICE_SWAP_FAILED: Final[str] = "settings.service.swap_failed"
SETTINGS_CHANNEL_CREATED: Final[str] = "settings.channel.created"

# ── Observability subscriber events ──────────────────────────────

SETTINGS_OBSERVABILITY_PIPELINE_REBUILT: Final[str] = (
"settings.observability.pipeline_rebuilt"
)
SETTINGS_OBSERVABILITY_REBUILD_FAILED: Final[str] = (
"settings.observability.rebuild_failed"
)
SETTINGS_OBSERVABILITY_VALIDATION_FAILED: Final[str] = (
"settings.observability.validation_failed"
)
36 changes: 32 additions & 4 deletions src/synthorg/observability/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,18 @@
import os
import sys
from pathlib import Path
from typing import Any
from types import MappingProxyType
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from collections.abc import Mapping

import structlog

from synthorg.observability.config import DEFAULT_SINKS, LogConfig, SinkConfig
from synthorg.observability.enums import LogLevel, SinkType
from synthorg.observability.processors import sanitize_sensitive_fields
from synthorg.observability.sinks import build_handler
from synthorg.observability.sinks import SINK_ROUTING, build_handler

# Default per-logger levels applied when no config overrides are given.
_DEFAULT_LOGGER_LEVELS: tuple[tuple[str, LogLevel], ...] = (
Expand Down Expand Up @@ -176,6 +180,8 @@ def _attach_handlers(
config: LogConfig,
root_logger: logging.Logger,
shared_processors: list[Any],
*,
routing_overrides: Mapping[str, tuple[str, ...]] | None = None,
) -> None:
"""Build and attach a handler for each configured sink.

Expand All @@ -187,17 +193,27 @@ def _attach_handlers(
config: The logging configuration.
root_logger: The stdlib root logger.
shared_processors: Processor chain for the foreign pre-chain.
routing_overrides: Optional extra routing entries (e.g. from
custom sinks) merged with the default ``SINK_ROUTING``.
An empty mapping is treated the same as ``None``.

Raises:
RuntimeError: If a critical sink fails to initialise.
"""
effective_routing: Mapping[str, tuple[str, ...]] | None = None
if routing_overrides:
merged = dict(SINK_ROUTING)
merged.update(routing_overrides)
effective_routing = MappingProxyType(merged)

log_dir = Path(config.log_dir)
for sink in config.sinks:
try:
handler = build_handler(
sink=sink,
log_dir=log_dir,
foreign_pre_chain=shared_processors,
routing=effective_routing,
)
root_logger.addHandler(handler)
except (OSError, RuntimeError, ValueError) as exc:
Expand Down Expand Up @@ -308,7 +324,11 @@ def _apply_console_level_override(config: LogConfig) -> LogConfig:
return config.model_copy(update={"sinks": tuple(new_sinks)})


def configure_logging(config: LogConfig | None = None) -> None:
def configure_logging(
config: LogConfig | None = None,
*,
routing_overrides: Mapping[str, tuple[str, ...]] | None = None,
) -> None:
"""Configure the structured logging system.

Sets up structlog processor chains, stdlib handlers, and per-logger
Expand All @@ -321,6 +341,9 @@ def configure_logging(config: LogConfig | None = None) -> None:
Args:
config: Logging configuration. When ``None``, uses sensible
defaults with all standard sinks.
routing_overrides: Optional extra logger-name routing entries
(e.g. from custom sinks) merged with the default
``SINK_ROUTING`` table.

Raises:
RuntimeError: If a critical sink fails to initialise.
Expand Down Expand Up @@ -349,7 +372,12 @@ def configure_logging(config: LogConfig | None = None) -> None:
_configure_structlog(enable_correlation=config.enable_correlation)

# 6. Build and attach handlers for each sink
_attach_handlers(config, root_logger, shared)
_attach_handlers(
config,
root_logger,
shared,
routing_overrides=routing_overrides,
)

# 7. Tame third-party loggers (clear duplicate handlers, set defaults)
_tame_third_party_loggers()
Expand Down
Loading
Loading