Pular para o conteúdo

Cap. 23 · Código

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.
Python 3 · SQLite · biblioteca padrão

Execução

Rodar em 3 comandos

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.

terminal
$ 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 dados

Saída esperada (python3 demo_data.py)

demo_data.py → stdout
[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-fonte

Código do módulo

demo_data.py
#!/usr/bin/env python3
"""Cap. 23 - End-to-end dashboard demo with FICTIONAL data (zero credentials).
Runs the whole pipeline offline:
1. Creates an in-memory SQLite DB via ``storage.py``.
2. Inserts several days of FICTIONAL metrics (no API, no credentials).
3. Evaluates the alert rules from ``alerts.py``.
4. Prints a small text dashboard: current snapshot, 7-day trend and alerts.
Run it standalone::
python3 demo_data.py
Nothing here touches Google Search Console, GA4 or any external service - the
numbers are made up so you can see the dashboard working before wiring real
credentials into ``collectors.py``.
Part of the "living code" companion repository for the book
"Aparecer ou Sumir" / "Be Seen or Be Forgotten".
"""
from __future__ import annotations
from datetime import date, timedelta
from typing import List
import alerts
import storage
# ---------------------------------------------------------------------------
# Fictional 7-day series. Engineered so the last day trips two alert rules:
# * gsc clicks fall sharply on the final day (drop_pct > 20%).
# * ga4 lcp creeps above the 2.5s ceiling on the final day (max).
# ---------------------------------------------------------------------------
CLICKS = [1180, 1205, 1240, 1260, 1230, 1275, 890] # last day: big drop
IMPRESSIONS = [45000, 46200, 47100, 47800, 47200, 48100, 46000]
SESSIONS = [2900, 2980, 3050, 3110, 3020, 3140, 3090] # stable -> no alert
LCP = [2.0, 2.1, 2.0, 2.2, 2.3, 2.4, 2.7] # last day: over ceiling
SIGNUPS = [38, 41, 44, 47, 43, 49, 45]
def _dates(n: int) -> List[str]:
"""Return the last ``n`` dates ending today, oldest first, as 'YYYY-MM-DD'."""
today = date(2026, 7, 11) # fixed for reproducible demo output
return [(today - timedelta(days=n - 1 - i)).isoformat() for i in range(n)]
def seed_fictional_data(conn: storage.sqlite3.Connection) -> int:
"""Insert the fictional multi-day series. Returns rows written."""
days = _dates(7)
rows = []
series = [
("gsc", "clicks", CLICKS),
("gsc", "impressions", IMPRESSIONS),
("ga4", "sessions", SESSIONS),
("ga4", "lcp", LCP),
("custom", "signups", SIGNUPS),
]
for source, metric, values in series:
for day, value in zip(days, values):
rows.append({"source": source, "metric": metric, "value": value, "date": day})
return storage.upsert_metrics(conn, rows)
def _spark(values: List[float]) -> str:
"""Tiny inline sparkline from a list of numbers (Unicode block bars)."""
bars = "▁▂▃▄▅▆▇█"
lo, hi = min(values), max(values)
span = (hi - lo) or 1.0
return "".join(bars[int((v - lo) / span * (len(bars) - 1))] for v in values)
def print_dashboard(conn: storage.sqlite3.Connection) -> None:
"""Render the small text dashboard from whatever is in the DB."""
print("=" * 60)
print(" DASHBOARD ÚNICO — dados fictícios (sem credenciais)")
print(" Unified dashboard — fictional data (no credentials)")
print("=" * 60)
snapshot = storage.latest_per_metric(conn)
print("\n Snapshot (valor mais recente / latest value)")
print(" " + "-" * 46)
print(f" {'source':<8}{'metric':<14}{'value':>12} {'date':>12}")
for row in snapshot:
value = row["value"]
shown = f"{value:.1f}" if value != int(value) else f"{int(value)}"
print(f" {row['source']:<8}{row['metric']:<14}{shown:>12} {row['date']:>12}")
print("\n Tendência 7 dias (7-day trend)")
print(" " + "-" * 46)
for source, metric in [
("gsc", "clicks"),
("gsc", "impressions"),
("ga4", "sessions"),
("ga4", "lcp"),
("custom", "signups"),
]:
rows = storage.query_recent(conn, metric, source=source, limit=7)
values = [r["value"] for r in reversed(rows)] # oldest -> newest
label = f"{source}/{metric}"
print(f" {label:<20} {_spark(values)} ({values[0]:g} → {values[-1]:g})")
fired = list(alerts.evaluate(conn))
print("\n Alertas (alerts)")
print(" " + "-" * 46)
if not fired:
print(" OK — nenhuma métrica fora do limite / no metrics off threshold.")
else:
for alert in fired:
print(f" [ALERTA] {alert.message}")
print("=" * 60)
def main() -> None:
conn = storage.connect(":memory:")
storage.init_db(conn)
written = seed_fictional_data(conn)
print(f"[demo] {written} linhas fictícias inseridas / fictional rows inserted\n")
print_dashboard(conn)
conn.close()
if __name__ == "__main__":
main()
collectors.py
#!/usr/bin/env python3
"""Cap. 23 - Metric collectors for the unified dashboard - STRUCTURE.
Defines collectors that pull data from three sources into a common shape:
* Google Search Console (Search Analytics API)
* Google Analytics 4 (GA4 Data API)
* A "custom" source (your own internal API)
Each collector returns a list of metric dicts shaped like::
{"source": "gsc", "metric": "clicks", "value": 1234, "date": "2026-07-10"}
so they can be fed straight into ``storage.upsert_metrics``.
============================================================================
REQUER CREDENCIAL / REQUIRES CREDENTIALS
---------------------------------------------------------------------------
The three real collectors below (GSC, GA4, Custom) need API credentials to
return live data. Credentials are read ONLY from ``os.environ`` - no secret
is ever hardcoded. Until they are set, the collectors raise
``NotImplementedError`` with setup guidance, but the module stays importable
and ``py_compile``-clean. Use ``sample_rows()`` / ``collect_all(..., use_sample=True)``
to run the whole pipeline offline with fictional data.
============================================================================
Part of the "living code" companion repository for the book
"Aparecer ou Sumir" / "Be Seen or Be Forgotten".
"""
from __future__ import annotations
import os
from typing import Dict, List
# ---------------------------------------------------------------------------
# TODO: credential — all values come from the environment. Nothing is hardcoded.
# Set these before switching use_sample to False.
# ---------------------------------------------------------------------------
# TODO: credential — path to a Google service-account key JSON with GSC + GA4 access.
GOOGLE_APPLICATION_CREDENTIALS = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS", "")
# TODO: credential — the verified Search Console site URL, e.g. "https://www.example.com/".
GSC_SITE_URL = os.environ.get("GSC_SITE_URL", "")
# TODO: credential — the GA4 numeric property id, e.g. "properties/123456789".
GA4_PROPERTY_ID = os.environ.get("GA4_PROPERTY_ID", "")
# TODO: credential — base URL + token for your own custom metrics API.
CUSTOM_API_BASE = os.environ.get("CUSTOM_API_BASE", "")
CUSTOM_API_TOKEN = os.environ.get("CUSTOM_API_TOKEN", "")
MetricRow = Dict[str, object]
class Collector:
"""Base collector. Subclasses implement :meth:`collect`."""
source = "base"
def collect(self, date: str) -> List[MetricRow]:
"""Return metric rows for the given 'YYYY-MM-DD' date."""
raise NotImplementedError
class SearchConsoleCollector(Collector):
"""Collector for the Google Search Console Search Analytics API.
REQUIRES CREDENTIALS: GOOGLE_APPLICATION_CREDENTIALS + GSC_SITE_URL.
"""
source = "gsc"
def collect(self, date: str) -> List[MetricRow]:
"""Pull clicks/impressions/CTR/position for ``date`` from GSC.
TODO: credential — steps once you have a service account:
1. pip install google-api-python-client google-auth
2. Build the service with GOOGLE_APPLICATION_CREDENTIALS::
from googleapiclient.discovery import build
from google.oauth2.service_account import Credentials
creds = Credentials.from_service_account_file(
GOOGLE_APPLICATION_CREDENTIALS,
scopes=["https://www.googleapis.com/auth/webmasters.readonly"])
service = build("searchconsole", "v1", credentials=creds)
3. Call ``searchanalytics.query`` on GSC_SITE_URL for the date range.
"""
if not (GOOGLE_APPLICATION_CREDENTIALS and GSC_SITE_URL):
raise NotImplementedError(
"SearchConsoleCollector needs GOOGLE_APPLICATION_CREDENTIALS and "
"GSC_SITE_URL. See the docstring for the setup steps."
)
# TODO: credential — replace with the real searchanalytics.query call.
raise NotImplementedError("GSC API call not implemented yet.")
class GA4Collector(Collector):
"""Collector for the Google Analytics 4 Data API.
REQUIRES CREDENTIALS: GOOGLE_APPLICATION_CREDENTIALS + GA4_PROPERTY_ID.
"""
source = "ga4"
def collect(self, date: str) -> List[MetricRow]:
"""Pull sessions/engagement/conversions for ``date`` from GA4.
TODO: credential — steps once you have a service account:
1. pip install google-analytics-data
2. Authenticate with GOOGLE_APPLICATION_CREDENTIALS and run a
``runReport`` request against GA4_PROPERTY_ID with the metrics and
the date range you need.
"""
if not (GOOGLE_APPLICATION_CREDENTIALS and GA4_PROPERTY_ID):
raise NotImplementedError(
"GA4Collector needs GOOGLE_APPLICATION_CREDENTIALS and "
"GA4_PROPERTY_ID. See the docstring for the setup steps."
)
# TODO: credential — replace with the real runReport call.
raise NotImplementedError("GA4 API call not implemented yet.")
class CustomCollector(Collector):
"""Collector for your own internal metrics API.
REQUIRES CREDENTIALS: CUSTOM_API_BASE + CUSTOM_API_TOKEN.
"""
source = "custom"
def collect(self, date: str) -> List[MetricRow]:
"""Pull custom metrics for ``date`` from your own API.
TODO: credential — perform an authenticated HTTP request to
CUSTOM_API_BASE using CUSTOM_API_TOKEN and map the response into
MetricRow dicts.
"""
if not (CUSTOM_API_BASE and CUSTOM_API_TOKEN):
raise NotImplementedError(
"CustomCollector needs CUSTOM_API_BASE and CUSTOM_API_TOKEN."
)
# TODO: credential — replace with the real HTTP request.
raise NotImplementedError("Custom API call not implemented yet.")
def sample_rows(date: str) -> List[MetricRow]:
"""Return sample-shaped rows so the pipeline can be exercised offline.
Useful for local development, tests and demos - it mimics exactly what the
real collectors would return, without touching any external service or
needing any credential.
"""
return [
{"source": "gsc", "metric": "clicks", "value": 1250, "date": date},
{"source": "gsc", "metric": "impressions", "value": 48000, "date": date},
{"source": "ga4", "metric": "sessions", "value": 3100, "date": date},
{"source": "ga4", "metric": "lcp", "value": 2.1, "date": date},
{"source": "custom", "metric": "signups", "value": 42, "date": date},
]
def collect_all(date: str, use_sample: bool = True) -> List[MetricRow]:
"""Collect from every source for ``date``.
When ``use_sample`` is True (default) it returns offline sample data so the
dashboard can run without credentials. Set it to False once the collectors
above are implemented AND their credentials are configured to hit the real
APIs.
"""
if use_sample:
return sample_rows(date)
rows: List[MetricRow] = []
for collector in (SearchConsoleCollector(), GA4Collector(), CustomCollector()):
rows.extend(collector.collect(date))
return rows
if __name__ == "__main__":
# Demo without credentials: print the sample rows.
for r in collect_all("2026-07-10"):
print(r)
storage.py
#!/usr/bin/env python3
"""Cap. 23 - SQLite storage layer for the unified dashboard.
A tiny, fully functional persistence layer built on the stdlib ``sqlite3``.
Every metric collected from Google Search Console, GA4 or a custom source is
stored in a single ``metrics`` table so the dashboard and alert rules can query
them uniformly. No external dependencies, no credentials.
Part of the "living code" companion repository for the book
"Aparecer ou Sumir" / "Be Seen or Be Forgotten".
"""
from __future__ import annotations
import sqlite3
from pathlib import Path
from typing import Iterable, List, Mapping, Optional, Union
# Default on-disk database file for the dashboard.
DEFAULT_DB_PATH = "dashboard.db"
def connect(db_path: Union[str, Path] = DEFAULT_DB_PATH) -> sqlite3.Connection:
"""Open a SQLite connection with row access by column name."""
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
return conn
def init_db(conn: sqlite3.Connection) -> None:
"""Create the metrics table (and a helpful unique index) if missing.
Schema:
source - where the metric came from (e.g. 'gsc', 'ga4', 'custom')
metric - the metric name (e.g. 'clicks', 'lcp', 'signups')
value - the numeric value
date - the day the metric refers to, as 'YYYY-MM-DD'
The UNIQUE(source, metric, date) index makes upserts idempotent: re-running a
collector for the same day updates the value instead of duplicating rows.
"""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL,
metric TEXT NOT NULL,
value REAL NOT NULL,
date TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE UNIQUE INDEX IF NOT EXISTS ux_metrics_source_metric_date
ON metrics (source, metric, date)
"""
)
conn.commit()
def upsert_metric(
conn: sqlite3.Connection,
source: str,
metric: str,
value: float,
date: str,
) -> None:
"""Insert or update a single metric row keyed by (source, metric, date)."""
conn.execute(
"""
INSERT INTO metrics (source, metric, value, date)
VALUES (?, ?, ?, ?)
ON CONFLICT(source, metric, date) DO UPDATE SET value = excluded.value
""",
(source, metric, value, date),
)
conn.commit()
def upsert_metrics(conn: sqlite3.Connection, rows: Iterable[Mapping[str, object]]) -> int:
"""Bulk upsert. Each row is a mapping with source/metric/value/date keys.
Returns the number of rows processed.
"""
prepared = [
(r["source"], r["metric"], float(r["value"]), r["date"]) for r in rows
]
conn.executemany(
"""
INSERT INTO metrics (source, metric, value, date)
VALUES (?, ?, ?, ?)
ON CONFLICT(source, metric, date) DO UPDATE SET value = excluded.value
""",
prepared,
)
conn.commit()
return len(prepared)
def query_recent(
conn: sqlite3.Connection,
metric: str,
source: Optional[str] = None,
limit: int = 30,
) -> List[sqlite3.Row]:
"""Return the most recent rows for a metric, newest first.
Args:
metric: metric name to filter on (e.g. 'clicks').
source: optional source filter (e.g. 'gsc').
limit: maximum number of rows to return.
"""
if source is None:
cur = conn.execute(
"SELECT source, metric, value, date FROM metrics "
"WHERE metric = ? ORDER BY date DESC LIMIT ?",
(metric, limit),
)
else:
cur = conn.execute(
"SELECT source, metric, value, date FROM metrics "
"WHERE metric = ? AND source = ? ORDER BY date DESC LIMIT ?",
(metric, source, limit),
)
return cur.fetchall()
def latest_per_metric(conn: sqlite3.Connection) -> List[sqlite3.Row]:
"""Return the single newest row for every (source, metric) pair.
Handy for rendering a "current state" dashboard snapshot.
"""
cur = conn.execute(
"""
SELECT m.source, m.metric, m.value, m.date
FROM metrics AS m
JOIN (
SELECT source, metric, MAX(date) AS max_date
FROM metrics
GROUP BY source, metric
) AS latest
ON m.source = latest.source
AND m.metric = latest.metric
AND m.date = latest.max_date
ORDER BY m.source, m.metric
"""
)
return cur.fetchall()
if __name__ == "__main__":
# Tiny self-check that runs without any external credentials.
connection = connect(":memory:")
init_db(connection)
upsert_metric(connection, "gsc", "clicks", 1200, "2026-07-10")
upsert_metric(connection, "gsc", "clicks", 1300, "2026-07-11")
for row in query_recent(connection, "clicks"):
print(dict(row))
connection.close()
alerts.py
#!/usr/bin/env python3
"""Cap. 23 - Threshold-based alerts for the unified dashboard.
Reads recent metrics from the SQLite storage layer and yields alerts when a
threshold is crossed. Two kinds of rules are demonstrated:
* DROP rules: fire when a metric drops more than X% versus the previous value
(e.g. organic clicks fall > 20% day over day).
* MAX rules: fire when a metric exceeds an absolute ceiling
(e.g. Largest Contentful Paint > 2.5s).
Pure Python, no external services, no credentials. The ``__main__`` demo inserts
sample data via ``storage.py`` so it runs standalone.
Part of the "living code" companion repository for the book
"Aparecer ou Sumir" / "Be Seen or Be Forgotten".
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterator, List, Optional
import storage
@dataclass
class Threshold:
"""A single alert rule.
kind:
'drop_pct' - fire when value falls more than ``limit`` percent vs previous.
'max' - fire when the latest value is greater than ``limit``.
"""
metric: str
kind: str
limit: float
source: Optional[str] = None # optional source filter
# Example threshold configuration. Tune these to your own baselines.
EXAMPLE_THRESHOLDS: List[Threshold] = [
Threshold(metric="clicks", kind="drop_pct", limit=20.0, source="gsc"),
Threshold(metric="sessions", kind="drop_pct", limit=25.0, source="ga4"),
Threshold(metric="lcp", kind="max", limit=2.5, source="ga4"),
]
@dataclass
class Alert:
"""A fired alert, ready to be logged, emailed or posted to Slack."""
metric: str
source: str
message: str
value: float
def evaluate(
conn: "storage.sqlite3.Connection",
thresholds: Optional[List[Threshold]] = None,
) -> Iterator[Alert]:
"""Yield an Alert for every threshold that is currently crossed.
For each rule we pull the two most recent stored values for the metric and
compare them (drop rules) or check the latest against a ceiling (max rules).
"""
rules = thresholds if thresholds is not None else EXAMPLE_THRESHOLDS
for rule in rules:
rows = storage.query_recent(conn, rule.metric, source=rule.source, limit=2)
if not rows:
continue
latest = rows[0]["value"]
src = rows[0]["source"]
if rule.kind == "max":
if latest > rule.limit:
yield Alert(
metric=rule.metric,
source=src,
value=latest,
message=(
f"{rule.metric} = {latest} exceeds max {rule.limit} "
f"(source: {src})"
),
)
elif rule.kind == "drop_pct":
if len(rows) < 2:
continue # need a previous value to compute a drop
previous = rows[1]["value"]
if previous <= 0:
continue
drop_pct = (previous - latest) / previous * 100.0
if drop_pct > rule.limit:
yield Alert(
metric=rule.metric,
source=src,
value=latest,
message=(
f"{rule.metric} dropped {drop_pct:.1f}% "
f"({previous} -> {latest}) exceeding {rule.limit}% "
f"(source: {src})"
),
)
def _demo() -> None:
"""Standalone demo: build an in-memory DB, insert sample data, run alerts."""
conn = storage.connect(":memory:")
storage.init_db(conn)
# Sample data engineered to trigger both a drop alert and a max alert.
storage.upsert_metrics(
conn,
[
# clicks fell from 1500 -> 1000 (~33% drop) -> triggers drop_pct.
{"source": "gsc", "metric": "clicks", "value": 1500, "date": "2026-07-10"},
{"source": "gsc", "metric": "clicks", "value": 1000, "date": "2026-07-11"},
# sessions roughly stable -> no alert.
{"source": "ga4", "metric": "sessions", "value": 3000, "date": "2026-07-10"},
{"source": "ga4", "metric": "sessions", "value": 2950, "date": "2026-07-11"},
# lcp = 3.2s > 2.5s ceiling -> triggers max.
{"source": "ga4", "metric": "lcp", "value": 3.2, "date": "2026-07-11"},
],
)
alerts = list(evaluate(conn))
if not alerts:
print("No alerts. All metrics within thresholds.")
for alert in alerts:
print(f"[ALERT] {alert.message}")
conn.close()
if __name__ == "__main__":
_demo()

Dúvidas

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.

Implementação

Prefere que eu implemente?

Conte o seu caso numa conversa e a gente define o escopo juntos.

Agendar conversa