← Blog

Process automation

Bureaurapportage in 12 minuten: een Notion-naar-Looker case

Een Rotterdams bureau van 14 mensen verbrandde elke maand twee seniordagen aan klantdecks. Wij brachten dat ritueel terug naar twaalf minuten met één Python-bestand.

Jacob Molkenboer· Oprichter · A Brand New Company· 19 apr 2024· 9 min
Messing stopwatch naast stapel crème rapportkaarten met touw, groen tabblad en rode lakzegel op ivoor bureau.

Eerste maandag van juni, een marketingbureau in Rotterdam. De ops lead opent elf browsertabs voordat het espressoapparaat klaar is met sissen. Notion voor de project tracker. Google Analytics voor drie klanten van wie ze de logins onthoudt. Meta Ads Manager voor zes klanten. LinkedIn Campaign Manager voor de B2B-helft. Search Console. Een Airtable die iemand in 2023 heeft gebouwd om retainers bij te houden. Tegen de tijd dat ze klant één in de maandtemplate heeft gekopieerd is het 10:14, en heeft ze vier Slack-pings beantwoord over 'het deck'.

Zo zag maandrapportage eruit bij een bureau van 14 mensen waar we dit voorjaar mee werkten. Twee senior medewerkers waren elke maand twee volle werkdagen kwijt aan het samenstellen van 23 klantdecks. De cijfers waren al zes dagen oud tegen de tijd dat de decks bij de klanten lagen. Tegen blended tarieven verbrandde het bureau ongeveer 6.000 EUR per maand aan het verplaatsen van cijfers van het ene scherm naar het andere.

We brachten het terug naar twaalf minuten. Die twaalf minuten zijn reviewtijd. De eigenlijke pipeline draait om 06:00 op de eerste van de maand, op een Hetzner-bakje van 4 EUR, en is klaar voordat iemand wakker is. Hieronder hoe hij is gebouwd, en welke onderdelen we er bewust uit hebben gelaten.

De audit kwam eerst

Niemand had ooit opgeschreven waar de rapportagedata van het bureau eigenlijk leefde. Dus we besteedden de eerste middag aan het maken van een lijst. Twaalf bronnen in totaal. Zes waren ad-platforms die het bureau al exporteerde naar een gedeelde Google Drive-map als maand-CSV's. Twee waren SaaS-dashboards met bruikbare API's (Hotjar, Brevo). Eén was een Plausible-instance die het bureau zelf hostte. De overige drie waren Notion, Airtable en een Google Sheet die een freelancer bijhield voor organische social.

De bottleneck waren niet de dashboards. De bottleneck was reconciliatie. Dezelfde klant verscheen als 'De Witte Bakker' in Notion, 'Witte Bakker BV' in Meta Ads, 'dewittebakker.nl' in Google Analytics en 'witte_bakker' in de Airtable facturatietag. Een junior werd betaald om die namen elke maand met het oog te laten matchen. De helft van de seniortijd ging eigenlijk op aan het corrigeren van de matches van die junior.

Kerninzicht

Als je rapportage traag voelt, zijn de dashboards bijna nooit het probleem. De reconciliatie tussen systemen is het probleem. Los dat op en de dashboards bouwen zichzelf.

Eén bron van waarheid voor klantidentiteit

We kozen Notion. Niet omdat Notion de beste database is (dat is hij niet), maar omdat het bureau al in Notion leefde en we wilden dat het ops team op één plek bleef werken, niet op twee. Elke klantrij in de Notion 'Clients' database kreeg een stabiel intern ID, een canonieke naam en drie kolommen met platform-specifieke aliassen: GA property ID, Meta ad account ID, LinkedIn account ID.

Die ene wijziging, voordat we ook maar één regel code schreven, haalde de handmatige reconciliatie volledig weg. Vanaf dat moment haakte elke downstream join in op het interne ID in plaats van een fuzzy name match. De ops lead was een halve dag bezig met het invullen van de aliaskolommen. Sindsdien heeft ze ze niet meer aangeraakt.

