Skip to Content

Procesarea webhook-urilor Pub/Sub de mare concurență și telemetria AdTech completă în Odoo

Un post-mortem tehnic de producție: construirea conductelor GCP Pub/Sub push, rezolvarea scurgerilor de memorie în aplicațiile SPA și atribuirea conversiilor server-side în PostgreSQL.

În arhitectura modernă a sistemelor enterprise, platforma ERP nu mai este o insulă operațională izolată. Ea devine ancora tranzacțională a unui ecosistem omnichannel, ingerând neîncetat fluxuri de evenimente cu frecvență ridicată: webhook-uri de mesagerie WhatsApp, cozi de mesaje Google Cloud Pub/Sub, rapoarte de încălcare a politicii de securitate CSP emise de browsere și telemetrie publicitară programmatică din Google Ad Manager (GAM).

Procesarea telemetriei în timp real și a webhook-urilor asincrone pe o stivă enterprise Python/PostgreSQL precum Odoo ridică provocări inginerești severe. În mod implicit, controllerele web din Odoo sunt concepute și optimizate pentru interacțiuni umane sincrone. Supuse unui volum masiv de evenimente concurente, implementările superficiale intră rapid în patru stări critice de avarie:

  1. Brokerii de mesaje garantează livrarea conform modelului „at-least-once”, astfel încât livrarea duplicată a webhook-urilor sub concurență masivă declanșează condiții de cursă (race conditions) care debitează plățile de două ori sau rezervă stocurile în mod eronat atunci când lipsește verificarea idempotenței.
  2. Combinarea blocurilor publicitare randate pe server (SSR) cu arhitectura de aplicație pe o singură pagină (SPA) a framework-ului OWL permite modificarea directă a arborelui DOM de către scripturi externe, ceea ce determină scurgeri masive de memorie RAM și blocarea reconcilierii Virtual DOM.
  3. Endpoint-urile publice de telemetrie (cum ar fi /csp-violation) deschid cursoare dedicate de bază de date la fiecare scanare a boților, epuizând instantaneu pool-ul de conexiuni PostgreSQL (db_maxconn) și blocând accesul utilizatorilor ERP la aplicație.
  4. Agenții autonomi și scripturile de integrare constată că rapoartele HTML trimise prin XML-RPC în canalele Chatter apar cu etichetele vizibile sub formă de text brut <div> din cauza barierelor clasei markupsafe.Markup.

Acest raport de laborator documentează soluțiile noastre inginerești la aceste patru provocări structurale, detaliind arhitectura validată în producție.

Conducta de ingestie și idempotență Pub/Sub de înaltă concurență

01

Ingestie edge și filtru de protocol

Reverse proxy-ul direcționează webhook-ul către controllerul type='http'; verifică metoda POST și token-ul secret de acces.

02

Verificare criptografică HMAC

Controllerul extrage antetul X-Hub-Signature-256 și validează semnătura prin hmac.compare_digest împotriva cheii secrete.

03

Blocaj atomic de idempotență

Interogare în tabela dlab.idempotency.log pentru (message_id, channel_type); duplicatele primesc răspuns imediat 200 Duplicate Ack.

04

Context de urmărire și rutare asincronă

Extrage trace_id, injectează identificatorul în request.context și predă sarcina către procesarea asincronă de fundal.

05

Confirmare robustă a stării

Returnează HTTP 200 OK la procesare reușită; aruncă HTTP 500 la eroare internă pentru a forța reîncercarea Pub/Sub.

De ce arhitectura de ingestie Pub/Sub impune type='http'


La conectarea abonamentelor de tip push din Google Cloud Pub/Sub cu Odoo, dezvoltatorii tind să configureze rutele folosind decoratorul uzual JSON:

@http.route('/webhook/push', type='json', auth='public', methods=['POST'])
def receive_push(self):
data = request.jsonrequest
# ... logica de prelucrare ...

Capcana arhitecturală

În mecanismul de dispecerizare din Odoo (odoo.http.JsonRequest), orice excepție Python declanșată în timpul execuției unei rute cu type='json' este interceptată de wrapper-ul intern de RPC. Odoo formatează excepția într-un răspuns JSON-RPC:

{"jsonrpc": "2.0", "id": null, "error": {"code": 200, "message": "Odoo Server Error", "data": {...}}}

În mod critic, Odoo setează codul de stare HTTP al răspunsului la 200 OK.

