and runs during HTML parse, but hubspotutk is set by js.hs-analytics.net, measured loading at ~834ms. On a first visit the cookie does not exist yet, so a single read returns nothing and the visitor gets no external ID at all — and a first visit is exactly what an ad click is. So poll briefly until HubSpot sets it. The SDK loads lazily (~4.5s) and replays a queue, so an init queued a second or two in still lands before any events are transmitted. */ (function attachExternalId() { var attempts = 0; var MAX_ATTEMPTS = 40; /* 40 x 200ms = 8s, then give up quietly */ var timer = null; function tryAttach() { var raw = getExternalId(); if (!raw) { if (++attempts >= MAX_ATTEMPTS && timer) clearInterval(timer); return; } if (timer) clearInterval(timer); sha256Hex(raw).then(function (hash) { oaiq("init", { user: { external_id_sha256: hash } }); }).catch(function () {}); } tryAttach(); /* returning visitor: immediate */ if (!getExternalId()) timer = setInterval(tryAttach, 200); })(); /* --------------------------------------------------------------------------- 2. Load the SDK off the critical path Whichever comes first: the main thread going idle after load, or the first sign of real user intent. No preconnect on purpose — a TLS handshake during LCP for a script we deliberately deferred is a net loss. Measured on clickpost.ai: load event 3614ms, SDK fetched 4508ms. ------------------------------------------------------------------------ */ var loaded = false; var INTENT = ["pointerdown", "keydown", "scroll"]; function loadSdk() { if (loaded) return; loaded = true; INTENT.forEach(function (t) { removeEventListener(t, loadSdk, { passive: true }); }); var s = document.createElement("script"); s.src = SDK; s.async = true; s.fetchPriority = "low"; document.head.appendChild(s); } INTENT.forEach(function (t) { addEventListener(t, loadSdk, { passive: true, once: true }); }); addEventListener("load", function () { window.requestIdleCallback ? requestIdleCallback(loadSdk, { timeout: 2500 }) : setTimeout(loadSdk, 1500); }, { once: true }); /* --------------------------------------------------------------------------- 3. Conversion id sha256(lowercase(email) + "|" + formId + "|" + ISO-hour), first 20 hex, "lead_" prefix. - email lowercased/trimmed so casing does not fork the id - formId keeps two different forms in the same hour from colliding - the hour bucket is only the server's fallback; the id computed here is sent to the relay verbatim, so the two paths cannot disagree even when a submit straddles an hour boundary Ads Manager's own sample omits event_id. We send it so the pixel event and the relay event collapse into one conversion instead of counting twice. ------------------------------------------------------------------------ */ function buildEventId(email, formId) { var seed = [ String(email || "").trim().toLowerCase(), String(formId || ""), new Date().toISOString().slice(0, 13) ].join("|"); return crypto.subtle.digest("SHA-256", new TextEncoder().encode(seed)).then(function (buf) { var hex = Array.prototype.map.call(new Uint8Array(buf), function (b) { return b.toString(16).padStart(2, "0"); }).join(""); return "lead_" + hex.slice(0, 20); }); } /* Guards a re-fire from the history.pushState that book-a-demo-core.js runs after submit, and from back-navigation onto the same state. */ function firedOnce(id) { try { if (sessionStorage.getItem("oaiq_" + id)) return true; sessionStorage.setItem("oaiq_" + id, "1"); return false; } catch (e) { return false; /* private mode / blocked storage: fire rather than lose it */ } } /* Path 2. keepalive lets the request finish even if the page navigates away. Failures are swallowed deliberately: a reporting call must never surface an error to someone who just submitted a form. The relay hashes the email server-side into user.emails_sha256 and adds the caller's IP and user agent, so this event carries the same match signals the pixel gets for free. */ function sendToRelay(email, formId, eventType, eventId) { try { var payload = { token: RELAY_TOKEN, email: email, form_id: formId || "", event_type: eventType, event_id: eventId, /* verbatim, so both paths always match */ source_url: location.href }; var oppref = getOppref(); if (oppref) payload.oppref = oppref; var externalId = getExternalId(); if (externalId) payload.external_id = externalId; fetch(RELAY_URL, { method: "POST", keepalive: true, headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }).catch(function () {}); } catch (e) {} } /* Capture the email while the form is still on the page. onFormSubmitted does not reliably carry submissionValues, and by then the form may be removed. */ var lastEmail = ""; document.addEventListener("submit", function (e) { var f = e.target; if (!f || !f.querySelector) return; var i = f.querySelector('input[name="email"], input[type="email"]'); if (i && i.value) lastEmail = i.value; }, true); /* Every HubSpot form on the site reports through this one message event, so no module or theme JS needs editing to add coverage. */ window.addEventListener("message", function (e) { var d = e.data; if (!d || d.type !== "hsFormCallback" || d.eventName !== "onFormSubmitted") return; var email = (d.data && d.data.submissionValues && d.data.submissionValues.email) || lastEmail; if (!email) return; /* without an email there is no stable id to dedupe on */ var eventType = EVENT_BY_FORM[d.id] || EVENT_DEFAULT; buildEventId(email, d.id).then(function (id) { if (firedOnce(id)) return; oaiq("measure", eventType, { type: DATA_TYPE }, { event_id: id }); /* path 1 */ sendToRelay(email, d.id, eventType, id); /* path 2 */ }); }); })();

