Skip to Content

Handling High-Concurrency Pub/Sub Webhooks and End-to-End AdTech Telemetry in Odoo

A production systems post-mortem: engineering GCP Pub/Sub push pipelines, resolving single-page application memory leaks, and building server-side conversion attribution in PostgreSQL.

In modern enterprise architectures, the ERP is no longer an isolated operational island. It serves as the transactional anchor of an omnichannel ecosystem, continuously ingesting high-velocity event streams: incoming WhatsApp messaging webhooks, Google Cloud Pub/Sub message queues, Content Security Policy (CSP) browser violation reports, and programmatic advertising telemetry from Google Ad Manager (GAM).

Operating real-time telemetry and asynchronous webhook endpoints on top of an enterprise Python/PostgreSQL framework like Odoo presents acute engineering challenges. By default, Odoo’s web controllers are optimized for synchronous user interactions. When subjected to bursty, high-concurrency event ingestion, naive implementations rapidly trigger four failure modes:

  1. Message queues guarantee at-least-once delivery, so duplicate webhook deliveries under high concurrency trigger race conditions that process the same payment or inventory reservation multiple times when idempotency checks are missing.
  2. Combining server-side rendered (SSR) advertising slots with Odoo's single-page application (SPA) frontend framework allows direct DOM manipulation by third-party scripts to cause memory leaks, zombie event listeners, and OWL virtual DOM reconciliation crashes.
  3. Unchecked browser telemetry endpoints (such as /csp-violation) open dedicated database cursors on every bot scan, exhausting PostgreSQL connection pools (db_maxconn) and bringing the core business application offline.
  4. Automation agents communicating over XML-RPC discover that rich HTML notes posted to Discuss threads have their markup escaped into visible <div> entities due to internal markupsafe.Markup boundaries.

This production post-mortem documents our architectural solutions to these four challenges, detailing the exact patterns deployed across our production infrastructure.

High-Concurrency Pub/Sub Ingestion & Idempotency Pipeline

01

Edge Ingestion & Protocol Gate

Reverse proxy routes incoming webhook to type='http' controller; rejects non-POST and validates route bearer token.

02

Cryptographic HMAC Verification

Controller parses X-Hub-Signature-256 header and executes timing-safe hmac.compare_digest against secret.

03

Atomic Idempotency Lock

Query checks dlab.idempotency.log for (message_id, channel_type); duplicate payloads return immediate 200 Duplicate Ack.

04

Trace Context & Factory Dispatch

Extracts trace_id, injects it into request.context, and hands normalized payload to asynchronous background processing.

05

Reliable Status Acknowledgment

Returns HTTP 200 OK on verified ingestion; throws HTTP 500 on internal failure to trigger Pub/Sub exponential backoff.

Why Pub/Sub Ingestion Requires type='http'


When integrating Google Cloud Pub/Sub push subscriptions with Odoo, junior developers instinctively configure controller routes using Odoo's native JSON decorator:

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

The Architectural Trap

Under Odoo's HTTP dispatch engine (odoo.http.JsonRequest), any Python exception raised during the execution of a type='json' route is caught by the internal RPC wrapper. Odoo formats the traceback into a JSON-RPC error structure:

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

Even when an unhandled exception occurs, Odoo sets the HTTP response status code to 200 OK.

Google Cloud Pub/Sub operates on standard HTTP status codes:

  • Status codes HTTP 200, 201, or 204 acknowledge the message (ack), instructing Pub/Sub to remove it from the subscription queue.
  • Status codes HTTP 500, 503, or 429 signal a failed delivery (nack), instructing Pub/Sub to retain the message and retry delivery according to its configured exponential backoff policy (e.g., retrying up to 7 days).

Because Odoo returns HTTP 200 OK even when a database deadlock, serialization failure, or unexpected null pointer occurs, Pub/Sub considers the message successfully delivered and permanently purges it from the queue. Critical financial webhooks, payment confirmations, and customer orders are silently lost forever.

The Production Solution

Webhook ingestion endpoints must strictly use @http.route(..., type='http'). This allows the developer to exert granular, manual control over the HTTP status response:

# -*- 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):

# MUST use type='http'. type='json' traps exceptions in 200 OK, breaking backoff retries.
@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. Bearer Token Verification
expected_token = request.env['ir.config_parameter'].sudo().get_param(
'dlab.pubsub_token', default='invalid_default'
)
if token != expected_token:
_logger.warning("Unauthorized webhook access attempt with invalid token.")
return Response("Forbidden", status=403)

# 2. Extract JSON Payload & Headers
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. Cryptographic Signature Gate (Meta / WhatsApp Webhooks)
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("HMAC Signature verification failed!")
return Response("Invalid Signature", status=403)

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

# 4. Standard GCP Pub/Sub Ingestion
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. Atomic Idempotency Verification
IdempotencyLog = request.env['dlab.idempotency.log'].sudo()
if IdempotencyLog.is_processed(message_id, channel_type):
_logger.info("Duplicate message %s safely dropped.", message_id)
return Response("Duplicate Ack", status=200)