Google Cloud Pub/Sub se bazează exclusiv pe codurile de stare din protocolul de transport HTTP:

  • Codurile HTTP 200, 201 sau 204 confirmă procesarea mesajului (ack), iar Pub/Sub elimină mesajul din coada de așteptare.
  • Codurile HTTP 500, 503 sau 429 semnalează o livrare eșuată (nack), determinând Pub/Sub să păstreze mesajul și să inițieze reîncercări succesive conform politicii de backoff exponențial (până la 7 zile).

Deoarece Odoo returnează HTTP 200 OK chiar și atunci când apare un blocaj tranzacțional în baza de date, un deadlock sau o excepție de date nule, Pub/Sub consideră mesajul procesat cu succes și îl șterge definitiv din coadă. Confirmările de plată, comenzile și mesajele critice se pierd ireversibil fără nicio urmă.

Soluția de producție

Punctele de intrare pentru webhook-uri trebuie să utilizeze fără excepție @http.route(..., type='http'). Aceasta îi permite inginerului să controleze direct codul de stare HTTP returnat:

# -*- coding: utf-8 -*-
from odoo import http, fields
from odoo.http import request, Response
import logging
import uuid
import hmac
import hashlib

_logger = logging.getLogger(__name__)

class PubSubController(http.Controller):

# Obligatoriu type='http'. type='json' maschează erorile în 200 OK și rupe reîncercările.
@http.route('/dlab/webhook/pubsub_push/<string:token>',
type='http', auth='public', methods=['GET', 'POST'], csrf=False)
def receive_pubsub_push(self, token, **post):
try:
# 1. Verificare Token de Tranzacție
expected_token = request.env['ir.config_parameter'].sudo().get_param(
'dlab.pubsub_token', default='invalid_default'
)
if token != expected_token:
_logger.warning("Tentativă de acces neautorizat la webhook cu token invalid.")
return Response("Forbidden", status=403)

# 2. Extragere Date JSON și Antete
json_data = request.get_json_data()
if not json_data:
return Response("Empty Payload", status=400)

raw_body = request.httprequest.get_data()
meta_sig = request.httprequest.headers.get('X-Hub-Signature-256')
trace_id = f"msg_{uuid.uuid4().hex[:8]}"

# 3. Verificare Criptografică HMAC (Webhook Meta / WhatsApp)
if meta_sig:
app_secret = request.env['ir.config_parameter'].sudo().get_param('dlab.meta.app_secret', default='')
expected_hash = hmac.new(app_secret.encode('utf-8'), raw_body, hashlib.sha256).hexdigest()
expected_sig = f"sha256={expected_hash}"

if not hmac.compare_digest(expected_sig, meta_sig):
_logger.critical("Eșec la verificarea semnăturii HMAC!")
return Response("Invalid Signature", status=403)

channel_type = "whatsapp"
message_id = json_data['entry'][0]['changes'][0]['value']['messages'][0]['id']

# 4. Ingestie Standard Google Cloud Pub/Sub
elif 'message' in json_data and 'messageId' in json_data['message']:
channel_type = "gcp_pubsub"
message_id = json_data['message']['messageId']
else:
return Response("Unprocessable Entity", status=422)

# 5. Verificare Atomică a Idempotenței
IdempotencyLog = request.env['dlab.idempotency.log'].sudo()
if IdempotencyLog.is_processed(message_id, channel_type):
_logger.info("Mesajul duplicat %s a fost ignorat în siguranță.", message_id)
return Response("Duplicate Ack", status=200)

# 6. Transmitere Sarcină către Stratul de Business
success = request.env['dlab.pubsub.normalizer'].sudo().process_payload(
channel_type, json_data, trace_id
)
if not success:
# Declanșăm reîncercarea în Pub/Sub prin codul 500
_logger.error("Prelucrarea internă a eșuat pentru mesajul %s", message_id)
return Response("Internal Processing Failure", status=500)

return Response("OK", status=200)

except Exception as e:
_logger.exception("Eroare necaptată în handlerul de webhook: %s", str(e))
# Esențial: Întoarcem 500 pentru ca brokerul Pub/Sub să declanșeze backoff
return Response("Internal Server Error", status=500)

Prevenirea scurgerilor de memorie în aplicațiile SPA sub framework-ul OWL


La integrarea motoarelor avansate de publicitate (cum ar fi Google Ad Manager prin biblioteca gpt.js) pe site-ul Odoo, arhitecții se lovesc frecvent de acumulări masive de memorie și blocarea reconcilierii DOM-ului virtual.