91ÊÓÆµ

See where your logistics stands on automation Start assessment

Home > Blog >

How We Found and Fixed a Recursive Deadlock in OpenTelemetry's Python SDK

How We Found and Fixed a Recursive Deadlock in OpenTelemetry's Python SDK

Deepanshu Kartikey
By Deepanshu Kartikey

Ready to ship smarter? Talk to our logistics experts.

Trusted by 600+ brands

In this blog

    TL;DR

    While integrating OpenTelemetry's logging pipeline into our production environment, we discovered a recursive deadlock in the OTel Python SDK that silently freezes all worker threads. We traced it using py-spy thread dumps, identified the exact line of code causing the recursion, and contributed a fix upstream. The PR was merged with 3 approvals from OTel maintainers and the fix will ship in the next release of opentelemetry-instrumentation-logging.

    The Setup

    We were rolling out OpenTelemetry for centralized log collection and trace correlation across our platform. The integration involved attaching OTel's LoggingHandler to our application's logging pipeline:

    � Python

    This meant every logging.info(), logging.debug(), or logging.warning() call would now pass through OTel's LoggingHandler before being emitted.

    The change looked completely harmless. It passed code review. It worked in local testing. But under production load with real data, it triggered a latent bug in the OTel SDK.

    The Symptoms

    After deploying the change, we noticed something strange in our production environment. Gunicorn worker threads were becoming unresponsive. They were neither crashing, nor throwing errors, but silently freezing. Requests would time out, and the frozen state would persist until workers were manually restarted.

    At first, we suspected the usual culprits:

     

    • Slow database queries - nothing unusual in our monitoring

    • Resource exhaustion - CPU and memory looked normal

    • Thread pool saturation - but the threads weren't busy, they were stuck

    Nothing in our standard monitoring explained why every single thread was frozen simultaneously. A slow query would affect one thread, not all 20 at once.

    That's when we reached for py-spy.

    The Breakthrough - py-spy Thread Dump

    is a sampling profiler that can attach to a running Python process and dump the call stack of every thread, without modifying the application, without any overhead.

    � Python

    What we saw immediately explained everything.

    Across all 4 Gunicorn workers (each running 5 threads), every single request-handling thread was stuck at the exact same line:

    � Python

    20 threads. All frozen. All at acquire (logging/__init__.py:927). Waiting to acquire a logging handler lock that would never be released.

    This wasn't a slow query. This was a deadlock.

    The Deadlock - How It Works

    The OpenTelemetry Python SDK has a known recursive logging deadlock bug (,,).

    Here's exactly what happens inside a single request thread:

    deadlock-diagram-hires

    Step 1: Our application code calls logging.info("fetching counter data")

    Step 2: Python's logging framework picks up this log, walks the handler chain, and when it reaches OTel's LoggingHandler, it acquires the handler lock before calling emit()

    Step 3: OTel's emit() calls _translate() to convert the Python log record into OTel format. During translation, it creates BoundedAttributes and calls _clean_extended_attribute() to validate each attribute

    Step 4 — THE BUG: Here's the exact function from the OTel SDK (opentelemetry/attributes/__init__.py):

    � Python

    Our py-spy stack trace pointed directly at line 209, not line 203. This told us the key was valid but the value was the problem. _clean_extended_attribute_value() handles None, primitives (str, int, float, bool), dicts, and lists safely. But for any other type — a datetime object, a set, a custom class instance like Django's WSGIRequest — it raises a TypeError. Django, Sentry, and Celery inject these non-standard types as log record attributes. The except block on line 208 catches the TypeError, and line 209 logs a warning about it — which is what triggers the recursive re-entry.

    Step 5: That _logger.warning() on line 209 is itself a log call. _logger is a module-level logger that propagates to the root logger, which has our OTel LoggingHandler attached. So the warning re-enters the Python logging pipeline, walks the handler chain again, and tries to acquire the same handler lock

    Step 6: But that lock is already held by Step 2 — by the same thread. The thread is now waiting for itself

    Step 7: DEADLOCK. The lock is never released. This thread is frozen forever

    The Cascade

    Once one thread deadlocks, the logging handler lock is permanently held. Every other thread that tries to log; request threads, health check threads, background heartbeat threads; they all queue up behind the same lock and block indefinitely.

    With a Gunicorn setup of 4 workers × 5 threads = 20 request threads, all 20 freeze. Zero requests can be served. The system is completely stuck until workers are restarted and even then, the deadlock recurs as soon as a log record with a non-primitive attribute is processed.

    The Fix - Contributing to OpenTelemetry

    After reverting the configuration to stabilize our environment, we turned to the real problem: this was a bug in the OTel SDK itself. Multiple GitHub issues documented it (#3858, #3560, #4585), but no fix had been merged into the logging handler.

    We opened PR on the opentelemetry-python-contrib repository.

    The PR Journey - 4 Rounds of Review

    What started as a straightforward fix evolved through 4 rounds of reviewer feedback from OTel maintainers and contributors. Each round caught something we'd missed.

    Round 1: threading.local()

    Our initial fix used a threading.local() guard to detect re-entrant calls:

    � Python

    Reviewer feedback (@DylanRussell): threading.local() doesn't handle async properly. In async frameworks like FastAPI, multiple coroutines share a single thread. threading.local() would let one coroutine's guard block another coroutine on the same thread.

    Round 2: ContextVar + safe logger

    Switched to ContextVar which isolates per async task, not just per thread. Also added a _propagate_false_logger — a dedicated logger with propagate=False for the recursion warning. Without this, the warning itself would propagate to the root logger, potentially hitting the OTel handler again.

    Round 3: Naming

    Reviewer feedback (@pmcollins): Renamed _emit_context to _is_emitting for clarity.

    Round 4: The lastResort edge case

    Reviewer feedback (@herin049): If a user overrides logging.lastResort to point to the OTel handler, our warning logger (with no handlers of its own) would fall back to lastResort and deadlock again. The fix: rename to _internal_logger and add an explicit StreamHandler, so it never consults lastResort.

    The Final Fix

    � Python

    How it works: Before processing a log record, check if we're already inside emit() on this execution context. If yes, skip the recursive call and log a warning to a safe internal logger (with propagate=False and its own StreamHandler, so it can never reach the OTel handler). The finally block ensures the guard always resets.

     

    Round

    Feedback

    Change

    1

    threading.local() doesn't handle async

    Switched to ContextVar

    2

    Need a safe logger for recursion warning

    Added _propagate_false_logger with propagate=False

    3

    Better naming

    Renamed _emit_context → _is_emitting

    4

    User could override logging.lastResort

    Added explicit StreamHandler to _internal_logger

    The Merge

    The PR received 3 approvals from reviewers with write access, passed 784 CI checks across Python 3.9 through 3.14, and was merged into the main branch.

    Status: ✅ Merged

    The fix will ship in the next release of opentelemetry-instrumentation-logging and will protect every Python application using OTel's logging integration from this deadlock.

    Lessons Learned

    py-spy is invaluable for production debugging

    Without the thread dump, we would have kept chasing the wrong hypothesis. The dump made the problem immediately visible. Every thread frozen at the same lock, the recursive call chain clear as day.

    � Python

    Keep py-spy in your production toolkit. When threads are stuck, it shows you exactly where.

    Don't assume. Look at the threads

    We initially suspected slow queries or resource exhaustion. The real problem was a deadlock, something no amount of scaling would fix. When your system freezes simultaneously across all threads, it's almost never a slow endpoint. It's a shared resource (lock, connection pool, file descriptor) that's poisoned.

    Logging is not free

    We tend to think of logging.info() as a no-op, but it acquires locks, traverses handler chains, and can trigger arbitrary code in handlers. In a threaded server, the logging lock becomes a global chokepoint. Any handler that does non-trivial work inside emit() is a potential bottleneck — or in this case, a deadlock.

    Review feedback makes code better

    Our initial fix used threading.local() and would have broken in async contexts. It also missed the logging.lastResort edge case. Four rounds of review from experienced maintainers turned a working fix into a robust one that handles edge cases we hadn't considered.

    If you fix a bug in a library, contribute it back

    We hit a known bug, understood it deeply through debugging, and wrote a fix with good tests. The production evidence; py-spy dumps, exact reproduction steps, thread analysis; made our PR credible. The open source community benefits, and so does every team that would have hit the same issue.

    References

     

    • PR:

    • OTel Issue #3858:

    • OTel Issue #3560:

    • OTel Issue #4585:

    • py-spy:


    We discovered this bug while rolling out OTel logging in production, traced it with a single py-spy command, and contributed a recursion guard that will prevent the same deadlock for every Python application using OpenTelemetry's logging integration.

    If you're using OTel's LoggingHandler with Python, upgrade to the latest opentelemetry-instrumentation-logging once this fix is released.

    The Logistics Automation Platform for E-commerce

    Momentum Leader - G2 Users Most Likely To Recommend - G2 Best Results Mid-Market - G2
    . window.disableHubSpotCookieBanner = true; function killNative() { var n = document.getElementById("hs-eu-cookie-confirmation"); if (n && n.parentNode) { n.parentNode.removeChild(n); return true; } return false; } killNative(); if (window.MutationObserver) { var mo = new MutationObserver(function () { killNative(); }); mo.observe(document.documentElement, { childList: true, subtree: true }); setTimeout(function () { mo.disconnect(); }, 15000); } var COOKIE = "cp_cookie_consent"; var EU = ["at","be","bg","hr","cy","cz","dk","ee","fi","fr","de","gr","hu","ie","it","lv","lt","lu","mt","nl","pl","pt","ro","sk","si","es","se","gb","uk","is","li","no","ch"]; function readCookie(nm) { var m = document.cookie.match(new RegExp("(?:^|; )" + nm + "=([^;]*)")); return m ? decodeURIComponent(m[1]) : null; } function writeCookie(nm, val, days) { var d = new Date(); d.setTime(d.getTime() + (days * 864e5)); document.cookie = nm + "=" + encodeURIComponent(val) + ";expires=" + d.toUTCString() + ";path=/;SameSite=Lax" + (location.protocol === "https:" ? ";Secure" : ""); } function stored() { var raw = readCookie(COOKIE); if (!raw) return null; try { var o = JSON.parse(raw); if (typeof o.analytics === "boolean") return o; } catch (e) {} return null; } function apply(state, revoke) { window._hsp = window._hsp || []; window._hsp.push(["setHubSpotConsent", { analytics: !!state.analytics, advertisement: !!state.advertisement, functionality: !!state.functionality }]); if (revoke && !state.analytics && !state.advertisement && !state.functionality) { window._hsp.push(["revokeCookieConsent"]); } window.dataLayer = window.dataLayer || []; window.dataLayer.push({ event: "cp_cookie_consent", cp_consent_analytics: !!state.analytics, cp_consent_advertisement: !!state.advertisement, cp_consent_functionality: !!state.functionality }); document.documentElement.setAttribute("data-cp-consent", "set"); } function inScope(banner) { if (banner.getAttribute("data-scope") !== "eu_only") return true; var geo = (document.body.getAttribute("data-geo_location") || "").toLowerCase(); if (!geo) return true; return EU.indexOf(geo) !== -1; } function init(banner) { var days = parseInt(banner.getAttribute("data-days"), 10) || 180; var prefs = banner.querySelector("[data-cpcb-prefs]"); var compact = banner.querySelector('[data-cpcb-actions="compact"]'); var expanded = banner.querySelector('[data-cpcb-actions="expanded"]'); var toggles = banner.querySelectorAll("[data-cpcb-toggle]"); function show() { banner.hidden = false; requestAnimationFrame(function () { banner.classList.add("is-visible"); }); } function hide() { banner.classList.remove("is-visible"); setTimeout(function () { banner.hidden = true; }, 300); } function collapse() { if (!prefs) return; prefs.hidden = true; if (expanded) expanded.hidden = true; if (compact) compact.hidden = false; } function expand() { if (!prefs) return; prefs.hidden = false; if (compact) compact.hidden = true; if (expanded) expanded.hidden = false; } function paint(state) { Array.prototype.forEach.call(toggles, function (t) { var key = t.getAttribute("data-cpcb-toggle"); if (key === "necessary") { t.setAttribute("aria-checked", "true"); return; } t.setAttribute("aria-checked", state[key] ? "true" : "false"); }); } function fromToggles() { var state = { analytics: false, advertisement: false, functionality: false }; Array.prototype.forEach.call(toggles, function (t) { var key = t.getAttribute("data-cpcb-toggle"); if (key in state) state[key] = t.getAttribute("aria-checked") === "true"; }); return state; } function commit(state, revoke) { writeCookie(COOKIE, JSON.stringify({ analytics: !!state.analytics, advertisement: !!state.advertisement, functionality: !!state.functionality, v: 1, ts: Date.now() }), days); apply(state, revoke); hide(); } Array.prototype.forEach.call(toggles, function (t) { if (t.disabled) return; t.addEventListener("click", function () { t.setAttribute("aria-checked", t.getAttribute("aria-checked") === "true" ? "false" : "true"); }); }); banner.addEventListener("click", function (e) { if (!e.target || !e.target.closest) return; var btn = e.target.closest("[data-cpcb-action]"); if (!btn) return; var action = btn.getAttribute("data-cpcb-action"); if (action === "customise") { expand(); return; } if (action === "accept") { commit({ analytics: true, advertisement: true, functionality: true }, false); return; } if (action === "reject") { commit({ analytics: false, advertisement: false, functionality: false }, true); return; } if (action === "save") { commit(fromToggles(), true); return; } }); var existing = stored(); if (existing) { apply(existing, false); paint(existing); collapse(); } else if (!inScope(banner)) { // Out of the consent-required region: implied consent, banner never shown. commitSilently(); } else { collapse(); show(); } function commitSilently() { var all = { analytics: true, advertisement: true, functionality: true }; writeCookie(COOKIE, JSON.stringify({ analytics: true, advertisement: true, functionality: true, v: 1, ts: Date.now(), implied: true }), days); apply(all, false); } // Public re-open hook for a footer "Cookie settings" link. window.cpCookieBanner = window.cpCookieBanner || {}; window.cpCookieBanner.open = function () { var s = stored() || { analytics: false, advertisement: false, functionality: false }; paint(s); expand(); show(); }; } function boot() { var banners = document.querySelectorAll("[data-cpcb]"); if (!banners.length) return; init(banners[0]); document.addEventListener("click", function (e) { if (!e.target || !e.target.closest) return; var trigger = e.target.closest("[data-cp-cookie-settings]"); if (!trigger) return; e.preventDefault(); if (window.cpCookieBanner && window.cpCookieBanner.open) window.cpCookieBanner.open(); }); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", boot); } else { boot(); } })();