Skip to content

Data Model: Observability

Feature: 006-observability | Date: 2026-05-29

This is a brownfield feature — no new database tables or ORM models. The observability stack operates on events, context variables, and external service APIs. This document describes the in-memory and context-based data structures used by observability components.

Entities

Metric Instrument

FieldTypeDescription
namestrOTel instrument name (e.g., scraper_runs_total)
kindCounter | HistogramInstrument type
valueint | floatCurrent accumulated value
attributesdict[str, str]Per-call labels (e.g., {"source": "rss"})

Instruments defined (module-level in otel_metrics.py):

NameKindDescription
scraper_runs_totalCounterTotal number of scraper runs
scraper_run_duration_secondsHistogramDuration of each scraper run
scraper_articles_found_totalCounterTotal articles discovered
scraper_articles_new_totalCounterNew (non-duplicate) articles
scraper_articles_duplicate_totalCounterDuplicate articles skipped
scraper_errors_totalCounterErrors encountered per source

Run Context

FieldTypeStorageDescription
run_idstr (UUID4)ContextVarUnique identifier for the current scraper run
correlation_idstr (UUID4)ContextVarBound into structlog context for log correlation

Log Entry

FieldTypeDescription
levelstrLog severity (info, warning, error, etc.)
timestampstr (ISO 8601)Event timestamp
correlation_idstrRun-scoped correlation identifier
eventstrEvent name/description
*anyAdditional event-specific key-value pairs

Request Log (extends Log Entry)

FieldTypeDescription
methodstrHTTP method
pathstrRequest path
status_codeintResponse status
duration_msfloatRequest duration in milliseconds
user_idstr | "anonymous"Authenticated user ID
user_emailstr | absentUser email (if authenticated)
user_rolestr | absentUser role (if authenticated)
ipstrClient IP address
user_agentstrClient user-agent
geo_countrystr | absentCountry from GeoIP lookup
geo_citystr | absentCity from GeoIP lookup

Notification Message

FieldTypeDescription
chat_idstrTelegram chat ID
textstr (MarkdownV2)Formatted pipeline summary
parse_modestrAlways "MarkdownV2"

GeoIP Lookup Result

FieldTypeDescription
countrystrISO country code (e.g., "TW")
citystrCity name (e.g., "Taipei")

Note: Returns {} on any failure (missing DB, invalid IP, lookup error).

Relationships

text
PipelineCompletedEvent
├── OtelMetricsHandler → SCRAPER_ARTICLES_NEW.add(new, {source})
│                       → SCRAPER_ARTICLES_DUPLICATE.add(duplicate, {source})
│                       → SCRAPER_ERRORS.add(failed, {source})
└── NotificationHandler → TelegramNotifier.notify(event)

Scraper Run (main.py)
├── init_run_context() → (run_id, correlation_id)
├── bind_correlation_id(correlation_id) → ContextVar
├── SCRAPER_RUNS.add(1)
├── tracer.start_as_current_span("scraper.run", attrs={run.id, run.correlation_id})
└── finally: SCRAPER_DURATION.record(duration)
            push_metrics()
            shutdown_tracing()

HTTP Request (backend)
├── RequestLoggingMiddleware
│   ├── request_id (UUID4) → X-Request-ID header
│   ├── _extract_user(request) → user_id/email/role
│   ├── get_geo(ip) → geo_country/geo_city
│   └── logger.info("request", ...) → structlog → stdout + Loki
└── languages.py
    └── resolve_language_from_ip(ip) → get_geo(ip) → "zh-TW" | "en"

Frontend Proxy
├── handler(request) → forward to backend
└── pushToLoki({event, method, path, status, duration, user, ip, user_agent, body})
    └── redact(body) → replace sensitive keys with [REDACTED]