Navigarea client-side în SPA față de scripturile terțe care modifică DOM-ul

În temele moderne Odoo Website, navigarea între articole se bazează pe un router asincron de tip SPA (publicWidget sau componente native OWL). La accesarea unui link de articol, browserul nu reîncarcă pagina de la zero; motorul SPA descarcă prin AJAX conținutul noului #wrap și înlocuiește nodurile DOM.

Scripturile externe precum Google Publisher Tag (googletag) păstrează un registru global intern al tuturor sloturilor definite pe pagină:

window.googletag.defineSlot('/23345748244/ad_unit', [300, 250], 'div-gpt-ad-1');

Când un utilizator navighează de la Articolul A la Articolul B:

  1. Elementele DOM care conțineau div-gpt-ad-1 sunt eliminate din pagină de către Odoo.
  2. Noul HTML este injectat, declarând un nou element cu ID-ul div-gpt-ad-1.
  3. Dacă widget-ul de client doar golește variabila locală window.dlab_defined_slots = {}, sloturile vechi rămân înregistrate în memoria globală a bibliotecii googletag.
  4. La reapelarea googletag.display('div-gpt-ad-1') pe noua pagină, motorul Google Publisher Tag se blochează cu eroarea: Error: Slot 'div-gpt-ad-1' was already registered on this page.
  5. Referințele reținute în memorie împiedică eliberarea memoriei de către Garbage Collector, provocând scurgeri de până la 50 MB la fiecare navigare în site.

Ciclul de viață SPA și modelul de scriere în doi pași prin XML-RPC

/** @odoo-module **/
import publicWidget from "@web/legacy/js/public/public_widget";

export const DlabGamBlogEngine = publicWidget.Widget.extend({
selector: '#o_wblog_post_main',

destroy: function () {
// Critic: Distrugerea explicită a sloturilor din googletag înainte de curățarea referințelor
if (window.googletag && googletag.destroySlots) {
// 1. Curățarea sloturilor dinamice injectate în corpul articolului
if (window.dlab_defined_slots && Object.keys(window.dlab_defined_slots).length > 0) {
googletag.destroySlots(Object.values(window.dlab_defined_slots));
window.dlab_defined_slots = {};
}
// 2. Curățarea sloturilor SSR de sus și multiplex
if (window.dlab_spa_slots && window.dlab_spa_slots.length > 0) {
googletag.destroySlots(window.dlab_spa_slots);
window.dlab_spa_slots = [];
}
}
this._super.apply(this, arguments);
}
});
# Soluția la capcana de escapare a tagurilor HTML prin XML-RPC:
# Pasul 1: Crearea mesajului prin message_post (escapare text simplu)
msg_id = models.execute_kw(db, uid, pwd, 'blog.post', 'message_post', [[post_id]], {
'body': 'Audit Log Initializat',
'message_type': 'comment',
'subtype_xmlid': 'mail.mt_note'
})

# Pasul 2: Scrierea directă pe mail.message acceptă text bogat fără escapare!
clean_html = "Telemetry Status: 100% Verificat (Semnatura Validata)"
models.execute_kw(db, uid, pwd, 'mail.message', 'write', [
[msg_id], {'body': clean_html}
])

Rezolvarea capcanei de escapare Markup() prin XML-RPC


În mediile enterprise, agenții de automatizare și procesele externe comunică cu Odoo în principal prin XML-RPC sau JSON-RPC. O sursă frecventă de degradare vizuală în canalele de comunicare Discuss apare atunci când un script extern publică un raport formatat în HTML.

Dubla escapare prin tools.plaintext2html()

La apelarea models.execute_kw(..., 'message_post', ...) prin protocolul XML-RPC, toate șirurile de caractere sunt serializate în noduri XML standard <string>. Tipul Python markupsafe.Markup nu poate fi transmis prin fir.

Când metoda Odoo mail.thread.message_post recepționează corpul mesajului:

isinstance(body, markupsafe.Markup) # Returnează False!

Deoarece obiectul este un simplu str, Odoo îl tratează ca pe un text simplu nesecurizat și îl trece prin funcția tools.plaintext2html(). Aceasta transformă fiecare < în &lt; și fiecare > în &gt;. Când utilizatorul deschide firul de discuție, în loc de un tabel formatat sau o insignă colorată, vede etichete brute de HTML afișate pe ecran:

&lt;p&gt;&lt;b&gt;Audit Finalizat&lt;/b&gt;&lt;/p&gt;