De vorm van de pipeline

We kozen voor de simpelste stack die een jaar verwaarlozing kon overleven. Notion als klantdimensie. Google BigQuery als warehouse. Looker Studio voor de klantrapportage. Eén Python-worker ertussen, gepland via systemd.

Notion biedt een goed gedocumenteerde REST API met cursor-paginering en een rate limit van drie requests per seconde. BigQuery accepteert batched inserts goedkoop. Looker Studio leest native uit BigQuery en rendert dashboards die je klanten met view-only toegang kunt geven zonder dat ze opnieuw moeten inloggen.

We hebben Zapier, n8n en Make hiervoor wel overwogen. Alle drie hadden kunnen werken. We hebben ze afgeschoten omdat het bureau al twee keer was gebrand door een Zapier-flow die niemand begreep, en omdat de per-task pricing van Zapier slecht opschaalt als je 23 klanten over zes platforms synchroniseert. Fivetran viel om dezelfde prijsreden af. Voor de ad-platforms gebruikten we vendor-native BigQuery-exports waar die bestonden (Google Ads, Search Console, GA4) en één self-hosted Airbyte-instance voor de rest. Daardoor landde de meeste data al in BigQuery zonder dat onze worker eraan kwam. De enige taken van de worker waren het syncen van de Notion clients-tabel en het draaien van de transformatie die het samengevoegde rapport opleverde.

De Python-worker, in één bestand

Hier is de kern van de Notion-naar-BigQuery sync. Het is ongeveer het bestand dat we hebben opgeleverd, met de projectnaam aangepast.

import os
import time
import requests
from google.cloud import bigquery

NOTION_TOKEN = os.environ["NOTION_TOKEN"]
NOTION_DB = os.environ["NOTION_CLIENTS_DB"]
BQ_TABLE = "agency_warehouse.dim_clients"

HEADERS = {
    "Authorization": f"Bearer {NOTION_TOKEN}",
    "Notion-Version": "2022-06-28",
    "Content-Type": "application/json",
}

def fetch_all_clients():
    url = f"https://api.notion.com/v1/databases/{NOTION_DB}/query"
    cursor = None
    while True:
        body = {"page_size": 100}
        if cursor:
            body["start_cursor"] = cursor
        r = requests.post(url, headers=HEADERS, json=body, timeout=30)
        r.raise_for_status()
        data = r.json()
        for row in data["results"]:
            yield flatten(row)
        if not data.get("has_more"):
            return
        cursor = data["next_cursor"]
        time.sleep(0.35)  # stay under 3 req/s

def flatten(page):
    p = page["properties"]
    return {
        "client_id": page["id"],
        "canonical_name": text(p["Name"]),
        "ga_property_id": text(p.get("GA Property")),
        "meta_account_id": text(p.get("Meta Account")),
        "li_account_id": text(p.get("LinkedIn Account")),
        "retainer_eur": number(p.get("Retainer EUR")),
        "active": p["Active"]["checkbox"],
        "last_synced": page["last_edited_time"],
    }

def text(prop):
    if not prop:
        return None
    parts = prop.get("title") or prop.get("rich_text") or []
    return "".join(part["plain_text"] for part in parts) or None

def number(prop):
    return prop.get("number") if prop else None

def write_to_bq(rows):
    client = bigquery.Client()
    job = client.load_table_from_json(
        list(rows),
        BQ_TABLE,
        job_config=bigquery.LoadJobConfig(
            write_disposition="WRITE_TRUNCATE",
            schema_update_options=["ALLOW_FIELD_ADDITION"],
        ),
    )
    job.result()

if __name__ == "__main__":
    write_to_bq(fetch_all_clients())