# 6. Dispatch Payload to Business Layer
success = request.env['dlab.pubsub.normalizer'].sudo().process_payload(
channel_type, json_data, trace_id
)
if not success:
# Trigger Pub/Sub retry via HTTP 500
_logger.error("Downstream processing failed for message %s", message_id)
return Response("Internal Processing Failure", status=500)

return Response("OK", status=200)

except Exception as e:
_logger.exception("Fatal unhandled exception in webhook handler: %s", str(e))
# Critical: Return 500 so Pub/Sub initiates exponential backoff
return Response("Internal Server Error", status=500)

Preventing Single-Page Application (SPA) Memory Leaks in the OWL Framework


When integrating enterprise advertising engines (such as Google Ad Manager via gpt.js) into an Odoo modern website, architects frequently encounter severe memory leaks and virtual DOM reconciliation crashes.

Client-Side SPA Transitions vs External Script Mutators

In standard Odoo Website themes, page navigation utilizes an asynchronous client-side routing engine (@web/legacy/js/public/public_widget or native OWL components). When a user clicks a blog post link, the browser does not perform a full page reload; instead, the SPA engine fetches the inner HTML of #wrap and swaps the DOM elements dynamically.

Third-party scripts like Google Publisher Tag (googletag) maintain their own internal global registries of active advertising slots:

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

When an Odoo user navigates from Article A to Article B:

  1. The DOM elements containing div-gpt-ad-1 are removed by Odoo's SPA engine.
  2. The new page HTML is injected, declaring a new div-gpt-ad-1.
  3. If the developer's client-side widget simply resets its local variable window.dlab_defined_slots = {}, the slots remain permanently registered inside googletag's internal memory.
  4. When googletag.display('div-gpt-ad-1') is called on the new page, the Google Publisher Tag engine panics with: Error: Slot 'div-gpt-ad-1' was already registered on this page.
  5. Stale closure references retain full DOM node subtrees in memory, triggering aggressive browser memory growth (leaking up to 50 MB per client-side navigation).

Client-Side SPA Lifecycle & XML-RPC Two-Step Pattern

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

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

destroy: function () {
// Critical: Explicitly unregister slots from googletag before clearing references
if (window.googletag && googletag.destroySlots) {
// 1. Purge dynamically injected in-article defined slots
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. Purge SSR top and multiplex slots
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);
}
});
# Solution to XML-RPC Markup Escaping Trap:
# Step 1: Create message via message_post (escapes plain string body)
msg_id = models.execute_kw(db, uid, pwd, 'blog.post', 'message_post', [[post_id]], {
'body': 'Audit Log Initialized',
'message_type': 'comment',
'subtype_xmlid': 'mail.mt_note'
})

# Step 2: Direct write to mail.message accepts unescaped rich text!
clean_html = "Telemetry Status: 100% Verified (Signature Validated)"
models.execute_kw(db, uid, pwd, 'mail.message', 'write', [
[msg_id], {'body': clean_html}
])

Resolving the XML-RPC Markup() Escaping Trap


In enterprise environments, AI agents and external orchestration tools interact with Odoo primarily over XML-RPC or JSON-RPC. A frequent source of visual corruption in Discuss channels and chatter threads occurs when an external script posts an HTML audit report.

Double Escaping via tools.plaintext2html()

When calling models.execute_kw(..., 'message_post', ...) over XML-RPC, the protocol serializes all strings into standard XML <string> scalars. The concept of Python’s markupsafe.Markup cannot exist over the wire.

When Odoo’s mail.thread.message_post receives the parameter:

isinstance(body, markupsafe.Markup) # Evaluates to False!

Because the incoming payload is a plain str, Odoo assumes it is untrusted raw text and passes it through tools.plaintext2html(). This function escapes every < into &lt; and > into &gt;. When the user views the Chatter, instead of a formatted table or status badge, they see raw, garbled HTML tags leaking across the interface:

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

The Two-Step Write Pattern

To circumvent this limitation without modifying Odoo core source code, external clients must use the Two-Step Write Pattern:

  1. Invoke message_post with a temporary plaintext placeholder, creating the underlying mail.message record and setting subtype_xmlid='mail.mt_note' (preventing unwanted customer email broadcasts).
  2. Execute a direct write() call on the newly created mail.message record targeting the body field (fields.Html).

Because direct write() operations on fields.Html bypass plaintext2html() and sanitize HTML directly via tools.html_sanitize(), the markup is preserved cleanly without entity escaping.

Protecting Database Cursors Under High-Volume CSP Telemetry


Modern security policies mandate deploying Content Security Policy (CSP) headers to prevent Cross-Site Scripting (XSS). When a browser detects a CSP violation, it dispatches an asynchronous JSON report to the /csp-violation endpoint.

Cursor Starvation Under Bot Traffic

During vulnerability scans or when an automated web crawler encounters a minor third-party script violation, thousands of client browsers may hit /csp-violation simultaneously.

