Skip to content
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

Adding event dispatcher #763

Merged
merged 12 commits into from
Jul 18, 2022
1 change: 1 addition & 0 deletions .phan/config.php
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,7 @@
'vendor/promphp/prometheus_client_php/src',
'vendor/google/protobuf/src',
'vendor/nyholm/dsn/src',
'vendor/cloudevents/sdk-php/src',
],

// A list of individual files to include in analysis
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"license": "Apache-2.0",
"require": {
"php": "^7.4 || ^8.0",
"cloudevents/sdk-php": "^v1.0.1",
tidal marked this conversation as resolved.
Show resolved Hide resolved
"ext-json": "*",
"google/protobuf": "^3.3.0",
"grpc/grpc": "^1.30",
Expand Down
5 changes: 5 additions & 0 deletions deptrac.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ deptrac:
collectors:
- type: className
regex: ^Swoole\\*
- name: CloudEvents
collectors:
- type: className
regex: ^CloudEvents\\*

ruleset:
Context:
Expand All @@ -101,6 +105,7 @@ deptrac:
- Context
- SemConv
- PsrLog
- CloudEvents
SDK:
- +API
- PsrHttp
Expand Down
63 changes: 63 additions & 0 deletions src/API/Common/Event/Dispatcher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

declare(strict_types=1);

namespace OpenTelemetry\API\Common\Event;

use CloudEvents\V1\CloudEventInterface;
use OpenTelemetry\Context\Context;
use OpenTelemetry\Context\ContextKey;

class Dispatcher implements DispatcherInterface
{
private static ?ContextKey $key = null;
/** @var array<string, array<int, array<callable>>> */
private array $listeners = [];

public static function getInstance(): self
{
$key = self::getConstantKeyInstance();

return Context::getCurrent()->get($key) ?? self::createInstance();
}

public function dispatch(CloudEventInterface $event): void
{
$this->dispatchEvent($this->getListenersForEvent($event->getType()), $event);
}

public function listen(string $type, callable $listener, int $priority = 0): void
{
$this->listeners[$type][$priority][] = $listener;
ksort($this->listeners[$type]);
}

private function getListenersForEvent(string $key): iterable
{
foreach ($this->listeners[$key] as $listeners) {
foreach ($listeners as $listener) {
yield $listener;
}
}
}

private function dispatchEvent(iterable $listeners, CloudEventInterface $event): void
{
foreach ($listeners as $listener) {
$listener($event);
}
}

private static function getConstantKeyInstance(): ContextKey
{
return self::$key ??= new ContextKey(self::class);
}

private static function createInstance(): self
{
$dispatcher = new self();
Context::getCurrent()->with(self::getConstantKeyInstance(), $dispatcher)->activate();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "root" dispatcher should use a static instance and not the context; currently leaks the created scope.
Additionally leads to different behavior depending on when the dispatcher is retrieved for the first time as listeners will then be added to the same instance.


return $dispatcher;
}
}
13 changes: 13 additions & 0 deletions src/API/Common/Event/DispatcherInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace OpenTelemetry\API\Common\Event;

use CloudEvents\V1\CloudEventInterface;

interface DispatcherInterface
{
public function dispatch(CloudEventInterface $event): void;
public function listen(string $type, callable $listener, int $priority = 0): void;
}
15 changes: 15 additions & 0 deletions src/API/Common/Event/EmitsEventsTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace OpenTelemetry\API\Common\Event;

use CloudEvents\V1\CloudEventInterface;

trait EmitsEventsTrait
{
protected static function emit(CloudEventInterface $event): void
{
Dispatcher::getInstance()->dispatch($event);
}
}
1 change: 1 addition & 0 deletions src/API/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
],
"require": {
"php": "^7.4 || ^8.0",
"cloudevents/sdk-php": "^v1.0.1",
"open-telemetry/context": "self.version",
"open-telemetry/sem-conv": "self.version",
"psr/log": "^1.1|^2.0|^3.0"
Expand Down
78 changes: 78 additions & 0 deletions tests/Benchmark/EventBench.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

declare(strict_types=1);

namespace OpenTelemetry\Tests\Benchmark;

use CloudEvents\V1\CloudEvent;
use CloudEvents\V1\CloudEventInterface;
use Generator;
use OpenTelemetry\API\Common\Event\Dispatcher;

class EventBench
{
private Dispatcher $dispatcher;
private $listener;
private CloudEventInterface $event;

public function __construct()
{
$this->dispatcher = Dispatcher::getInstance();
$this->listener = function () {
};
$this->event = new CloudEvent(uniqid(), self::class, 'foo');
}

public function addEvents(): void
{
for ($i=0; $i<10; $i++) {
$this->dispatcher->listen('event_' . $i, $this->listener);
}
$this->dispatcher->listen($this->event->getType(), $this->listener);
}

/**
* @ParamProviders("provideListenerCounts")
* @Revs(1000)
* @Iterations(10)
* @OutputTimeUnit("microseconds")
*/
public function benchAddListeners(array $params): void
{
for ($i=0; $i<$params[0]; $i++) {
$this->dispatcher->listen('event_' . $i, $this->listener);
}
}

/**
* @ParamProviders("provideListenerCounts")
* @Revs(1000)
* @Iterations(10)
* @OutputTimeUnit("microseconds")
*/
public function benchAddListenersForSameEvent(array $params): void
{
for ($i=0; $i<$params[0]; $i++) {
$this->dispatcher->listen('event', $this->listener);
}
}

/**
* @BeforeMethods("addEventsToListener")
* @Revs(1000)
* @Iterations(10)
* @OutputTimeUnit("microseconds")
*/
public function benchDispatchEvent(): void
{
$this->dispatcher->dispatch($this->event);
}

public function provideListenerCounts(): Generator
{
yield [1];
yield [4];
yield [16];
yield [256];
}
}
115 changes: 115 additions & 0 deletions tests/Unit/API/Common/Event/DispatcherTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<?php

declare(strict_types=1);

namespace OpenTelemetry\Tests\Unit\API\Common\Event;

use CloudEvents\V1\CloudEventInterface;
use OpenTelemetry\API\Common\Event\Dispatcher;
use OpenTelemetry\Context\Context;
use OpenTelemetry\Context\ContextKey;
use PHPUnit\Framework\TestCase;

/**
* @covers \OpenTelemetry\API\Common\Event\Dispatcher
*/
class DispatcherTest extends TestCase
{
private Dispatcher $dispatcher;
private CloudEventInterface $event;
private \ReflectionMethod $method;

public function setUp(): void
{
$this->dispatcher = new Dispatcher();

$reflection = new \ReflectionClass($this->dispatcher);
$this->method = $reflection->getMethod('getListenersForEvent');
$this->method->setAccessible(true);

$this->event = $this->createMock(CloudEventInterface::class);
$this->event->method('getType')->willReturn('foo');
}

public function test_get_instance(): void
{
$dispatcher = Dispatcher::getInstance();
$this->assertInstanceOf(Dispatcher::class, $dispatcher);
$this->assertSame($dispatcher, Dispatcher::getInstance());
}

public function test_get_instance_from_parent_context(): void
{
$dispatcher = Dispatcher::getInstance();
$this->assertInstanceOf(Dispatcher::class, $dispatcher);
$parent = Context::getCurrent()->with(new ContextKey('foo'), 'bar');
$parent->activate();
$this->assertSame($dispatcher, Dispatcher::getInstance());
}

public function test_add_listener(): void
{
$listenerFunction = function () {
};
$this->dispatcher->listen($this->event->getType(), $listenerFunction);
$listeners = [...$this->method->invokeArgs($this->dispatcher, [$this->event->getType()])];
$this->assertCount(1, $listeners);
$this->assertSame($listenerFunction, $listeners[0]);
}

public function test_dispatch_event(): void
{
$handler = function ($receivedEvent) {
$this->assertSame($this->event, $receivedEvent);
};
$this->dispatcher->listen($this->event->getType(), $handler);
$this->dispatcher->dispatch($this->event);
}

public function test_add_multiple_listeners_with_same_priority(): void
{
$listenerOne = function (CloudEventInterface $event) {
};
$listenerTwo = function (CloudEventInterface $event) {
};
$this->dispatcher->listen($this->event->getType(), $listenerOne);
$this->dispatcher->listen($this->event->getType(), $listenerTwo);
$listeners = [...$this->method->invokeArgs($this->dispatcher, [$this->event->getType()])];
$this->assertCount(2, $listeners);
$this->assertSame($listenerOne, $listeners[0]);
$this->assertSame($listenerTwo, $listeners[1]);
}

public function test_listener_priority(): void
{
$listenerOne = function () {
};
$listenerTwo = function () {
};
$listenerThree = function () {
};
$listenerFour = function () {
};
$this->dispatcher->listen($this->event->getType(), $listenerOne, 1);
$this->dispatcher->listen($this->event->getType(), $listenerTwo, -1);
$this->dispatcher->listen($this->event->getType(), $listenerThree, 0);
$this->dispatcher->listen($this->event->getType(), $listenerFour, 1);
$listeners = [...$this->method->invokeArgs($this->dispatcher, [$this->event->getType()])];
$this->assertCount(4, $listeners);
$this->assertSame($listenerTwo, $listeners[0]);
$this->assertSame($listenerThree, $listeners[1]);
$this->assertSame($listenerOne, $listeners[2]);
$this->assertSame($listenerFour, $listeners[3]);
}

public function test_add_listener_to_multiple_events(): void
{
$event = $this->createMock(CloudEventInterface::class);
$event->method('getType')->willReturn('bar');
$listener = function () {
};
$this->dispatcher->listen($this->event->getType(), $listener);
$this->dispatcher->listen($event->getType(), $listener);
$this->assertSame([$listener], [...$this->method->invokeArgs($this->dispatcher, [$this->event->getType()])]);
}
}
42 changes: 42 additions & 0 deletions tests/Unit/API/Common/Event/EmitsEventsTraitTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace OpenTelemetry\Tests\Unit\API\Common\Event;

use CloudEvents\V1\CloudEventInterface;
use OpenTelemetry\API\Common\Event\Dispatcher;
use OpenTelemetry\API\Common\Event\EmitsEventsTrait;
use PHPUnit\Framework\TestCase;

/**
* @covers \OpenTelemetry\API\Common\Event\EmitsEventsTrait
*/
class EmitsEventsTraitTest extends TestCase
{
public function test_emits_event(): void
{
$event = $this->createMock(CloudEventInterface::class);
$event->method('getType')->willReturn('bar');
$called = false;
$class = $this->createInstance();
Dispatcher::getInstance()->listen($event->getType(), function () use (&$called) {
$this->assertTrue(true, 'listener was called');
$called = true;
});
$class->run('emit', $event);
$this->assertTrue($called);
}

private function createInstance(): object
{
return new class() {
use EmitsEventsTrait;
//accessor for protected trait methods
public function run(string $method, $param): void
{
$this->{$method}($param);
}
};
}
}