Skip to content

Latest commit

 

History

History
529 lines (385 loc) · 17.5 KB

context.md

File metadata and controls

529 lines (385 loc) · 17.5 KB

Context

Introduction

Hypervel's "context" capabilities enable you to capture, retrieve, and share information throughout requests, jobs, and commands executing within your application. This captured information is also included in logs written by your application, giving you deeper insight into the surrounding code execution history that occurred before a log entry was written and allowing you to trace execution flows throughout a distributed system.

In Hypervel, the context repository is stored under the current coroutine via Hypervel\Context\CoroutineContext. This means that context values are isolated between concurrent requests, queued jobs, and commands running within the same long-lived Swoole worker. When the coroutine ends, its context is destroyed automatically.

Warning

Do not store request-specific state in static properties, global variables, or mutable singleton properties. These values are shared by all coroutines in the worker and may leak between concurrent requests. Use context for request, job, and log metadata that should remain scoped to the current coroutine.

How it Works

The best way to understand Hypervel's context capabilities is to see it in action using the built-in logging features. To get started, you may add information to the context using the Context facade. In this example, we will use a middleware to add the request URL and a unique trace ID to the context on every incoming request:

<?php

namespace App\Http\Middleware;

use Closure;
use Hypervel\Http\Request;
use Hypervel\Support\Facades\Context;
use Hypervel\Support\Str;
use Symfony\Component\HttpFoundation\Response;

class AddContext
{
    /**
     * Handle an incoming request.
     */
    public function handle(Request $request, Closure $next): Response
    {
        Context::add('url', $request->url());
        Context::add('trace_id', Str::uuid()->toString());

        return $next($request);
    }
}

Information added to the context is automatically appended as metadata to any log entries that are written throughout the request. Appending context as metadata allows information passed to individual log entries to be differentiated from the information shared via Context. For example, imagine we write the following log entry:

Log::info('User authenticated.', ['auth_id' => Auth::id()]);

The written log will contain the auth_id passed to the log entry, but it will also contain the context's url and trace_id as metadata:

User authenticated. {"auth_id":27} {"url":"https://example.com/login","trace_id":"e04e1a11-e75c-4db3-b5b5-cfef4ef56697"}

Information added to the context is also made available to jobs dispatched to the queue. For example, imagine we dispatch a ProcessPodcast job to the queue after adding some information to the context:

// In our middleware...
Context::add('url', $request->url());
Context::add('trace_id', Str::uuid()->toString());

// In our controller...
ProcessPodcast::dispatch($podcast);

When the job is dispatched, any information currently stored in the context is captured and shared with the job. The captured information is then hydrated back into the current context while the job is executing. So, if our job's handle method was to write to the log:

class ProcessPodcast implements ShouldQueue
{
    use Queueable;

    // ...

    /**
     * Execute the job.
     */
    public function handle(): void
    {
        Log::info('Processing podcast.', [
            'podcast_id' => $this->podcast->id,
        ]);

        // ...
    }
}

The resulting log entry would contain the information that was added to the context during the request that originally dispatched the job:

Processing podcast. {"podcast_id":95} {"url":"https://example.com/login","trace_id":"e04e1a11-e75c-4db3-b5b5-cfef4ef56697"}

In addition to its logging features, context allows you to share information with queued jobs, concurrent processes, and scheduled commands. You may also add hidden context data that is not written with log entries.

Capturing Context

You may store information in the current context using the Context facade's add method:

use Hypervel\Support\Facades\Context;

Context::add('key', 'value');

To add multiple items at once, you may pass an associative array to the add method:

Context::add([
    'first_key' => 'value',
    'second_key' => 'value',
]);

The add method will override any existing value that shares the same key. If you only wish to add information to the context if the key does not already exist, you may use the addIf method:

Context::add('key', 'first');

Context::get('key');
// "first"

Context::addIf('key', 'second');

Context::get('key');
// "first"

Context also provides convenient methods for incrementing or decrementing a given key. Both of these methods accept at least one argument: the key to track. A second argument may be provided to specify the amount by which the key should be incremented or decremented:

Context::increment('records_added');
Context::increment('records_added', 5);

Context::decrement('records_added');
Context::decrement('records_added', 5);

Conditional Context

The when method may be used to add data to the context based on a given condition. The first closure provided to the when method will be invoked if the given condition evaluates to true, while the second closure will be invoked if the condition evaluates to false:

use Hypervel\Support\Facades\Auth;
use Hypervel\Support\Facades\Context;

