Dashboard único: coleta e alertas
Reúna métricas de Google Search Console, GA4 e uma fonte custom num único dashboard. Tudo é guardado em SQLite (stdlib) e regras de limite disparam alertas. Roda ponta a ponta com dados fictícios — sem credenciais.
Requer credencial: apenas collectors.py precisa de credenciais (GOOGLE_APPLICATION_CREDENTIALS, GSC_SITE_URL, GA4_PROPERTY_ID, CUSTOM_API_BASE/TOKEN) para buscar dados reais. Todo o resto — storage, alertas e o demo — roda sem nenhum segredo.
Rodar em 3 comandos
$ python3 demo_data.py # dashboard completo com dados fictícios
$ python3 alerts.py # insere dados de exemplo e mostra alertas
$ python3 storage.py # self-check da camada de dadosSaída esperada (python3 demo_data.py)
[demo] 35 linhas fictícias inseridas / fictional rows inserted
============================================================
DASHBOARD ÚNICO — dados fictícios (sem credenciais)
Unified dashboard — fictional data (no credentials)
============================================================
Snapshot (valor mais recente / latest value)
----------------------------------------------
source metric value date
custom signups 45 2026-07-11
ga4 lcp 2.7 2026-07-11
ga4 sessions 3090 2026-07-11
gsc clicks 890 2026-07-11
gsc impressions 46000 2026-07-11
Tendência 7 dias (7-day trend)
----------------------------------------------
gsc/clicks ▆▆▇▇▇█▁ (1180 → 890)
gsc/impressions ▁▃▅▇▅█▃ (45000 → 46000)
ga4/sessions ▁▃▅▇▄█▆ (2900 → 3090)
ga4/lcp ▁▂▁▃▃▄█ (2 → 2.7)
custom/signups ▁▂▄▆▄█▅ (38 → 45)
Alertas (alerts)
----------------------------------------------
[ALERTA] clicks dropped 30.2% (1275.0 -> 890.0) exceeding 20.0% (source: gsc)
[ALERTA] lcp = 2.7 exceeds max 2.5 (source: ga4)
============================================================Código do módulo
1#!/usr/bin/env python32"""Cap. 23 - End-to-end dashboard demo with FICTIONAL data (zero credentials).34Runs the whole pipeline offline:56 1. Creates an in-memory SQLite DB via ``storage.py``.7 2. Inserts several days of FICTIONAL metrics (no API, no credentials).8 3. Evaluates the alert rules from ``alerts.py``.9 4. Prints a small text dashboard: current snapshot, 7-day trend and alerts.1011Run it standalone::1213 python3 demo_data.py1415Nothing here touches Google Search Console, GA4 or any external service - the16numbers are made up so you can see the dashboard working before wiring real17credentials into ``collectors.py``.1819Part of the "living code" companion repository for the book20"Aparecer ou Sumir" / "Be Seen or Be Forgotten".21"""2223from __future__ import annotations2425from datetime import date, timedelta26from typing import List2728import alerts29import storage3031# ---------------------------------------------------------------------------32# Fictional 7-day series. Engineered so the last day trips two alert rules:33# * gsc clicks fall sharply on the final day (drop_pct > 20%).34# * ga4 lcp creeps above the 2.5s ceiling on the final day (max).35# ---------------------------------------------------------------------------36CLICKS = [1180, 1205, 1240, 1260, 1230, 1275, 890] # last day: big drop37IMPRESSIONS = [45000, 46200, 47100, 47800, 47200, 48100, 46000]38SESSIONS = [2900, 2980, 3050, 3110, 3020, 3140, 3090] # stable -> no alert39LCP = [2.0, 2.1, 2.0, 2.2, 2.3, 2.4, 2.7] # last day: over ceiling40SIGNUPS = [38, 41, 44, 47, 43, 49, 45]414243def _dates(n: int) -> List[str]:44 """Return the last ``n`` dates ending today, oldest first, as 'YYYY-MM-DD'."""45 today = date(2026, 7, 11) # fixed for reproducible demo output46 return [(today - timedelta(days=n - 1 - i)).isoformat() for i in range(n)]474849def seed_fictional_data(conn: storage.sqlite3.Connection) -> int:50 """Insert the fictional multi-day series. Returns rows written."""51 days = _dates(7)52 rows = []53 series = [54 ("gsc", "clicks", CLICKS),55 ("gsc", "impressions", IMPRESSIONS),56 ("ga4", "sessions", SESSIONS),57 ("ga4", "lcp", LCP),58 ("custom", "signups", SIGNUPS),59 ]60 for source, metric, values in series:61 for day, value in zip(days, values):62 rows.append({"source": source, "metric": metric, "value": value, "date": day})63 return storage.upsert_metrics(conn, rows)646566def _spark(values: List[float]) -> str:67 """Tiny inline sparkline from a list of numbers (Unicode block bars)."""68 bars = "▁▂▃▄▅▆▇█"69 lo, hi = min(values), max(values)70 span = (hi - lo) or 1.071 return "".join(bars[int((v - lo) / span * (len(bars) - 1))] for v in values)727374def print_dashboard(conn: storage.sqlite3.Connection) -> None:75 """Render the small text dashboard from whatever is in the DB."""76 print("=" * 60)77 print(" DASHBOARD ÚNICO — dados fictícios (sem credenciais)")78 print(" Unified dashboard — fictional data (no credentials)")79 print("=" * 60)8081 snapshot = storage.latest_per_metric(conn)82 print("\n Snapshot (valor mais recente / latest value)")83 print(" " + "-" * 46)84 print(f" {'source':<8}{'metric':<14}{'value':>12} {'date':>12}")85 for row in snapshot:86 value = row["value"]87 shown = f"{value:.1f}" if value != int(value) else f"{int(value)}"88 print(f" {row['source']:<8}{row['metric']:<14}{shown:>12} {row['date']:>12}")8990 print("\n Tendência 7 dias (7-day trend)")91 print(" " + "-" * 46)92 for source, metric in [93 ("gsc", "clicks"),94 ("gsc", "impressions"),95 ("ga4", "sessions"),96 ("ga4", "lcp"),97 ("custom", "signups"),98 ]:99 rows = storage.query_recent(conn, metric, source=source, limit=7)100 values = [r["value"] for r in reversed(rows)] # oldest -> newest101 label = f"{source}/{metric}"102 print(f" {label:<20} {_spark(values)} ({values[0]:g} → {values[-1]:g})")103104 fired = list(alerts.evaluate(conn))105 print("\n Alertas (alerts)")106 print(" " + "-" * 46)107 if not fired:108 print(" OK — nenhuma métrica fora do limite / no metrics off threshold.")109 else:110 for alert in fired:111 print(f" [ALERTA] {alert.message}")112 print("=" * 60)113114115def main() -> None:116 conn = storage.connect(":memory:")117 storage.init_db(conn)118 written = seed_fictional_data(conn)119 print(f"[demo] {written} linhas fictícias inseridas / fictional rows inserted\n")120 print_dashboard(conn)121 conn.close()122123124if __name__ == "__main__":125 main()
1#!/usr/bin/env python32"""Cap. 23 - Metric collectors for the unified dashboard - STRUCTURE.34Defines collectors that pull data from three sources into a common shape:56 * Google Search Console (Search Analytics API)7 * Google Analytics 4 (GA4 Data API)8 * A "custom" source (your own internal API)910Each collector returns a list of metric dicts shaped like::1112 {"source": "gsc", "metric": "clicks", "value": 1234, "date": "2026-07-10"}1314so they can be fed straight into ``storage.upsert_metrics``.1516============================================================================17 REQUER CREDENCIAL / REQUIRES CREDENTIALS18 ---------------------------------------------------------------------------19 The three real collectors below (GSC, GA4, Custom) need API credentials to20 return live data. Credentials are read ONLY from ``os.environ`` - no secret21 is ever hardcoded. Until they are set, the collectors raise22 ``NotImplementedError`` with setup guidance, but the module stays importable23 and ``py_compile``-clean. Use ``sample_rows()`` / ``collect_all(..., use_sample=True)``24 to run the whole pipeline offline with fictional data.25============================================================================2627Part of the "living code" companion repository for the book28"Aparecer ou Sumir" / "Be Seen or Be Forgotten".29"""3031from __future__ import annotations3233import os34from typing import Dict, List3536# ---------------------------------------------------------------------------37# TODO: credential — all values come from the environment. Nothing is hardcoded.38# Set these before switching use_sample to False.39# ---------------------------------------------------------------------------40# TODO: credential — path to a Google service-account key JSON with GSC + GA4 access.41GOOGLE_APPLICATION_CREDENTIALS = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS", "")42# TODO: credential — the verified Search Console site URL, e.g. "https://www.example.com/".43GSC_SITE_URL = os.environ.get("GSC_SITE_URL", "")44# TODO: credential — the GA4 numeric property id, e.g. "properties/123456789".45GA4_PROPERTY_ID = os.environ.get("GA4_PROPERTY_ID", "")46# TODO: credential — base URL + token for your own custom metrics API.47CUSTOM_API_BASE = os.environ.get("CUSTOM_API_BASE", "")48CUSTOM_API_TOKEN = os.environ.get("CUSTOM_API_TOKEN", "")495051MetricRow = Dict[str, object]525354class Collector:55 """Base collector. Subclasses implement :meth:`collect`."""5657 source = "base"5859 def collect(self, date: str) -> List[MetricRow]:60 """Return metric rows for the given 'YYYY-MM-DD' date."""61 raise NotImplementedError626364class SearchConsoleCollector(Collector):65 """Collector for the Google Search Console Search Analytics API.6667 REQUIRES CREDENTIALS: GOOGLE_APPLICATION_CREDENTIALS + GSC_SITE_URL.68 """6970 source = "gsc"7172 def collect(self, date: str) -> List[MetricRow]:73 """Pull clicks/impressions/CTR/position for ``date`` from GSC.7475 TODO: credential — steps once you have a service account:76 1. pip install google-api-python-client google-auth77 2. Build the service with GOOGLE_APPLICATION_CREDENTIALS::7879 from googleapiclient.discovery import build80 from google.oauth2.service_account import Credentials81 creds = Credentials.from_service_account_file(82 GOOGLE_APPLICATION_CREDENTIALS,83 scopes=["https://www.googleapis.com/auth/webmasters.readonly"])84 service = build("searchconsole", "v1", credentials=creds)85 3. Call ``searchanalytics.query`` on GSC_SITE_URL for the date range.86 """87 if not (GOOGLE_APPLICATION_CREDENTIALS and GSC_SITE_URL):88 raise NotImplementedError(89 "SearchConsoleCollector needs GOOGLE_APPLICATION_CREDENTIALS and "90 "GSC_SITE_URL. See the docstring for the setup steps."91 )92 # TODO: credential — replace with the real searchanalytics.query call.93 raise NotImplementedError("GSC API call not implemented yet.")949596class GA4Collector(Collector):97 """Collector for the Google Analytics 4 Data API.9899 REQUIRES CREDENTIALS: GOOGLE_APPLICATION_CREDENTIALS + GA4_PROPERTY_ID.100 """101102 source = "ga4"103104 def collect(self, date: str) -> List[MetricRow]:105 """Pull sessions/engagement/conversions for ``date`` from GA4.106107 TODO: credential — steps once you have a service account:108 1. pip install google-analytics-data109 2. Authenticate with GOOGLE_APPLICATION_CREDENTIALS and run a110 ``runReport`` request against GA4_PROPERTY_ID with the metrics and111 the date range you need.112 """113 if not (GOOGLE_APPLICATION_CREDENTIALS and GA4_PROPERTY_ID):114 raise NotImplementedError(115 "GA4Collector needs GOOGLE_APPLICATION_CREDENTIALS and "116 "GA4_PROPERTY_ID. See the docstring for the setup steps."117 )118 # TODO: credential — replace with the real runReport call.119 raise NotImplementedError("GA4 API call not implemented yet.")120121122class CustomCollector(Collector):123 """Collector for your own internal metrics API.124125 REQUIRES CREDENTIALS: CUSTOM_API_BASE + CUSTOM_API_TOKEN.126 """127128 source = "custom"129130 def collect(self, date: str) -> List[MetricRow]:131 """Pull custom metrics for ``date`` from your own API.132133 TODO: credential — perform an authenticated HTTP request to134 CUSTOM_API_BASE using CUSTOM_API_TOKEN and map the response into135 MetricRow dicts.136 """137 if not (CUSTOM_API_BASE and CUSTOM_API_TOKEN):138 raise NotImplementedError(139 "CustomCollector needs CUSTOM_API_BASE and CUSTOM_API_TOKEN."140 )141 # TODO: credential — replace with the real HTTP request.142 raise NotImplementedError("Custom API call not implemented yet.")143144145def sample_rows(date: str) -> List[MetricRow]:146 """Return sample-shaped rows so the pipeline can be exercised offline.147148 Useful for local development, tests and demos - it mimics exactly what the149 real collectors would return, without touching any external service or150 needing any credential.151 """152 return [153 {"source": "gsc", "metric": "clicks", "value": 1250, "date": date},154 {"source": "gsc", "metric": "impressions", "value": 48000, "date": date},155 {"source": "ga4", "metric": "sessions", "value": 3100, "date": date},156 {"source": "ga4", "metric": "lcp", "value": 2.1, "date": date},157 {"source": "custom", "metric": "signups", "value": 42, "date": date},158 ]159160161def collect_all(date: str, use_sample: bool = True) -> List[MetricRow]:162 """Collect from every source for ``date``.163164 When ``use_sample`` is True (default) it returns offline sample data so the165 dashboard can run without credentials. Set it to False once the collectors166 above are implemented AND their credentials are configured to hit the real167 APIs.168 """169 if use_sample:170 return sample_rows(date)171172 rows: List[MetricRow] = []173 for collector in (SearchConsoleCollector(), GA4Collector(), CustomCollector()):174 rows.extend(collector.collect(date))175 return rows176177178if __name__ == "__main__":179 # Demo without credentials: print the sample rows.180 for r in collect_all("2026-07-10"):181 print(r)
1#!/usr/bin/env python32"""Cap. 23 - SQLite storage layer for the unified dashboard.34A tiny, fully functional persistence layer built on the stdlib ``sqlite3``.5Every metric collected from Google Search Console, GA4 or a custom source is6stored in a single ``metrics`` table so the dashboard and alert rules can query7them uniformly. No external dependencies, no credentials.89Part of the "living code" companion repository for the book10"Aparecer ou Sumir" / "Be Seen or Be Forgotten".11"""1213from __future__ import annotations1415import sqlite316from pathlib import Path17from typing import Iterable, List, Mapping, Optional, Union1819# Default on-disk database file for the dashboard.20DEFAULT_DB_PATH = "dashboard.db"212223def connect(db_path: Union[str, Path] = DEFAULT_DB_PATH) -> sqlite3.Connection:24 """Open a SQLite connection with row access by column name."""25 conn = sqlite3.connect(str(db_path))26 conn.row_factory = sqlite3.Row27 return conn282930def init_db(conn: sqlite3.Connection) -> None:31 """Create the metrics table (and a helpful unique index) if missing.3233 Schema:34 source - where the metric came from (e.g. 'gsc', 'ga4', 'custom')35 metric - the metric name (e.g. 'clicks', 'lcp', 'signups')36 value - the numeric value37 date - the day the metric refers to, as 'YYYY-MM-DD'3839 The UNIQUE(source, metric, date) index makes upserts idempotent: re-running a40 collector for the same day updates the value instead of duplicating rows.41 """42 conn.execute(43 """44 CREATE TABLE IF NOT EXISTS metrics (45 id INTEGER PRIMARY KEY AUTOINCREMENT,46 source TEXT NOT NULL,47 metric TEXT NOT NULL,48 value REAL NOT NULL,49 date TEXT NOT NULL50 )51 """52 )53 conn.execute(54 """55 CREATE UNIQUE INDEX IF NOT EXISTS ux_metrics_source_metric_date56 ON metrics (source, metric, date)57 """58 )59 conn.commit()606162def upsert_metric(63 conn: sqlite3.Connection,64 source: str,65 metric: str,66 value: float,67 date: str,68) -> None:69 """Insert or update a single metric row keyed by (source, metric, date)."""70 conn.execute(71 """72 INSERT INTO metrics (source, metric, value, date)73 VALUES (?, ?, ?, ?)74 ON CONFLICT(source, metric, date) DO UPDATE SET value = excluded.value75 """,76 (source, metric, value, date),77 )78 conn.commit()798081def upsert_metrics(conn: sqlite3.Connection, rows: Iterable[Mapping[str, object]]) -> int:82 """Bulk upsert. Each row is a mapping with source/metric/value/date keys.8384 Returns the number of rows processed.85 """86 prepared = [87 (r["source"], r["metric"], float(r["value"]), r["date"]) for r in rows88 ]89 conn.executemany(90 """91 INSERT INTO metrics (source, metric, value, date)92 VALUES (?, ?, ?, ?)93 ON CONFLICT(source, metric, date) DO UPDATE SET value = excluded.value94 """,95 prepared,96 )97 conn.commit()98 return len(prepared)99100101def query_recent(102 conn: sqlite3.Connection,103 metric: str,104 source: Optional[str] = None,105 limit: int = 30,106) -> List[sqlite3.Row]:107 """Return the most recent rows for a metric, newest first.108109 Args:110 metric: metric name to filter on (e.g. 'clicks').111 source: optional source filter (e.g. 'gsc').112 limit: maximum number of rows to return.113 """114 if source is None:115 cur = conn.execute(116 "SELECT source, metric, value, date FROM metrics "117 "WHERE metric = ? ORDER BY date DESC LIMIT ?",118 (metric, limit),119 )120 else:121 cur = conn.execute(122 "SELECT source, metric, value, date FROM metrics "123 "WHERE metric = ? AND source = ? ORDER BY date DESC LIMIT ?",124 (metric, source, limit),125 )126 return cur.fetchall()127128129def latest_per_metric(conn: sqlite3.Connection) -> List[sqlite3.Row]:130 """Return the single newest row for every (source, metric) pair.131132 Handy for rendering a "current state" dashboard snapshot.133 """134 cur = conn.execute(135 """136 SELECT m.source, m.metric, m.value, m.date137 FROM metrics AS m138 JOIN (139 SELECT source, metric, MAX(date) AS max_date140 FROM metrics141 GROUP BY source, metric142 ) AS latest143 ON m.source = latest.source144 AND m.metric = latest.metric145 AND m.date = latest.max_date146 ORDER BY m.source, m.metric147 """148 )149 return cur.fetchall()150151152if __name__ == "__main__":153 # Tiny self-check that runs without any external credentials.154 connection = connect(":memory:")155 init_db(connection)156 upsert_metric(connection, "gsc", "clicks", 1200, "2026-07-10")157 upsert_metric(connection, "gsc", "clicks", 1300, "2026-07-11")158 for row in query_recent(connection, "clicks"):159 print(dict(row))160 connection.close()
1#!/usr/bin/env python32"""Cap. 23 - Threshold-based alerts for the unified dashboard.34Reads recent metrics from the SQLite storage layer and yields alerts when a5threshold is crossed. Two kinds of rules are demonstrated:67 * DROP rules: fire when a metric drops more than X% versus the previous value8 (e.g. organic clicks fall > 20% day over day).9 * MAX rules: fire when a metric exceeds an absolute ceiling10 (e.g. Largest Contentful Paint > 2.5s).1112Pure Python, no external services, no credentials. The ``__main__`` demo inserts13sample data via ``storage.py`` so it runs standalone.1415Part of the "living code" companion repository for the book16"Aparecer ou Sumir" / "Be Seen or Be Forgotten".17"""1819from __future__ import annotations2021from dataclasses import dataclass22from typing import Iterator, List, Optional2324import storage252627@dataclass28class Threshold:29 """A single alert rule.3031 kind:32 'drop_pct' - fire when value falls more than ``limit`` percent vs previous.33 'max' - fire when the latest value is greater than ``limit``.34 """3536 metric: str37 kind: str38 limit: float39 source: Optional[str] = None # optional source filter404142# Example threshold configuration. Tune these to your own baselines.43EXAMPLE_THRESHOLDS: List[Threshold] = [44 Threshold(metric="clicks", kind="drop_pct", limit=20.0, source="gsc"),45 Threshold(metric="sessions", kind="drop_pct", limit=25.0, source="ga4"),46 Threshold(metric="lcp", kind="max", limit=2.5, source="ga4"),47]484950@dataclass51class Alert:52 """A fired alert, ready to be logged, emailed or posted to Slack."""5354 metric: str55 source: str56 message: str57 value: float585960def evaluate(61 conn: "storage.sqlite3.Connection",62 thresholds: Optional[List[Threshold]] = None,63) -> Iterator[Alert]:64 """Yield an Alert for every threshold that is currently crossed.6566 For each rule we pull the two most recent stored values for the metric and67 compare them (drop rules) or check the latest against a ceiling (max rules).68 """69 rules = thresholds if thresholds is not None else EXAMPLE_THRESHOLDS7071 for rule in rules:72 rows = storage.query_recent(conn, rule.metric, source=rule.source, limit=2)73 if not rows:74 continue75 latest = rows[0]["value"]76 src = rows[0]["source"]7778 if rule.kind == "max":79 if latest > rule.limit:80 yield Alert(81 metric=rule.metric,82 source=src,83 value=latest,84 message=(85 f"{rule.metric} = {latest} exceeds max {rule.limit} "86 f"(source: {src})"87 ),88 )8990 elif rule.kind == "drop_pct":91 if len(rows) < 2:92 continue # need a previous value to compute a drop93 previous = rows[1]["value"]94 if previous <= 0:95 continue96 drop_pct = (previous - latest) / previous * 100.097 if drop_pct > rule.limit:98 yield Alert(99 metric=rule.metric,100 source=src,101 value=latest,102 message=(103 f"{rule.metric} dropped {drop_pct:.1f}% "104 f"({previous} -> {latest}) exceeding {rule.limit}% "105 f"(source: {src})"106 ),107 )108109110def _demo() -> None:111 """Standalone demo: build an in-memory DB, insert sample data, run alerts."""112 conn = storage.connect(":memory:")113 storage.init_db(conn)114115 # Sample data engineered to trigger both a drop alert and a max alert.116 storage.upsert_metrics(117 conn,118 [119 # clicks fell from 1500 -> 1000 (~33% drop) -> triggers drop_pct.120 {"source": "gsc", "metric": "clicks", "value": 1500, "date": "2026-07-10"},121 {"source": "gsc", "metric": "clicks", "value": 1000, "date": "2026-07-11"},122 # sessions roughly stable -> no alert.123 {"source": "ga4", "metric": "sessions", "value": 3000, "date": "2026-07-10"},124 {"source": "ga4", "metric": "sessions", "value": 2950, "date": "2026-07-11"},125 # lcp = 3.2s > 2.5s ceiling -> triggers max.126 {"source": "ga4", "metric": "lcp", "value": 3.2, "date": "2026-07-11"},127 ],128 )129130 alerts = list(evaluate(conn))131 if not alerts:132 print("No alerts. All metrics within thresholds.")133 for alert in alerts:134 print(f"[ALERT] {alert.message}")135 conn.close()136137138if __name__ == "__main__":139 _demo()
Perguntas frequentes
Preciso de credenciais para rodar o dashboard?
Não. O demo_data.py roda ponta a ponta com dados fictícios, sem tocar em nenhuma API. Só o collectors.py precisa de credenciais (GSC/GA4/custom) para buscar dados reais — e essas seções estão claramente marcadas.
Onde os dados ficam guardados?
No storage.py, uma camada SQLite da biblioteca padrão. Todas as métricas vão para uma única tabela metrics(source, metric, value, date), com upsert idempotente por (source, metric, date).
Como os alertas funcionam?
O alerts.py lê as métricas recentes do storage e aplica regras de limite: quedas percentuais (ex.: cliques caem > 20% de um dia para o outro) e tetos absolutos (ex.: LCP > 2,5s). Cada regra cruzada vira um alerta.