feat: observability, per-receiver Telegram options, oversized-video fallback

Operability:
- Correlation IDs end-to-end: shared dispatch_id between log lines and
  EventLog rows (event/watcher/scheduled/deferred/action/HA/command paths)
  and a new X-Request-Id middleware that normalizes inbound ids and binds
  request_id into log context.
- dispatch_summary block merged into EventLog.details: per-target
  success/failure counts plus Telegram media delivered/skipped/failed and
  truncated error lists, so partial outcomes surface in the UI.
- Diagnostic mode: admin can flip one module to DEBUG for a bounded
  window with auto-revert (in-memory only; setup_logging() resets on
  boot, lifespan reverts on shutdown). New /diagnostic-mode endpoints
  plus DiagnosticsCassette UI on the settings page.

Telegram:
- Per-receiver options: disable_notification (silent send) and
  message_thread_id (forum-topic routing), wired through the dispatcher
  via a ContextVar so all four send sites (sendMessage / sendPhoto-Video-
  Document / sendMediaGroup / cache-hit POST) pick them up.
- send_large_videos_as_documents target setting: bypass the 50 MB
  sendVideo cap by falling back to sendDocument for oversized videos.
- sendMediaGroup byte-budget enforcement (TELEGRAM_MAX_GROUP_TOTAL_BYTES,
  45 MB) with per-item fallback on chunk failure so a stale file_id no
  longer silently drops a cached asset.

Tests:
- New: diagnostic_mode, dispatch_summary, request_correlation,
  telegram_media_group_partial, telegram_per_send_options.

Docs:
- .claude/reviews/: six-axis production-readiness review of v0.8.1.
- .claude/docs/functional-review-2026-05-28.md: focused review of
  Telegram/Immich/logging subsystems.
This commit is contained in:
2026-05-28 15:19:31 +03:00
parent 85a8f1e71c
commit 6a8f374678
39 changed files with 7239 additions and 142 deletions
@@ -9,6 +9,11 @@ from typing import Any, Awaitable, Callable
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from notify_bridge_core.log_context import (
bind_log_context,
ensure_dispatch_id,
enrich_details_with_correlation,
)
from notify_bridge_core.models.events import ServiceEvent
from notify_bridge_core.notifications.dispatcher import NotificationDispatcher, TargetConfig
from notify_bridge_core.notifications.telegram.cache import TelegramFileCache
@@ -30,6 +35,7 @@ from .dispatch_helpers import (
load_link_data,
resolve_provider_credential,
)
from .dispatch_summary import record_dispatch_summary_async
_LOGGER = logging.getLogger(__name__)
@@ -262,6 +268,13 @@ _POLL_FACTORIES: dict[str, PollerFactory] = {
async def check_tracker(tracker_id: int) -> dict[str, Any]:
"""Poll a tracker's provider for changes and dispatch notifications."""
# Bind a per-tick dispatch_id so the EventLog row written for each detected
# change carries the same correlation id as the dispatcher's log lines.
with bind_log_context(dispatch_id=ensure_dispatch_id()):
return await _check_tracker_impl(tracker_id)
async def _check_tracker_impl(tracker_id: int) -> dict[str, Any]:
engine = get_engine()
# Load all DB data eagerly before entering aiohttp context
@@ -457,7 +470,7 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
collection_id=event.collection_id,
collection_name=event.collection_name,
assets_count=assets_count,
details=details,
details=enrich_details_with_correlation(details),
)
session.add(log)
await session.flush()
@@ -605,6 +618,10 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
event.provider_type.value != "bridge_self"
)
# Per-event accumulator so the summary write covers every
# tracking-config group, not just the last one.
event_results: list[dict[str, Any]] = []
for tc, target_entries in groups.values():
if not target_entries:
continue
@@ -616,6 +633,7 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
continue
target_configs = [entry[0] for entry in target_entries]
results = await dispatcher.dispatch(shaped_event, target_configs)
event_results.extend(results)
for entry, r in zip(target_entries, results):
_, target_id, target_name = entry
if r.get("success"):
@@ -637,6 +655,15 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
"bridge_self target-failure emission failed",
)
# The EventLog row was committed in the earlier session block
# so we run a tiny follow-up UPDATE in a fresh session. Best-
# effort: a failure here logs but does not abort the watcher.
if event_log_id is not None and event_results:
async with AsyncSession(engine) as summary_session:
await record_dispatch_summary_async(
summary_session, event_log_id, event_results,
)
return {
"status": "ok",
"events_detected": len(events),