Context::when(
    Auth::user()->isAdmin(),
    fn ($context) => $context->add('permissions', Auth::user()->permissions),
    fn ($context) => $context->add('permissions', []),
);

Scoped Context

The scope method provides a way to temporarily modify the context during the execution of a given callback and restore the context to its original state when the callback finishes executing. Additionally, you can pass extra data that should be merged into the context (as the second and third arguments) while the closure executes.

use Hypervel\Support\Facades\Context;
use Hypervel\Support\Facades\Log;

Context::add('trace_id', 'abc-999');
Context::addHidden('user_id', 123);

Context::scope(
    function () {
        Context::add('action', 'adding_friend');

        $userId = Context::getHidden('user_id');

        Log::debug("Adding user [{$userId}] to friends list.");
        // Adding user [987] to friends list.  {"trace_id":"abc-999","user_name":"taylor_otwell","action":"adding_friend"}
    },
    data: ['user_name' => 'taylor_otwell'],
    hidden: ['user_id' => 987],
);

Context::all();
// [
//     'trace_id' => 'abc-999',
// ]

Context::allHidden();
// [
//     'user_id' => 123,
// ]

Warning

If an object within the context is modified inside the scoped closure, that mutation will be reflected outside of the scope.

Stacks

Context offers the ability to create "stacks", which are lists of data stored in the order that they were added. You can add information to a stack by invoking the push method:

use Hypervel\Support\Facades\Context;

Context::push('breadcrumbs', 'first_value');

Context::push('breadcrumbs', 'second_value', 'third_value');

Context::get('breadcrumbs');
// [
//     'first_value',
//     'second_value',
//     'third_value',
// ]

Stacks can be useful to capture historical information about a request, such as events that are happening throughout your application. For example, you could create an event listener to push to a stack every time a query is executed, capturing the query SQL and duration as a tuple:

use Hypervel\Support\Facades\Context;
use Hypervel\Support\Facades\DB;

// In AppServiceProvider.php...
DB::listen(function ($event) {
    Context::push('queries', [$event->time, $event->sql]);
});

You may determine if a value is in a stack using the stackContains and hiddenStackContains methods:

if (Context::stackContains('breadcrumbs', 'first_value')) {
    //
}

if (Context::hiddenStackContains('secrets', 'first_value')) {
    //
}

The stackContains and hiddenStackContains methods also accept a closure as their second argument, allowing more control over the value comparison operation:

use Hypervel\Support\Facades\Context;
use Hypervel\Support\Str;

return Context::stackContains('breadcrumbs', function ($value) {
    return Str::startsWith($value, 'query_');
});

Retrieving Context

You may retrieve information from the context using the Context facade's get method:

use Hypervel\Support\Facades\Context;

$value = Context::get('key');

The only and except methods may be used to retrieve a subset of the information in the context:

$data = Context::only(['first_key', 'second_key']);

$data = Context::except(['first_key']);

The pull method may be used to retrieve information from the context and immediately remove it from the context:

$value = Context::pull('key');

If context data is stored in a stack, you may pop items from the stack using the pop method:

Context::push('breadcrumbs', 'first_value', 'second_value');

Context::pop('breadcrumbs');
// second_value

Context::get('breadcrumbs');
// ['first_value']

The remember and rememberHidden methods may be used to retrieve information from the context, while setting the context value to the value returned by the given closure if the requested information doesn't exist:

$permissions = Context::remember(
    'user-permissions',
    fn () => $user->permissions,
);

If you would like to retrieve all of the information stored in the context, you may invoke the all method:

$data = Context::all();

Determining Item Existence

You may use the has and missing methods to determine if the context has any value stored for the given key:

use Hypervel\Support\Facades\Context;

if (Context::has('key')) {
    // ...
}

if (Context::missing('key')) {
    // ...
}

The has method will return true regardless of the value stored. So, for example, a key with a null value will be considered present:

Context::add('key', null);

Context::has('key');
// true

Injecting Context

Hypervel's service container offers a Context attribute that allows you to inject context values into classes resolved by the container. The attribute accepts the context key as its first argument:

use Hypervel\Container\Attributes\Context;

class ProcessPodcast
{
    public function __construct(
        #[Context('trace_id')] protected ?string $traceId,
    ) {
        // ...
    }
}

You may provide a default value as the second argument to the attribute:

use Hypervel\Container\Attributes\Context;

class ProcessPodcast
{
    public function __construct(
        #[Context('trace_id', 'unknown')] protected string $traceId,
    ) {
        // ...
    }
}

To retrieve a value from hidden context, pass true to the hidden argument:

use Hypervel\Container\Attributes\Context;

