A Symfony 7 bundle that stamps every inbound HTTP request with a UUIDv7 X-Request-Id, mirrors it onto every Monolog log record's extra.request_id, echoes it back on the response, and rides through Symfony Messenger so a worker logging a handler ends up with the same id the originating HTTP request held.
amashukov/tracing-bundle is a vendor-extractable Symfony 7 bundle for end-to-end request_id propagation. Doing this once at the right layer beats bolting it on per-call: the bundle ships the kernel listener, the resolver, the Monolog processor, the Messenger stamp, the consume-side context restorer, validation at the trust boundary, and the CLI fallback so every console run / cron / worker logs {"request_id":"..."} instead of an empty extra block. Drop it into a Symfony 7 project, get the same id flowing through the FE fetch, the controller, every log line on every channel, the response header, and the queue-bound worker that picks the message up minutes later — with zero App\* namespace coupling.
FE fetch ─┐ ┌─► response with X-Request-Id
│ │
▼ │
┌──────────────────────────────────────────────┴───┐
│ Symfony kernel │
│ ├─ RequestIdListener (kernel.request +256) │
│ ├─ RequestIdResolver (DI service) │
│ ├─ RequestIdProcessor (monolog.processor) │
│ └─ ...handlers... │
└──────────────┬───────────────────────────────────┘
│ $bus->dispatch($message)
▼
envelope + RequestIdStamp ─► queue ─► worker
│
▼
RequestIdMessengerMiddleware
└─► WorkerRequestIdContext
└─► RequestIdResolver
└─► same UUIDv7 in handler logs
symfony/uid when the incoming X-Request-Id is missing or malformed; valid inbound ids are lowercased and forwarded unchanged.kernel.request (priority 256) writes the id onto the Request attribute; kernel.response (priority -256) mirrors it onto the response header. Main-request only; sub-requests inherit the parent id naturally.[a-f0-9-]. Prevents log-injection (SQL fragments, control characters, oversized payloads) from contaminating log files and aggregator search.extra.request_id attached to every LogRecord (app, doctrine, security, messenger, ...) with pre-existing extras preserved.RequestIdStamp to outbound envelopes; consume-side restores the id into WorkerRequestIdContext before the handler runs, clears in finally so message N+1 starts clean.{"request_id":"cli"} instead of an empty extra block — log-aggregator queries stay consistent regardless of execution mode.final readonly services — narrow contracts, immutable wiring, autowired by default.App\* coupling — bundle depends only on monolog/monolog + symfony/*. Drop into any project without renaming.composer require amashukov/tracing-bundle
Symfony Flex registers the bundle automatically. If you don't run Flex, add it manually:
// config/bundles.php
use Amashukov\TracingBundle\TracingBundle;
return [
TracingBundle::class => ['all' => true],
];
symfony/uid ≥ 7.0)monolog/monolog ^3.0symfony/* ^7.0 (config, dependency-injection, event-dispatcher, http-foundation, http-kernel, uid, yaml)symfony/messenger ^7.0 — soft suggest. The middleware class only loads when Messenger is installed; non-Messenger projects pay zero overhead.After install — no further config. Every request automatically:
request_id attribute on the Request.extra.request_id in every Monolog record.X-Request-Id on the response.use Amashukov\TracingBundle\Http\RequestIdResolverInterface;
final readonly class MyService
{
public function __construct(private RequestIdResolverInterface $requestId) {}
public function someMethod(): void
{
$current = $this->requestId->current(); // UUIDv7 string or 'cli'
}
}
const requestId = crypto.randomUUID();
const api = $fetch.create({
onRequest: ({ options }) => {
options.headers = { ...options.headers, 'X-Request-Id': requestId };
},
});
export const test = base.extend<{ testRequestId: string }>({
testRequestId: [async ({ browser: _ }, use, testInfo) => {
const requestId = crypto.randomUUID();
process.stdout.write(`[TEST-REQ-ID] ${testInfo.title} -> ${requestId}\n`);
await use(requestId);
}, { auto: true }],
page: async ({ context, page, testRequestId }, use) => {
await context.setExtraHTTPHeaders({ 'X-Request-Id': testRequestId });
await use(page);
},
});
Then debug any failing run:
grep "<test-uuid>" backend/var/log/app.log
Every BE event scoped to that one test, no cross-spec noise.
If the FE talks to a different origin, allow the header on both directions:
# RoadRunner .rr.yaml — or your CORS layer of choice
http:
middleware:
- headers
headers:
cors:
allowed_headers: "...,X-Request-Id"
exposed_headers: "...,X-Request-Id"
When a Messenger message crosses the sync → queue boundary, the worker process has no RequestStack. The bundle's middleware closes that gap.
Dispatch side (HTTP request handler):
$bus->dispatch(new MyMessage(...));
// Envelope leaves the dispatcher with [RequestIdStamp('01923e1c-...')] attached.
Consume side (worker process):
- Worker pulls the message; ReceivedStamp lands on the envelope.
- Middleware sees ReceivedStamp + RequestIdStamp -> WorkerContext::setRequestId(...).
- Handler runs; any Monolog log inside it gets extra.request_id == '01923e1c-...'.
- Middleware's finally clause clears the context so message N+1 starts fresh.
Per W3C Trace Context spec Non-HTTP Protocol Support and the Symfony Messenger official middleware pattern ($envelope->last(ReceivedStamp::class) discriminates dispatch vs consume).
| Class | What it does |
|---|---|
Http\RequestIdListener |
kernel.request (priority 256) reads X-Request-Id, validates as 36-char hex UUID, generates UUIDv7 via symfony/uid when missing or malformed. kernel.response (priority -256) mirrors the id onto the response header. Main-request only. |
Http\RequestIdResolverInterface |
Narrow contract current(): string. Services depend on the interface; the bundle wires the alias. |
Http\RequestIdResolver |
final readonly implementation. Reads request_id off the main request first, falls back to WorkerRequestIdContext (when running inside Messenger), then to the cli constant. |
Monolog\RequestIdProcessor |
Tagged monolog.processor. Attaches extra.request_id to every LogRecord on every channel. Pre-existing extra keys preserved. |
Messenger\RequestIdStamp |
Immutable StampInterface value object carrying one string (the originating request's id). |
Messenger\RequestIdMessengerMiddleware |
Dual-path Messenger middleware. Dispatch: attaches new RequestIdStamp($resolver->current()) to the envelope if not already present. Consume (ReceivedStamp present): reads the stamp, writes the id to WorkerRequestIdContext before calling the next middleware, clears in finally. |
Messenger\WorkerRequestIdContext |
Single-cell mutable state holder for the worker's current message id. Read by RequestIdResolver::current() when there is no HTTP request. |
Incoming X-Request-Id is rejected when:
[a-f0-9-]Rejected → bundle generates a fresh UUIDv7.
In CLI context (no Request on RequestStack, no WorkerRequestIdContext value set), the resolver returns the literal cli. Every console command / one-shot cron task logs {"request_id":"cli"} instead of an empty extra block — log-aggregator queries stay consistent regardless of execution mode.
traceparent)This bundle deliberately implements the X-Request-Id header pattern only (Heroku / Cloudflare CF-Ray style). For W3C Trace Context (traceparent / OpenTelemetry alignment) pair this bundle with the official open-telemetry/opentelemetry-php-instrumentation-symfony — the two are complementary, not alternatives.
composer install
composer test
composer cs
composer stan
composer rector
Suite covers: valid UUIDv7 accept, mixed-case header lowercased, five invalid-header regen cases (missing, too short, too long, wrong charset, SQL-injection-looking string), response header mirror, sub-request skip, subscribed-events shape, CLI fallback (no request / no attribute / non-string attribute), extra.request_id attach with pre-existing extras preserved, Messenger stamp value semantics, worker-context set/get/clear, middleware dispatch path, middleware consume path, middleware finally clears between messages.
MIT — see LICENSE.
Andrei Mashukov — a.mashukoff@gmail.com
How can I help you explore Laravel packages today?