-
Notifications
You must be signed in to change notification settings - Fork 703
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
base: main
Are you sure you want to change the base?
Changes from all commits
e217f4d
2fd885a
713f9a4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| """Celery signal handlers for the backend (producer side). | ||
|
|
||
| Propagates the HTTP ``request_id`` (correlation ID assigned by | ||
| ``CustomRequestIDMiddleware``) onto every published Celery task so that worker | ||
| logs can be correlated back to the originating request. | ||
|
|
||
| The value is placed in the task message headers under ``request_id``. Workers | ||
| read it from ``task.request`` in ``task_prerun`` and bind it onto their log | ||
| context -- see ``workers/shared/infrastructure/logging/logger.py``. Using the | ||
| ``before_task_publish`` signal means this works for *every* ``send_task`` / | ||
| ``.delay`` / ``.apply_async`` call with no per-call-site changes. | ||
| """ | ||
|
|
||
| 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. | ||
|
|
||
| Fires in the producer thread (the web request thread for API-triggered | ||
| tasks), where ``StateStore`` still holds the request_id set by | ||
| ``CustomRequestIDMiddleware``. No-ops when there is no request_id in scope | ||
| (e.g. beat-scheduled publishes), leaving the worker to fall back to its | ||
| own correlation id (execution_id / task_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: | ||
| """Read a propagated request_id off a Celery task's message context.""" | ||
| 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 executed by the backend's OWN | ||
| Celery workers (beat, dashboard-metric tasks, etc.). | ||
|
|
||
| The separate ``workers/`` fleet has its own ``task_prerun`` reader; the | ||
| backend Celery app previously injected the header (``propagate_request_id``) | ||
| but never consumed it, so backend-executed tasks logged ``request_id:-``. | ||
| Binding it onto ``log_request_id``'s thread-local makes | ||
| ``log_request_id.filters.RequestIDFilter`` emit it, and onto ``StateStore`` | ||
| so any task this worker itself publishes re-propagates it. | ||
| """ | ||
| 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 bound in ``bind_request_id``.""" | ||
| 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) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,6 +32,20 @@ | |
| APPLICATION_JSON = "application/json" | ||
|
|
||
|
|
||
| def _current_request_id() -> str | None: | ||
| """Return the request_id bound on the current worker log context, if any. | ||
|
|
||
| Bound by the ``task_prerun`` handler in the logging module; used to | ||
| propagate ``X-Request-ID`` onto outbound calls to the backend internal API. | ||
| Returns ``None`` for the ``"-"`` placeholder so no empty header is sent. | ||
| """ | ||
| ctx = WorkerLogger.get_context() | ||
| request_id = getattr(ctx, "request_id", None) if ctx else None | ||
| if not request_id or request_id == "-": | ||
| return None | ||
| return request_id | ||
|
|
||
|
|
||
| # Single PG-queue rollout flag (same key as pg_queue.flags / executor_rpc). | ||
| _PG_QUEUE_FLAG_KEY = "pg_queue_enabled" | ||
|
|
||
|
|
@@ -316,6 +330,12 @@ def _make_request( | |
| if current_org_id: | ||
| headers["X-Organization-ID"] = current_org_id | ||
|
|
||
| # Propagate the correlation id back to the backend so worker | ||
| # callbacks share the originating request's request_id in logs. | ||
| request_id = _current_request_id() | ||
| if request_id: | ||
| headers["X-Request-ID"] = request_id | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The worker→backend hop does not actually correlate — the backend drops this header.
# log_request_id/middleware.py::_get_request_id
return request.META.get(request_id_header, default_request_id)The lookup therefore always misses, and because Concrete scenario: a file-processing worker calls the internal API with Fix is one line:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 713f9a4:
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The worker → runner hop stamps a different id, and it isn't in the out-of-scope list. This closes the worker → backend hop, but the worker → runner hop already sends an # unstract/tool-sandbox/src/unstract/tool_sandbox/helper.py:439 (and :592)
headers = {
"X-Request-ID": file_execution_id,
}The runner reads that header via This is called from worker context, where Either close it or add it alongside the SDK1 x2text-adapter gap in the PR's out-of-scope list; right now it reads as covered.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch. |
||
|
|
||
| if headers: | ||
| kwargs["headers"] = headers | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -33,6 +33,12 @@ class LogContext: | |
| organization_id: str | None = None | ||
| correlation_id: str | None = None | ||
| request_id: str | None = None | ||
| # True only when request_id came from an upstream message header (a genuine | ||
| # cross-service correlation id), not a locally-derived payload id or the | ||
| # task_id fallback. Gates worker->worker re-propagation so a fallback id is | ||
| # never stamped onto child tasks (which would override their own | ||
| # file_execution_id correlation). | ||
| request_id_propagatable: bool = False | ||
|
|
||
|
|
||
| class RequestIDFilter(logging.Filter): | ||
|
|
@@ -703,22 +709,82 @@ def _extract_request_id( | |
| return None | ||
|
|
||
|
|
||
| def _request_id_from_message(task: Any) -> str | None: | ||
| """Read an explicit request_id propagated via Celery message headers. | ||
|
|
||
| The task producer (backend ``before_task_publish`` handler, or a worker | ||
| re-publishing a downstream task) injects ``request_id`` into the message | ||
| headers. Celery exposes custom headers on ``task.request`` -- as a direct | ||
| attribute under protocol v2, and via the raw ``headers`` mapping as a | ||
| version-safe fallback. This is the authoritative cross-service correlation | ||
| id and takes precedence over payload-derived ids (file_execution_id, etc.). | ||
| """ | ||
| request = getattr(task, "request", None) | ||
| if request is None: | ||
| return None | ||
| value = getattr(request, "request_id", None) | ||
| if not value: | ||
| headers = getattr(request, "headers", None) | ||
| if isinstance(headers, Mapping): | ||
| value = headers.get("request_id") | ||
| return _coerce_id(value) | ||
|
|
||
|
|
||
| def _bind_task_context(task_id, task, args, kwargs, **_): | ||
| """Celery ``task_prerun`` handler: bind request_id onto the log context. | ||
|
|
||
| Catches any extraction failure so a malformed payload can never leave | ||
| the previous task's id bound on the thread. | ||
| Resolution order: an explicit request_id propagated on the message headers | ||
| (genuine cross-service correlation), then a payload-derived id | ||
| (``_extract_request_id``), then the Celery ``task_id``. Only the first | ||
| (header) source is marked propagatable, so a locally-derived fallback is | ||
| never re-stamped onto child tasks. | ||
|
|
||
| The whole resolution runs inside the ``try`` so a malformed payload -- or a | ||
| surprising ``task.request`` -- can never raise and leave the previous task's | ||
| id bound on the thread. | ||
| """ | ||
| propagatable = False | ||
| try: | ||
| request_id = _extract_request_id(args or (), kwargs or {}, task) or task_id | ||
| request_id = _request_id_from_message(task) | ||
| if request_id: | ||
| propagatable = True | ||
| else: | ||
| request_id = _extract_request_id(args or (), kwargs or {}, task) | ||
| except Exception: | ||
| logging.getLogger(__name__).debug( | ||
| "request_id extraction failed for task %s; falling back to task_id", | ||
| task_id, | ||
| exc_info=True, | ||
| ) | ||
| request_id = task_id | ||
| WorkerLogger.update_context(request_id=request_id, task_id=task_id) | ||
| request_id = None | ||
| request_id = request_id or task_id | ||
| WorkerLogger.update_context( | ||
| request_id=request_id, | ||
| task_id=task_id, | ||
| request_id_propagatable=propagatable, | ||
| ) | ||
|
|
||
|
|
||
| def _propagate_request_id_on_publish(headers=None, **_): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The PG-queue transport bypasses this hop entirely, so correlation breaks there.
Concrete scenario: an API-triggered execution whose task name is in
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed — the PG transport enqueues via |
||
| """Celery ``before_task_publish`` handler (worker side): forward the current | ||
| request_id onto tasks this worker publishes. | ||
|
|
||
| Keeps a genuine cross-service correlation id flowing across worker->worker | ||
| task chains (e.g. a file-processing task enqueuing a callback). Only | ||
| propagates when the current id came from an upstream header | ||
| (``request_id_propagatable``) -- never a locally-derived payload id or the | ||
| ``task_id`` fallback, which would otherwise override the child task's own | ||
| ``file_execution_id`` correlation (e.g. for beat/scheduler-originated | ||
| pipelines). No-ops when absent or when the caller already set the header. | ||
| """ | ||
| if headers is None or headers.get("request_id"): | ||
| return | ||
| ctx = WorkerLogger.get_context() | ||
| if not ctx or not getattr(ctx, "request_id_propagatable", False): | ||
| return | ||
| request_id = _coerce_id(getattr(ctx, "request_id", None)) | ||
| if request_id: | ||
| headers["request_id"] = request_id | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This re-publish handler combined with the new header-first precedence regresses correlation for beat-scheduled pipelines.
Concrete scenario — scheduled ETL/TASK pipelines, the most common production flow:
The PR's risk section says beat-scheduled tasks "fall back to the existing Suggest only propagating an id that is genuinely a cross-service correlation id — e.g. have
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 713f9a4. Added a |
||
|
|
||
|
|
||
| def _clear_task_context(**_): | ||
|
|
@@ -728,7 +794,9 @@ def _clear_task_context(**_): | |
| ``WorkerLogger.configure()``; only nulls out the per-task fields bound | ||
| in ``_bind_task_context``. | ||
| """ | ||
| WorkerLogger.update_context(request_id=None, task_id=None) | ||
| WorkerLogger.update_context( | ||
| request_id=None, task_id=None, request_id_propagatable=False | ||
| ) | ||
|
|
||
|
|
||
| @functools.lru_cache(maxsize=1) | ||
|
|
@@ -739,13 +807,14 @@ def _install_celery_request_id_signals() -> None: | |
| debug log if Celery is not importable (e.g. unit tests). | ||
| """ | ||
| try: | ||
| from celery.signals import task_postrun, task_prerun | ||
| from celery.signals import before_task_publish, task_postrun, task_prerun | ||
| except ImportError as exc: | ||
| logging.getLogger(__name__).debug( | ||
| "celery.signals not importable; request_id signal install skipped: %s", | ||
| exc, | ||
| ) | ||
| return | ||
| before_task_publish.connect(_propagate_request_id_on_publish, weak=False) | ||
| task_prerun.connect(_bind_task_context, weak=False) | ||
| task_postrun.connect(_clear_task_context, weak=False) | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,17 +1,25 @@ | ||
| import logging | ||
| from os import environ as env | ||
|
|
||
| from dotenv import load_dotenv | ||
| from flask import Flask | ||
|
|
||
| from app.controllers import api | ||
| from app.logging_util import register_request_id_middleware, setup_logging | ||
| from app.models import X2TextAudit, be_db | ||
|
|
||
| load_dotenv() | ||
|
|
||
|
|
||
| def create_app() -> Flask: | ||
| log_level = getattr(logging, env.get("LOG_LEVEL", "INFO").upper(), logging.INFO) | ||
| setup_logging(log_level) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The old logging config it replaces is still in the tree. # x2text-service/app/controllers/controller.py:19
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
)That runs at import of Worth deleting as part of this change — it's the line
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 713f9a4 — removed the stale |
||
|
|
||
| app = Flask(__name__) | ||
|
|
||
| # Assign/propagate a request_id (X-Request-ID) for cross-service log correlation. | ||
| register_request_id_middleware(app) | ||
|
|
||
| api_url_prefix = env.get("API_URL_PREFIX", "/api/v1") | ||
| app.register_blueprint(api, url_prefix=api_url_prefix) | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The header is injected for the Django Celery workers too, but nothing there consumes it — and those workers don't re-propagate.
The
workers/package gets its receiving side from_bind_task_context/_propagate_request_id_on_publish, but the backend Celery app has notask_prerunequivalent: its logging useslog_request_id.filters.RequestIDFilter, which readslog_request_id.local.request_id— only ever populated by the HTTP middleware. So for-A backend workerservices (e.g.worker-metricsondashboard_metric_eventsindocker/docker-compose.yaml:49, plus thecelery/celery_api_deployments/celery_periodic_logsqueues) the injected header is carried and then dropped: those task logs still showrequest_id:-.Second-order effect: since
StateStoreis only written byCustomAuthMiddlewareon an HTTP request, this handler is a no-op inside those worker processes, so any task they publish downstream loses the id too — the chain breaks at the first backend-worker hop.Also a doc nit on the docstring above:
StateStore[request_id]is populated byCustomAuthMiddleware(backend/account_v2/custom_auth_middleware.py:26), not byCustomRequestIDMiddleware(which only setsrequest.id). Worth correcting since the distinction matters for which requests actually have an id in scope.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 713f9a4. Added
task_prerun/task_postrunhandlers incelery_signals.pythat bind the injected header ontolog_request_id.local(so the backendRequestIDFilteremits it) and ontoStateStore(so a task the backend worker itself publishes re-propagates). No longer a no-op on that side.