-
Notifications
You must be signed in to change notification settings - Fork 709
UN-2123 [FEAT] Propagate request_id across services and workers #2229
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Deepak-Kesavan
wants to merge
8
commits into
main
Choose a base branch
from
UN-2123-propagate-request-id
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
e217f4d
UN-2123 [FEAT] Propagate request_id across services and workers
Deepak-Kesavan 2fd885a
UN-2123 [FIX] Address PR review: guard x2text request_id filter with …
Deepak-Kesavan 713f9a4
UN-2123 [FIX] Address review: honor incoming header, gate re-propagat…
Deepak-Kesavan 9bd918a
UN-2123 [FIX] Address PR review: add request_id correlation tests
Deepak-Kesavan 858fac1
UN-2123 [FIX] Address PR review: keep code comments rot-resistant
Deepak-Kesavan 272d66d
UN-2123 [FIX] Self-review: validate incoming X-Request-ID, cover the …
Deepak-Kesavan 52ed22a
UN-2123 [FIX] Provision the request_id in the backend instead of trus…
Deepak-Kesavan fc9ba72
UN-2123 [FIX] Expose X-Request-ID to the browser via CORS
Deepak-Kesavan File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| """Celery signal handlers carrying the HTTP ``request_id`` onto published tasks. | ||
|
|
||
| The id travels in the task message headers under ``Common.REQUEST_ID``. Hooking | ||
| ``before_task_publish`` rather than each producer is deliberate: it covers every | ||
| ``send_task`` / ``.delay`` / ``.apply_async`` with no per-call-site change. | ||
| """ | ||
|
|
||
| import logging | ||
|
|
||
| from account_v2.constants import Common | ||
| from celery.signals import before_task_publish, task_postrun, task_prerun | ||
| from log_request_id import local as log_request_id_local | ||
| from utils.local_context import StateStore | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| @before_task_publish.connect | ||
| def propagate_request_id(headers=None, **kwargs): | ||
| """Inject the current request_id into the outgoing task's message headers. | ||
|
|
||
| Relies on firing in the *producer* thread, where ``StateStore`` still holds | ||
| the id set by ``CustomRequestIDMiddleware``. No-ops without one (e.g. beat | ||
| publishes), leaving the worker to derive its own correlation id. | ||
| """ | ||
| if headers is None: | ||
| return | ||
| try: | ||
| request_id = StateStore.get(Common.REQUEST_ID) | ||
| except Exception: | ||
| # StateStore can raise if CONCURRENCY_MODE is misconfigured; never let | ||
| # correlation plumbing break task publishing. | ||
| logger.debug("Unable to read request_id from StateStore", exc_info=True) | ||
| return | ||
| if request_id and not headers.get(Common.REQUEST_ID): | ||
| headers[Common.REQUEST_ID] = request_id | ||
|
|
||
|
|
||
| def _request_id_from_task(task) -> str | None: | ||
| request = getattr(task, "request", None) | ||
| if request is None: | ||
| return None | ||
| request_id = getattr(request, Common.REQUEST_ID, None) | ||
| if not request_id: | ||
| task_headers = getattr(request, "headers", None) | ||
| if isinstance(task_headers, dict): | ||
| request_id = task_headers.get(Common.REQUEST_ID) | ||
| return request_id or None | ||
|
|
||
|
|
||
| @task_prerun.connect | ||
| def bind_request_id(task=None, **kwargs): | ||
| """Bind the propagated request_id for tasks run by the backend's own workers. | ||
|
|
||
| Two writes, both required: ``log_request_id``'s thread-local is the only | ||
| thing ``log_request_id.filters.RequestIDFilter`` reads (the HTTP middleware | ||
| is otherwise its sole writer, so a task would log ``request_id:-``), and | ||
| ``StateStore`` is what ``propagate_request_id`` reads, so tasks this worker | ||
| itself publishes carry the id onward. | ||
| """ | ||
| request_id = _request_id_from_task(task) | ||
| if not request_id: | ||
| return | ||
| log_request_id_local.request_id = request_id | ||
| try: | ||
| StateStore.set(Common.REQUEST_ID, request_id) | ||
| except Exception: | ||
| logger.debug("Unable to set request_id on StateStore", exc_info=True) | ||
|
|
||
|
|
||
| @task_postrun.connect | ||
| def clear_request_id(**kwargs): | ||
| """Clear the task-scoped request_id -- worker threads are pooled and reused.""" | ||
| if hasattr(log_request_id_local, "request_id"): | ||
| try: | ||
| del log_request_id_local.request_id | ||
| except AttributeError: | ||
| pass | ||
| try: | ||
| StateStore.clear(Common.REQUEST_ID) | ||
| except Exception: | ||
| logger.debug("Unable to clear request_id from StateStore", exc_info=True) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,161 @@ | ||
| """Unit checks for the backend's Celery request_id signal handlers. | ||
|
|
||
| These are the producer half of the correlation chain: the HTTP request binds an | ||
| id (see ``middleware/test_request_id.py``), and these handlers carry it onto | ||
| every task the backend publishes and back off the message for tasks the | ||
| backend's *own* Celery workers run. | ||
|
|
||
| The handlers are written to fail open -- correlation plumbing must never break | ||
| task publishing -- which means a regression here is silent by construction: the | ||
| header simply stops being set and every worker log line reverts to | ||
| ``request_id:-``. Nothing raises, so only assertions catch it. | ||
|
|
||
| Pure-logic: ``StateStore`` is a thread-local and the signals are called | ||
| directly, so no broker, worker or DB is involved. | ||
| """ | ||
|
|
||
| import pytest | ||
| from account_v2.constants import Common | ||
| from log_request_id import local as log_request_id_local | ||
| from utils.local_context import StateStore | ||
|
|
||
| from backend.celery_signals import ( | ||
| bind_request_id, | ||
| clear_request_id, | ||
| propagate_request_id, | ||
| ) | ||
|
|
||
| REQUEST_ID = "6f1c2d3e-4a5b-4c6d-8e9f-0a1b2c3d4e5f" | ||
|
|
||
|
|
||
| class _Request: | ||
| """Stands in for a Celery task ``Context``.""" | ||
|
|
||
| def __init__(self, request_id=None, headers=None): | ||
| if request_id is not None: | ||
| self.request_id = request_id | ||
| self.headers = headers | ||
|
|
||
|
|
||
| class _Task: | ||
| def __init__(self, request=None): | ||
| self.request = request | ||
|
|
||
|
|
||
| @pytest.fixture(autouse=True) | ||
| def _clean_context(): | ||
| """Both stores are thread-local and shared across tests in a worker, so a | ||
| leaked id would make a later test pass for the wrong reason. | ||
| """ | ||
| yield | ||
| for store_key in (Common.REQUEST_ID,): | ||
| try: | ||
| StateStore.clear(store_key) | ||
| except AttributeError: | ||
| pass | ||
| if hasattr(log_request_id_local, "request_id"): | ||
| del log_request_id_local.request_id | ||
|
|
||
|
|
||
| # Producer side: before_task_publish | ||
|
|
||
|
|
||
| def test_publish_injects_request_id_from_state_store(): | ||
| """The id bound by the HTTP middleware rides out on the task message.""" | ||
| StateStore.set(Common.REQUEST_ID, REQUEST_ID) | ||
| headers = {} | ||
|
|
||
| propagate_request_id(headers=headers) | ||
|
|
||
| assert headers[Common.REQUEST_ID] == REQUEST_ID | ||
|
|
||
|
|
||
| def test_publish_is_a_noop_without_a_request_id(): | ||
| """Beat-scheduled publishes have no request in scope; the worker falls back | ||
| to its own execution_id/task_id rather than receiving an empty header. | ||
| """ | ||
| headers = {} | ||
|
|
||
| propagate_request_id(headers=headers) | ||
|
|
||
| assert headers == {} | ||
|
|
||
|
|
||
| def test_publish_does_not_clobber_an_explicit_header(): | ||
| """A caller that set the header deliberately outranks the ambient id.""" | ||
| StateStore.set(Common.REQUEST_ID, REQUEST_ID) | ||
| headers = {Common.REQUEST_ID: "caller-supplied"} | ||
|
|
||
| propagate_request_id(headers=headers) | ||
|
|
||
| assert headers[Common.REQUEST_ID] == "caller-supplied" | ||
|
|
||
|
|
||
| def test_publish_tolerates_missing_headers(): | ||
| """``before_task_publish`` must never raise -- it would break the publish | ||
| itself, taking the actual task down with the correlation plumbing. | ||
| """ | ||
| propagate_request_id(headers=None) # does not raise | ||
|
|
||
|
|
||
| # Consumer side: task_prerun / task_postrun on the backend's own workers | ||
|
|
||
|
|
||
| def test_prerun_binds_id_from_task_attribute(): | ||
| """Celery surfaces the custom header as an attribute on the Context.""" | ||
| bind_request_id(task=_Task(_Request(request_id=REQUEST_ID))) | ||
|
|
||
| assert log_request_id_local.request_id == REQUEST_ID | ||
| assert StateStore.get(Common.REQUEST_ID) == REQUEST_ID | ||
|
|
||
|
|
||
| def test_prerun_falls_back_to_raw_headers_mapping(): | ||
| """Fallback for when Celery leaves the header only in the raw mapping.""" | ||
| bind_request_id(task=_Task(_Request(headers={Common.REQUEST_ID: REQUEST_ID}))) | ||
|
|
||
| assert log_request_id_local.request_id == REQUEST_ID | ||
|
|
||
|
|
||
| def test_prerun_binding_makes_the_id_re_propagate(): | ||
| """Second-order effect that motivated the handler: a task the backend | ||
| worker itself publishes must carry the inherited id onward, or the chain | ||
| dies at the first backend-worker hop. | ||
| """ | ||
| bind_request_id(task=_Task(_Request(request_id=REQUEST_ID))) | ||
| headers = {} | ||
|
|
||
| propagate_request_id(headers=headers) | ||
|
|
||
| assert headers[Common.REQUEST_ID] == REQUEST_ID | ||
|
|
||
|
|
||
| def test_prerun_without_an_id_leaves_stores_untouched(): | ||
| """No header means no correlation to inherit -- and, importantly, no empty | ||
| string bound over whatever the logger would otherwise show. | ||
| """ | ||
| bind_request_id(task=_Task(_Request())) | ||
|
|
||
| assert not hasattr(log_request_id_local, "request_id") | ||
| assert StateStore.get(Common.REQUEST_ID) is None | ||
|
|
||
|
|
||
| def test_prerun_tolerates_a_task_without_a_request(): | ||
| bind_request_id(task=_Task(request=None)) # does not raise | ||
|
|
||
| assert not hasattr(log_request_id_local, "request_id") | ||
|
|
||
|
|
||
| def test_postrun_clears_the_bound_id(): | ||
| """Celery reuses worker threads; a surviving id would mislabel the *next* | ||
| task's logs with the previous task's correlation id. | ||
| """ | ||
| bind_request_id(task=_Task(_Request(request_id=REQUEST_ID))) | ||
|
|
||
| clear_request_id() | ||
|
|
||
| assert not hasattr(log_request_id_local, "request_id") | ||
| assert StateStore.get(Common.REQUEST_ID) is None | ||
|
|
||
|
|
||
| def test_postrun_is_safe_when_nothing_was_bound(): | ||
| clear_request_id() # does not raise |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.