Modelul de scriere în doi pași (Two-Step Write)

Pentru a rezolva această problemă fără a modifica codul de bază din Odoo, apelurile externe trebuie să aplice modelul de scriere în doi pași:

  1. Se apelează message_post cu un text simplu temporar, generând înregistrarea mail.message și setând subtype_xmlid='mail.mt_note' (pentru a bloca notificarea clienților pe email).
  2. Se execută imediat o operațiune directă de write() pe înregistrarea nou creată în modelul mail.message, actualizând direct câmpul body (fields.Html).

Scrierea directă pe un câmp de tip fields.Html ocolește funcția plaintext2html() și aplică igienizarea internă tools.html_sanitize(), păstrând etichetele HTML intacte și afișând corect elementele vizuale.

Protecția cursoarelor de bază de date sub telemetrie masivă CSP


Politicile de securitate moderne impun utilizarea antetelor Content Security Policy (CSP) pentru blocarea atacurilor de tip Cross-Site Scripting (XSS). Când browserul detectează o încălcare, trimite automat un raport JSON către endpoint-ul /csp-violation.

Epuizarea pool-ului de conexiuni sub traficul de boți

În timpul unui atac de tip scanare de vulnerabilități sau când un crawler web declanșează alerte pe o pagină cu scripturi externe, mii de apeluri pot lovi endpoint-ul /csp-violation în câteva secunde.

Dacă controllerul deschide un cursor dedicat pentru fiecare cerere:

with request.env.registry.cursor() as cr:
env = api.Environment(cr, SUPERUSER_ID, {})
env['dlab.csp.violation'].create(report_data)

Fiecare cerere HTTP blochează o conexiune din baza de date. La un volum de 500 de cereri pe secundă, limita de conexiuni (db_maxconn = 64) este consumată instantaneu. Utilizatorii autentificați și fluxurile de facturare sunt blocate complet cu eroarea psycopg2.OperationalError: FATAL: remaining connection slots are reserved for non-replication superuser connections. În plus, pe parcursul lunilor de funcționare, tabela se umple cu sute de mii de înregistrări inutile.

Limitarea în memorie și curățarea automată prin @api.autovacuum

Arhitectura noastră folosește două bariere complementare:

  1. Un limitator glisant în memoria RAM per IP urmărește frecvența rapoartelor pe fiecare subrețea /24. Dacă o adresă IP depășește 10 rapoarte într-un interval de 60 de secunde, controllerul respinge cererea direct la nivelul stratului WSGI cu HTTP 429 Too Many Requests, fără a atinge baza de date și fără a aloca vreun cursor PostgreSQL.
  2. Un mecanism de curățare automată programată implementează cârligul nativ @api.autovacuum, corelat cu parametrul de companie res.company.x_csp_retention_days (implicit 7 zile). Procedura de noapte șterge automat înregistrările vechi în pachete indexate, prevenind blocarea tabelei.
from odoo import models, fields, api
from datetime import timedelta

class DlabCspViolation(models.Model):
_name = 'dlab.csp.violation'
_description = 'Telemetrie Încalcare CSP cu Throttling'
_order = 'create_date desc'

blocked_uri = fields.Char(string="URI Blocat", size=512, index=True)
violated_directive = fields.Char(string="Directivă Încălcată", size=128)
client_ip = fields.Char(string="IP Client", size=45, index=True)

@api.autovacuum
def _gc_csp_violations(self):
"""Ștergerea rapoartelor mai vechi decât perioada de retenție configurată."""
retention_days = int(
self.env['ir.config_parameter'].sudo().get_param('dlab.csp_retention_days', 7)
)
cutoff_date = fields.Datetime.now() - timedelta(days=retention_days)

# Ștergerea în pachete previne blocajele prelungite pe tabelă
self.search([('create_date', '<', cutoff_date)], limit=5000).unlink()

Atribuirea închisă (closed-loop) în SQL de la cookie-ul FPID la veniturile reale


Punctul culminant al ingineriei de telemetrie este corelarea fluxurilor eterogene de date într-un registru unificat de atribuire financiară. În contextul blocării cookie-urilor terțe (3rd-party cookies) în browserele moderne, sistemele analitice trebuie să se bazeze pe identificatori server-side de primă parte (first-party).

