Skip to content

Commit 9177a48

Browse files
committed
Added a coroutine-aware RequestContextScope service that persists request metadata in the current coroutine while providing a synchronous fallback.
Registered the scope in the application container, seeding it for each request, and updated the logging processor to pull request details from the coroutine-local scope. Updated HTTP middleware to read and write request metadata through the new scope and refreshed tests to validate scope-based context resolution.
1 parent e4ef0c0 commit 9177a48

11 files changed

Lines changed: 166 additions & 43 deletions

src/Core/Application.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
use Bamboo\Module\ModuleInterface;
55
use Bamboo\Web\Kernel;
66
use Bamboo\Web\RequestContext;
7+
use Bamboo\Web\RequestContextScope;
78
use Psr\Http\Message\ResponseInterface;
89
use Psr\Http\Message\ServerRequestInterface as Request;
910

@@ -16,6 +17,7 @@ public function __construct(protected Config $config) {
1617
$this->singleton(Config::class, fn() => $config);
1718
$this->singleton('router', fn() => new Router());
1819
$this->singleton(Kernel::class, fn() => new Kernel($this->get(Config::class)));
20+
$this->singleton(RequestContextScope::class, fn() => new RequestContextScope());
1921
$this->bootRoutes();
2022
}
2123
/**
@@ -42,8 +44,7 @@ public function handle(Request $request): ResponseInterface {
4244
$context->merge([
4345
'method' => $request->getMethod(),
4446
]);
45-
$this->instances[RequestContext::class] = $context;
46-
$this->instances['request.context'] = $context;
47+
$this->get(RequestContextScope::class)->set($context);
4748
$router = $this->get('router');
4849
$match = $router->match($request);
4950
$defaultSignature = sprintf('%s %s', $request->getMethod(), $request->getUri()->getPath());

src/Provider/AppProvider.php

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
namespace Bamboo\Provider;
33
use Bamboo\Core\Application;
44
use Bamboo\Web\ProblemDetailsHandler;
5-
use Bamboo\Web\RequestContext;
5+
use Bamboo\Web\RequestContextScope;
66
use Bamboo\Web\View\Engine\TemplateEngineManager;
77
use Monolog\Handler\StreamHandler;
88
use Monolog\Level;
@@ -14,15 +14,20 @@ public function register(Application $app): void {
1414
$log = new \Monolog\Logger('bamboo');
1515
$log->pushHandler(new StreamHandler($app->config('app.log_file'), Level::Debug));
1616
$log->pushProcessor(function(LogRecord $record) use ($app) {
17-
if (!$app->has(RequestContext::class)) {
17+
if (!$app->has(RequestContextScope::class)) {
1818
return $record;
1919
}
20-
$context = $app->get(RequestContext::class)->all();
20+
$scope = $app->get(RequestContextScope::class);
21+
$context = $scope->get();
2122
if (!$context) {
2223
return $record;
2324
}
25+
$values = $context->all();
26+
if ($values === []) {
27+
return $record;
28+
}
2429
$extra = $record->extra;
25-
$extra['request'] = array_merge($extra['request'] ?? [], $context);
30+
$extra['request'] = array_merge($extra['request'] ?? [], $values);
2631
return $record->with(extra: $extra);
2732
});
2833
return $log;

src/Web/Middleware/CircuitBreakerMiddleware.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
use Bamboo\Core\Config;
88
use Bamboo\Observability\Metrics\CircuitBreakerMetrics;
9-
use Bamboo\Web\RequestContext;
9+
use Bamboo\Web\RequestContextScope;
1010
use Bamboo\Web\Resilience\CircuitBreakerRegistry;
1111
use Nyholm\Psr7\Response;
1212
use Psr\Http\Message\ResponseInterface;
@@ -19,7 +19,7 @@ final class CircuitBreakerMiddleware
1919

2020
public function __construct(
2121
private Config $config,
22-
private RequestContext $context,
22+
private RequestContextScope $contextScope,
2323
private CircuitBreakerRegistry $registry,
2424
private CircuitBreakerMetrics $metrics,
2525
?callable $clock = null,
@@ -29,7 +29,8 @@ public function __construct(
2929

3030
public function handle(Request $request, \Closure $next): ResponseInterface
3131
{
32-
$route = $this->context->get('route', sprintf('%s %s', $request->getMethod(), $request->getUri()->getPath()));
32+
$context = $this->contextScope->getOrCreate();
33+
$route = $context->get('route', sprintf('%s %s', $request->getMethod(), $request->getUri()->getPath()));
3334
$method = $request->getMethod();
3435
$settings = $this->resolveSettings($method, $route, $request);
3536

src/Web/Middleware/HttpMetricsCollector.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,21 @@
55
namespace Bamboo\Web\Middleware;
66

77
use Bamboo\Observability\Metrics\HttpMetrics;
8-
use Bamboo\Web\RequestContext;
8+
use Bamboo\Web\RequestContextScope;
99
use Psr\Http\Message\ResponseInterface;
1010
use Psr\Http\Message\ServerRequestInterface as Request;
1111

1212
class HttpMetricsCollector
1313
{
14-
public function __construct(private HttpMetrics $metrics, private RequestContext $context)
14+
public function __construct(private HttpMetrics $metrics, private RequestContextScope $contextScope)
1515
{
1616
}
1717

1818
public function handle(Request $request, \Closure $next): ResponseInterface
1919
{
20+
$context = $this->contextScope->getOrCreate();
2021
$method = $request->getMethod();
21-
$route = $this->context->get('route', sprintf('%s %s', $method, $request->getUri()->getPath()));
22+
$route = $context->get('route', sprintf('%s %s', $method, $request->getUri()->getPath()));
2223

2324
$this->metrics->incrementInFlight($method, $route);
2425
$timer = $this->metrics->startTimer($method, $route);

src/Web/Middleware/RequestId.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
<?php
22
namespace Bamboo\Web\Middleware;
33

4-
use Bamboo\Web\RequestContext;
4+
use Bamboo\Web\RequestContextScope;
55
use Psr\Http\Message\ServerRequestInterface as Request;
66

77
class RequestId {
8-
public function __construct(private RequestContext $context) {}
8+
public function __construct(private RequestContextScope $contextScope) {}
99
public function handle(Request $request, \Closure $next) {
1010
$requestId = trim($request->getHeaderLine('X-Request-ID'));
1111
if ($requestId === '') { $requestId = $this->generateUuid(); }
12-
$this->context->merge([
12+
$context = $this->contextScope->getOrCreate();
13+
$context->merge([
1314
'id' => $requestId,
1415
'method' => $request->getMethod(),
1516
'route' => sprintf('%s %s', $request->getMethod(), $request->getRequestTarget()),

src/Web/Middleware/TimeoutMiddleware.php

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
use Bamboo\Core\Config;
88
use Bamboo\Observability\Metrics\HttpMetrics;
9-
use Bamboo\Web\RequestContext;
9+
use Bamboo\Web\RequestContextScope;
1010
use Nyholm\Psr7\Response;
1111
use Psr\Http\Message\ResponseInterface;
1212
use Psr\Http\Message\ServerRequestInterface as Request;
@@ -15,14 +15,15 @@ final class TimeoutMiddleware
1515
{
1616
public function __construct(
1717
private Config $config,
18-
private RequestContext $context,
18+
private RequestContextScope $contextScope,
1919
private HttpMetrics $metrics,
2020
) {
2121
}
2222

2323
public function handle(Request $request, \Closure $next): ResponseInterface
2424
{
25-
$route = $this->context->get('route', sprintf('%s %s', $request->getMethod(), $request->getUri()->getPath()));
25+
$context = $this->contextScope->getOrCreate();
26+
$route = $context->get('route', sprintf('%s %s', $request->getMethod(), $request->getUri()->getPath()));
2627
$method = $request->getMethod();
2728
$threshold = $this->resolveThreshold($method, $route, $request);
2829

@@ -36,13 +37,13 @@ public function handle(Request $request, \Closure $next): ResponseInterface
3637
$response = $next($request);
3738
} catch (\Throwable $exception) {
3839
$elapsed = microtime(true) - $start;
39-
$this->context->set('timeout.elapsed', $elapsed);
40+
$context->set('timeout.elapsed', $elapsed);
4041
throw $exception;
4142
}
4243

4344
$elapsed = microtime(true) - $start;
44-
$this->context->set('timeout.elapsed', $elapsed);
45-
$this->context->set('timeout.threshold', $threshold);
45+
$context->set('timeout.elapsed', $elapsed);
46+
$context->set('timeout.threshold', $threshold);
4647

4748
if ($elapsed > $threshold) {
4849
$this->metrics->incrementTimeout($method, $route);

src/Web/RequestContextScope.php

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<?php
2+
3+
namespace Bamboo\Web;
4+
5+
use ArrayAccess;
6+
7+
class RequestContextScope
8+
{
9+
private const CONTEXT_KEY = 'bamboo.request_context';
10+
11+
private ?RequestContext $fallbackContext = null;
12+
13+
public function set(RequestContext $context): void
14+
{
15+
$store = $this->fetchCoroutineContext();
16+
if ($store !== null) {
17+
$store[self::CONTEXT_KEY] = $context;
18+
return;
19+
}
20+
21+
$this->fallbackContext = $context;
22+
}
23+
24+
public function get(): ?RequestContext
25+
{
26+
$store = $this->fetchCoroutineContext();
27+
if ($store !== null) {
28+
return $this->getFromStore($store);
29+
}
30+
31+
return $this->fallbackContext;
32+
}
33+
34+
public function getOrCreate(): RequestContext
35+
{
36+
$context = $this->get();
37+
if ($context instanceof RequestContext) {
38+
return $context;
39+
}
40+
41+
$context = new RequestContext();
42+
$this->set($context);
43+
44+
return $context;
45+
}
46+
47+
public function has(): bool
48+
{
49+
$store = $this->fetchCoroutineContext();
50+
if ($store !== null) {
51+
return $this->getFromStore($store) instanceof RequestContext;
52+
}
53+
54+
return $this->fallbackContext instanceof RequestContext;
55+
}
56+
57+
public function clear(): void
58+
{
59+
$store = $this->fetchCoroutineContext();
60+
if ($store !== null) {
61+
if ($store instanceof ArrayAccess && $store->offsetExists(self::CONTEXT_KEY)) {
62+
$store->offsetUnset(self::CONTEXT_KEY);
63+
}
64+
return;
65+
}
66+
67+
$this->fallbackContext = null;
68+
}
69+
70+
private function getFromStore(ArrayAccess $store): ?RequestContext
71+
{
72+
if (!$store->offsetExists(self::CONTEXT_KEY)) {
73+
return null;
74+
}
75+
76+
$value = $store->offsetGet(self::CONTEXT_KEY);
77+
return $value instanceof RequestContext ? $value : null;
78+
}
79+
80+
private function fetchCoroutineContext(): ?ArrayAccess
81+
{
82+
$class = $this->resolveCoroutineClass();
83+
if ($class === null) {
84+
return null;
85+
}
86+
87+
try {
88+
$context = $class::getContext();
89+
} catch (\Throwable) {
90+
return null;
91+
}
92+
93+
return $context instanceof ArrayAccess ? $context : null;
94+
}
95+
96+
private function resolveCoroutineClass(): ?string
97+
{
98+
if (class_exists('\\OpenSwoole\\Coroutine')) {
99+
return '\\OpenSwoole\\Coroutine';
100+
}
101+
102+
if (class_exists('\\Swoole\\Coroutine')) {
103+
return '\\Swoole\\Coroutine';
104+
}
105+
106+
return null;
107+
}
108+
}

tests/Core/ApplicationPipelineTest.php

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
use Bamboo\Provider\MetricsProvider;
1010
use Bamboo\Provider\ResilienceProvider;
1111
use Bamboo\Web\Kernel;
12-
use Bamboo\Web\RequestContext;
12+
use Bamboo\Web\RequestContextScope;
1313
use Nyholm\Psr7\Response;
1414
use Nyholm\Psr7\ServerRequest;
1515
use PHPUnit\Framework\TestCase;
@@ -350,7 +350,9 @@ public function testKernelCachesSingleEntryForUnmatchedRoutes(): void {
350350
$responseOne = $app->handle(new ServerRequest('GET', '/missing-one'));
351351
$this->assertSame(404, $responseOne->getStatusCode());
352352

353-
$contextAfterFirst = $app->get(RequestContext::class);
353+
$scope = $app->get(RequestContextScope::class);
354+
$contextAfterFirst = $scope->get();
355+
$this->assertInstanceOf(\Bamboo\Web\RequestContext::class, $contextAfterFirst);
354356
$this->assertSame('GET /missing-one', $contextAfterFirst->get('route'));
355357

356358
$firstCache = $ref->getValue($kernel);
@@ -359,7 +361,8 @@ public function testKernelCachesSingleEntryForUnmatchedRoutes(): void {
359361
$responseTwo = $app->handle(new ServerRequest('GET', '/missing-two'));
360362
$this->assertSame(404, $responseTwo->getStatusCode());
361363

362-
$contextAfterSecond = $app->get(RequestContext::class);
364+
$contextAfterSecond = $scope->get();
365+
$this->assertInstanceOf(\Bamboo\Web\RequestContext::class, $contextAfterSecond);
363366
$this->assertSame('GET /missing-two', $contextAfterSecond->get('route'));
364367

365368
$secondCache = $ref->getValue($kernel);

tests/Http/RequestIdMiddlewareTest.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
use Bamboo\Core\RouteDefinition;
88
use Bamboo\Provider\AppProvider;
99
use Bamboo\Provider\MetricsProvider;
10-
use Bamboo\Web\RequestContext;
10+
use Bamboo\Web\RequestContextScope;
1111
use Monolog\Handler\TestHandler;
1212
use Monolog\LogRecord;
1313
use Nyholm\Psr7\Response;
@@ -44,7 +44,9 @@ public function testPropagatesInboundRequestId(): void {
4444
$this->assertSame('abc-123', $response->getHeaderLine('X-Request-ID'));
4545
$this->assertSame('abc-123', $captured['request_id']);
4646
$this->assertSame('abc-123', $captured['correlation_id']);
47-
$context = $app->get(RequestContext::class);
47+
$scope = $app->get(RequestContextScope::class);
48+
$context = $scope->get();
49+
$this->assertInstanceOf(\Bamboo\Web\RequestContext::class, $context);
4850
$this->assertSame('abc-123', $context->get('id'));
4951
$this->assertSame('GET /context', $context->get('route'));
5052
$this->assertSame('fast', $response->getHeaderLine('X-Bamboo'));

tests/Roadmap/V0_4/CircuitBreakerMiddlewareTest.php

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
use Bamboo\Observability\Metrics\CircuitBreakerMetrics;
66
use Bamboo\Web\Middleware\CircuitBreakerMiddleware;
7-
use Bamboo\Web\RequestContext;
7+
use Bamboo\Web\RequestContextScope;
88
use Bamboo\Web\Resilience\CircuitBreakerRegistry;
99
use Nyholm\Psr7\Response;
1010
use Nyholm\Psr7\ServerRequest;
@@ -30,13 +30,13 @@ public function testCircuitBreakerOpensAfterFailureThreshold(): void {
3030

3131
$registry = new CircuitBreakerRegistry();
3232
$metrics = new CircuitBreakerMetrics(new CollectorRegistry(new InMemory()), ['namespace' => 'test']);
33-
$context = new RequestContext();
34-
$context->merge(['route' => 'GET /unstable']);
33+
$scope = new RequestContextScope();
34+
$scope->getOrCreate()->merge(['route' => 'GET /unstable']);
3535

3636
$now = 0.0;
3737
$clock = function() use (&$now): float { return $now; };
3838

39-
$middleware = new CircuitBreakerMiddleware($config, $context, $registry, $metrics, $clock);
39+
$middleware = new CircuitBreakerMiddleware($config, $scope, $registry, $metrics, $clock);
4040
$request = new ServerRequest('GET', '/unstable');
4141

4242
try {
@@ -86,13 +86,13 @@ public function testCircuitBreakerPublishesStateMetrics(): void {
8686
$collector = new CollectorRegistry(new InMemory());
8787
$metrics = new CircuitBreakerMetrics($collector, ['namespace' => 'test']);
8888
$registry = new CircuitBreakerRegistry();
89-
$context = new RequestContext();
90-
$context->merge(['route' => 'GET /metrics']);
89+
$scope = new RequestContextScope();
90+
$scope->getOrCreate()->merge(['route' => 'GET /metrics']);
9191

9292
$now = 0.0;
9393
$clock = function() use (&$now): float { return $now; };
9494

95-
$middleware = new CircuitBreakerMiddleware($config, $context, $registry, $metrics, $clock);
95+
$middleware = new CircuitBreakerMiddleware($config, $scope, $registry, $metrics, $clock);
9696
$request = new ServerRequest('GET', '/metrics');
9797

9898
try {

0 commit comments

Comments
 (0)