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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user