class ProcessPodcast
{
    public function __construct(
        #[Context('user_id', hidden: true)] protected ?int $userId,
    ) {
        // ...
    }
}

Removing Context

The forget method may be used to remove a key and its value from the current context:

use Hypervel\Support\Facades\Context;

Context::add(['first_key' => 1, 'second_key' => 2]);

Context::forget('first_key');

Context::all();

// ['second_key' => 2]

You may forget several keys at once by providing an array to the forget method:

Context::forget(['first_key', 'second_key']);

Hidden Context

Context offers the ability to store "hidden" data. This hidden information is not appended to logs, and is not accessible via the data retrieval methods documented above. Context provides a different set of methods to interact with hidden context information:

use Hypervel\Support\Facades\Context;

Context::addHidden('key', 'value');

Context::getHidden('key');
// 'value'

Context::get('key');
// null

The "hidden" methods mirror the functionality of the non-hidden methods documented above:

Context::addHidden(/* ... */);
Context::addHiddenIf(/* ... */);
Context::pushHidden(/* ... */);
Context::getHidden(/* ... */);
Context::pullHidden(/* ... */);
Context::popHidden(/* ... */);
Context::rememberHidden(/* ... */);
Context::onlyHidden(/* ... */);
Context::exceptHidden(/* ... */);
Context::allHidden(/* ... */);
Context::hasHidden(/* ... */);
Context::missingHidden(/* ... */);
Context::hiddenStackContains(/* ... */);
Context::forgetHidden(/* ... */);

Low-Level Coroutine Context

The Context facade documents Hypervel's application-facing context API. Internally, this repository is stored in Hypervel\Context\CoroutineContext under the current coroutine. Most application code should use the Context facade, but package and framework code may use CoroutineContext directly when it needs low-level coroutine-local storage.

CoroutineContext provides methods such as set, get, has, forget, getOrSet, override, copyFrom, copyFromNonCoroutine, and flush:

use Hypervel\Context\CoroutineContext;

CoroutineContext::set('tenant_id', 123);

$tenantId = CoroutineContext::get('tenant_id');

Plain child coroutines do not automatically inherit the parent's context. For example, Hypervel\Coroutine\Coroutine::fork copies the parent's context when invoked and installs it in the child. Objects stored directly as context values are shared by default. Values implementing Hypervel\Context\ReplicableContext, such as the context repository, are copied independently, while values implementing Hypervel\Context\NonCopyableContext, such as borrowed database and Redis connections, are omitted. Hypervel does not inspect objects nested within arrays or other objects.

Events

Context dispatches two events that allow you to hook into the hydration and dehydration process of the context.

To illustrate how these events may be used, imagine that in a middleware of your application you set the current application locale based on the incoming HTTP request's Accept-Language header. Context's events allow you to capture this value during the request and restore it on the queue, ensuring notifications sent on the queue use the correct locale. We can use context's events and hidden data to achieve this, which the following documentation will illustrate.

Dehydrating

Whenever a job is dispatched to the queue the data in the context is "dehydrated" and captured alongside the job's payload. The Context::dehydrating method allows you to register a closure that will be invoked during the dehydration process. Within this closure, you may make changes to the data that will be shared with the queued job.

Typically, you should register dehydrating callbacks within the boot method of your application's AppServiceProvider class:

use Hypervel\Log\Context\Repository;
use Hypervel\Support\Facades\App;
use Hypervel\Support\Facades\Context;

/**
 * Bootstrap any application services.
 */
public function boot(): void
{
    Context::dehydrating(function (Repository $context) {
        $context->addHidden('locale', App::currentLocale());
    });
}

Note

You should not use the Context facade within the dehydrating callback, as that will change the context of the current coroutine. Ensure you only make changes to the repository passed to the callback.

Hydrated

Whenever a queued job begins executing on the queue, any context that was shared with the job will be "hydrated" back into the current context. The Context::hydrated method allows you to register a closure that will be invoked during the hydration process.

Typically, you should register hydrated callbacks within the boot method of your application's AppServiceProvider class:

use Hypervel\Log\Context\Repository;
use Hypervel\Support\Facades\App;
use Hypervel\Support\Facades\Context;

/**
 * Bootstrap any application services.
 */
public function boot(): void
{
    Context::hydrated(function (Repository $context) {
        if ($context->hasHidden('locale')) {
            App::setLocale($context->getHidden('locale'));
        }
    });
}

Note

You should not use the Context facade within the hydrated callback. Use the repository passed to the callback to read hydrated values, then restore other coroutine-local state using the appropriate service APIs.