Dat is alles. Zestig regels Python, geen framework, geen orchestrator. Tegen de 23-rijen clientsdatabase van het bureau draait hij in ongeveer negen seconden. Een systemd-timer schopt hem elke ochtend om 05:55 aan. Als hij faalt, probeert de timer het vijf minuten later opnieuw en post hij in een Slack-kanaal dat de ops lead daadwerkelijk leest. We hebben hem bewust niet aan Airflow of Prefect gehangen. Op deze schaal is dat theater.

De transformatiestap

Zodra de clients-dimensie in BigQuery staat, is de rest van het werk SQL. We schreven één scheduled query die de ad-platform fact-tabellen joint aan de clients-dimensie op de interne ID's, maandelijkse aggregaties per kanaal berekent en het resultaat wegschrijft naar een monthly_client_summary tabel. Looker Studio leest uit die tabel.

We gebruikten hiervoor BigQuery's scheduled queries in plaats van dbt. dbt is het juiste antwoord als je vijftig modellen hebt die van elkaar afhangen. Wij hadden er vier, en geen daarvan deelde logica. De scheduled query is veertig regels SQL en is in vijf maanden niet aangeraakt. Wanneer het bureau een data-analist aanneemt (wat we ze hebben geadviseerd), kost de migratie naar dbt één middag.

De Looker Studio template

Eén rapportagetemplate, geparametriseerd op client ID. De ops lead dupliceert de template per klant, zet de parameter en deelt de URL met de klant. Klanten zien alleen hun eigen data, omdat de BigQuery-view op de parameter filtert. Er is geen klantdeck meer per maand. Het 'deck' is een live link die de klant kan openen wanneer hij wil.

Het bureau was eerst zenuwachtig over live toegang voor klanten. Drie maanden later hebben twee klanten gevraagd of de link weer dichtgezet kon worden naar een maandelijkse PDF-snapshot. De andere eenentwintig hebben liever de live versie en kijken er tussen calls door zelf naar. Eén klant begon op een woensdagmiddag al campaign drift te melden in plaats van te wachten op de maandelijkse call. Dat alleen al heeft het project terugverdiend.

De valkuilen waar we tegenaan liepen

Drie dingen beten ons. Het is de moeite waard ze te benoemen, omdat ze iedereen die hetzelfde doet zullen bijten.

Ten eerste hernoemt Notion stilletjes properties. Als iemand in het bureau via de Notion UI de kolom 'Meta Account' hernoemt naar 'Meta Ad Account', geeft de Notion API dat veld niet meer terug onder de oude naam. Onze flattener crashte daar vroeger op. We fingerprinten nu het database-schema aan het begin van elke run, loggen elke drift en pingen de ops lead via Slack als een bekende kolom is verdwenen. De flattener tolereert ontbrekende kolommen en geeft NULL terug.

Ten tweede kunnen Notion's rich-text velden gesplitst zijn over meerdere text runs, ook als ze in de UI als één string lijken. Als een gebruiker een waarde met verschillende opmaak kopieert en plakt, krijg je een lijst van drie text-objecten, niet één. De text() helper hierboven plakt ze aan elkaar. We leerden dat op de harde manier nadat het GA property ID van een klant terugkwam als 'G-ABC' in plaats van 'G-ABC123XYZ', door een verdwaalde fontwijziging halverwege de string.

Ten derde is WRITE_TRUNCATE prima voor een dimensie van 23 rijen. Voor een fact-tabel zou het fout zijn. Als je dit patroon kopieert voor iets dat groter is dan een paar duizend rijen, gebruik dan een MERGE statement met de Notion page ID als sleutel. Truncate-and-reload op schaal vreet je slot quota op en breekt elke downstream materialised view die afhangt van stabiele row identity.

Waarschuwing

Vertrouw nooit op een stabiele Notion-kolomnaam. Fingerprint altijd het schema aan het begin van de run en faal hard als een kolom waar je van afhangt hernoemd of verwijderd is.

Wat het echt kostte