If the controller allocates a dedicated database cursor per request:

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

Each incoming HTTP request locks a database connection from the pool. Under a burst of 500 requests per second, the pool limit (db_maxconn = 64) is instantly exhausted. Legitimate administrative users and sales checkouts fail with psycopg2.OperationalError: FATAL: remaining connection slots are reserved for non-replication superuser connections. Over weeks of operation, the dlab_csp_violation table also accumulates hundreds of thousands of useless rows, bloating by gigabytes.

Sliding Window In-Memory Throttling and @api.autovacuum

Our architecture deploys two complementary defenses:

  1. An in-memory IP sliding window cache tracks violation frequency per /24 subnet. If an IP exceeds 10 reports in a 60-second window, the controller returns HTTP 429 Too Many Requests immediately at the WSGI layer without opening a PostgreSQL database cursor.
  2. An automated vacuum retention job implements Odoo's native @api.autovacuum hook, tying data retention to res.company.x_csp_retention_days (defaulting to 7 days). Nightly cron runs automatically purge historical records in batched chunks using index-backed deletion.
from odoo import models, fields, api
from datetime import timedelta

class DlabCspViolation(models.Model):
_name = 'dlab.csp.violation'
_description = 'Throttled CSP Violation Telemetry'
_order = 'create_date desc'

blocked_uri = fields.Char(string="Blocked URI", size=512, index=True)
violated_directive = fields.Char(string="Violated Directive", size=128)
client_ip = fields.Char(string="Client IP", size=45, index=True)

@api.autovacuum
def _gc_csp_violations(self):
"""Purge historical violations older than configured retention days."""
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)

# Batch deletion prevents long-running table locks
self.search([('create_date', '<', cutoff_date)], limit=5000).unlink()

Closed-Loop SQL Attribution from First-Party FPID to Revenue Reporting


The final milestone of telemetry engineering is stitching disparate event streams into a unified financial attribution ledger. With third-party cookies deprecated across modern browsers, enterprise analytics must rely on server-side first-party identifiers.

Our attribution engine links three discrete data streams in PostgreSQL:

  1. The first-party identifier dlab_fpid is a cryptographically random, HTTP-only, secure cookie generated on the client's initial visit and logged across all web sessions.
  2. Meta Server-Side CAPI dispatches server-to-server purchase conversion events over HTTPS with hashed user parameters.
  3. The Google Ad Manager SOAP API (v202602) provides nightly revenue reporting that ingests eCPM, impression counts, and custom targeting criteria downloaded via ReportDownloader.DownloadReportToFile. (Note: In API v202602, the downloaded report stream is gzip-compressed by default and must be decompressed with gzip.open(..., mode='rt', encoding='utf-8')).
-- Production Attribution View: Closed-Loop Content Revenue Performance
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;

Attribution Accuracy Lift: Server-Side CAPI + First-Party FPID vs Client Pixel

When Not to Deploy Complex Telemetry Stacks


Engineering rigor requires acknowledging when an architecture should not be implemented:

  1. Organizations running low-traffic corporate information sites receiving under 500 visits per day and processing fewer than 10 webhooks per week have no need for asynchronous Pub/Sub message queues, PostgreSQL idempotency logs, or server-side CAPI pipelines. Standard analytics and synchronous webhook handlers are entirely adequate.
  2. Environments with monolithic private network connectivity where all webhook producers and consumers live inside the same VPC with sub-millisecond latency and a 99.999% SLA (such as internal microservices communicating over gRPC) do not need multi-day retry buffers and exponential backoff.
  3. Simple synchronous payment form handlers where an e-commerce checkout depends on an immediate credit card authorization token and the user is waiting on screen for a 3D-Secure challenge redirect should never be converted into an asynchronous Pub/Sub worker queue, as doing so introduces UI lag and degrades conversion rates.

When evaluating these patterns for high-concurrency deployments, keep the protocol boundaries distinct. The JSON-RPC 2.0 specification requires returning HTTP 200 whenever transport-level communication succeeds, encapsulating application errors inside the JSON payload, whereas cloud message brokers like GCP Pub/Sub rely strictly on HTTP 500 or 503 status codes to trigger redeliveries. Similarly, separating XML-RPC message_post creation from a direct write() to mail.message.body, explicitly calling googletag.destroySlots() during OWL SPA navigation, and rejecting abusive /csp-violation bursts in WSGI memory before allocating a PostgreSQL cursor keep both the frontend and database pool stable under peak load.

Managed ERP, Omnichannel AI & White-Label Delivery dlab.md Engineering Advisory

Deploy Production-Grade Odoo 19, MCP Agents & Server-Side Attribution in 6 Weeks

Whether you are a scaling enterprise unifying CRM, ERP, and conversational AI, or a European digital agency seeking a white-label engineering partner for complex Odoo 19 and Meta CAPI deployments—we deliver fixed-scope infrastructure without source-code lock-in.