Motorul nostru de atribuire corelează trei surse de date în PostgreSQL:

  1. Identificatorul dlab_fpid este un cookie securizat, marcat HTTP-only, generat la prima vizită a utilizatorului și salvat în sesiunile web locale.
  2. Meta Conversions API (CAPI server-side) transmite evenimentele de conversie și achiziție direct de la server la server prin apeluri HTTPS cu parametri hashuiți.
  3. Google Ad Manager SOAP API (v202602) furnizează rapoartele financiare de noapte conținând metricile eCPM, numărul de afișări și criteriile de targetare descărcate prin ReportDownloader.DownloadReportToFile. (Atenție: în versiunea API v202602, fișierul descărcat este arhivat implicit prin gzip și trebuie decomprimat cu gzip.open(..., mode='rt', encoding='utf-8')).
-- Vedere SQL de Producție: Performanța Veniturilor și Atribuirea Conținutului
CREATE OR REPLACE VIEW dlab_content_attribution_report AS
SELECT
bp.id AS post_id,
bp.name AS article_title,
bp.author_id,
COUNT(DISTINCT sess.fpid) AS unique_readers,
COALESCE(SUM(gam.impressions), 0) AS total_ad_impressions,
COALESCE(SUM(gam.revenue_cpm), 0.0) AS direct_ad_revenue,
COALESCE(SUM(capi.conversion_value), 0.0) AS downstream_conversions,
(COALESCE(SUM(gam.revenue_cpm), 0.0) + COALESCE(SUM(capi.conversion_value), 0.0)) AS total_economic_yield
FROM
blog_post bp
LEFT JOIN
dlab_web_session sess ON sess.post_id = bp.id
LEFT JOIN
dlab_gam_daily_revenue gam ON gam.post_id = bp.id
AND gam.date >= CURRENT_DATE - INTERVAL '30 days'
LEFT JOIN
dlab_meta_capi_event capi ON capi.fpid = sess.fpid
AND capi.event_name = 'Purchase'
GROUP BY
bp.id, bp.name, bp.author_id;

Creșterea acurateței atribuirii: CAPI server-side + FPID vs pixel tradițional

Când nu trebuie implementate stive complexe de telemetrie


Rigoarea inginerească impune identificarea clară a limitelor de oportunitate:

  1. Companiile care dețin site-uri instituționale cu trafic redus (sub 500 de vizite zilnice) și procesează mai puțin de 10 webhook-uri pe săptămână nu au nevoie de cozi Pub/Sub, tabele de idempotență și conducte CAPI. Instrumentele standard de client și scripturile sincrone sunt pe deplin suficiente.
  2. Mediile cu conectivitate privată monolitică, în care toți producătorii și consumatorii de evenimente rulează în interiorul aceluiași VPC cu latențe sub o milisecundă și disponibilitate de 99.999% (cum ar fi microserviciile interne prin gRPC), nu necesită cozi de reîncercare cu stocare pe termen lung.
  3. Fluxurile de plată sincrone cu redirect 3D-Secure, în care checkout-ul de e-commerce impune ca utilizatorul să primească instantaneu pe ecran provocarea băncii emitente, nu trebuie transformate într-o coadă asincronă Pub/Sub, deoarece întârzierea interfeței determină abandonul coșului de cumpărături.

La proiectarea acestor integrări de înaltă concurență, granițele dintre protocoale trebuie păstrate clare. Specificația JSON-RPC 2.0 cere returnarea codului HTTP 200 ori de câte ori comunicarea de transport reușește, împachetând detaliile erorii în corpul JSON, în timp ce brokerii cloud precum GCP Pub/Sub se bazează strict pe codurile HTTP 500 sau 503 pentru a declanșa reîncercările. În mod similar, separarea creării mesajului prin message_post de scrierea directă write() pe mail.message.body, apelarea explicită a metodei googletag.destroySlots() la navigarea în SPA-ul OWL și respingerea rafalelor abuzive către /csp-violation direct în memoria WSGI înainte de alocarea unui cursor PostgreSQL mențin stabile atât interfața web, cât și baza de date sub sarcină maximă.

ERP Gestionat, AI Omnicanal și Livrare White-Label dlab.md Engineering Advisory

Implementați Odoo 19, agenți MCP și atribuire Server-Side în 6 săptămâni

Fie că sunteți o companie în expansiune care unifică CRM-ul, ERP-ul și agenții AI conversaționali, fie o agenție digitală europeană care caută un partener tehnic White-Label pentru proiecte complexe Odoo 19 și Meta CAPI — livrăm infrastructură la cheie cu buget și termen fix.