De volledige rekening, per maand: 4 EUR voor het Hetzner-bakje, ongeveer 2 EUR aan BigQuery storage en querykosten bij het volume van het bureau, nul voor Looker Studio, nul voor de Notion API. De Airbyte-instance draait op datzelfde Hetzner-bakje. Totale infrastructuur: ongeveer 7 EUR per maand, tegenover ruwweg 6.000 EUR per maand aan seniortijd die we hebben vervangen.

De bouwtijd was vier werkdagen, end to end, inclusief de bronnenaudit en één revisieronde op de Looker-template. Het bureau had op dag drie een werkende versie. De vierde dag ging op aan het weghalen van de dingen die we niet nodig hadden.

Wat we bewust niet hebben gebouwd

Geen interne admin UI. De ops lead bewerkt Notion, zoals ze dat altijd al deed. Geen anomaly detection. Looker Studio heeft conditional formatting en het bureau wilde drift de eerste zes maanden met eigen ogen zien. Geen Slack-bot die dagelijks cijfers post. Het team heeft al genoeg Slack-ruis. Geen machine-learning model dat churn voorspelt. Het bureau heeft niet het datavolume om er één te trainen, en een logistische regressie op retainerlengte en campagnespend zou ze hetzelfde gratis vertellen.

Elk van die ideeën kwam in de kickoff voorbij. Elk had een week extra bouwen betekend, een week training en een staart aan onderhoud. Het doel van het project was twee senior mensen twee dagen per maand teruggeven. Alles wat daar niet aan bijdroeg, ging eruit.

Het kleinere idee binnen het grotere

Het interessante aan dit project was niet de pipeline. Het interessante was de audit. Zodra we de dataflow op één A3 hadden getekend, kon het bureau zien dat 80% van het tweedaagse maandritueel reconciliatie was. Fix de reconciliatie en de rest is leidingwerk.

Je kunt die audit zelf doen, zonder ons. Open een notebook, schrijf elk systeem op dat je rapportage raakt en teken een pijl voor elke handmatige copy-paste ertussen. De pijlen zijn het werk. De vakjes niet.

Toen we deze pipeline bouwden voor het Rotterdamse bureau, was het stilletjes hernoemen van properties in Notion het ding dat we niet zagen aankomen. We losten dat op door bij elke run het schema te fingerprinten en hard te falen zodra het driftte. Heeft jouw team een vergelijkbaar rapportageritueel dat elke maand senior uren opeet, dan is dat het soort procesautomatisering dat wij leveren.

Eén ding dat je vandaag kunt doen: zet alle systemen op een rij waar je laatste maandrapport uit trok, en omcirkel die waar de klant-identifier niet matcht tussen systemen. Die cirkel is het project.

Kern

Als maandrapportage traag voelt, zijn de dashboards niet het probleem. Reconciliatie tussen systemen is het probleem. Los dat eerst op en de rest is leidingwerk.

FAQ

Waarom Notion als bron van waarheid en niet een echte database?

Omdat het bureau al in Notion leefde. De dimensie naar Postgres verhuizen zou een tweede plek toevoegen om te bewerken. De dataset van 23 rijen heeft de Notion API nooit zwaar belast.

Waarom BigQuery en Looker Studio in plaats van Metabase of Power BI?

BigQuery's free tier dekte het volume van het bureau, Looker Studio is gratis en klanten kunnen dashboards bekijken zonder weer een login. Metabase moet je zelf hosten; Power BI vraagt licenties.

Werkt dit ook voor een bureau met 100 klanten?

De vorm werkt. Het truncate-and-reload patroon niet. Vervang WRITE_TRUNCATE door een MERGE met de Notion page ID als sleutel, en verplaats de transformatie naar dbt zodra je meer dan tien modellen hebt.

Hoe lang duurde de bouw?

Vier werkdagen end to end, inclusief de bronnenaudit en één revisieronde op Looker. Het bureau had op dag drie een werkende versie.

process automationautomationcase studyintegrationsworkflowoperations

Iets bouwen?

Start een project