diff --git a/composer.json b/composer.json index f9bccf4..886eab9 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,14 @@ { "name": "rcrdortiz/axpecto", "description": "PHP meta‑framework for modern, AI‑augmented, aspect‑oriented development.", + "keywords": [ + "php", + "aop", + "dependency-injection", + "collections", + "meta-framework", + "ai" + ], "type": "library", "version": "1.0.4", "license": "MIT", diff --git a/src/Annotation/Annotation.php b/src/Annotation/Annotation.php index 18dd381..50eb897 100644 --- a/src/Annotation/Annotation.php +++ b/src/Annotation/Annotation.php @@ -14,7 +14,6 @@ * * @package Axpecto\Aop * - * @TODO Refactor this and possibly create a hierarchy of annotations with Annotation -> BuildAnnotation -> MethodExecutionAnnotation. */ #[Attribute] class Annotation { diff --git a/src/Annotation/AnnotationReader.php b/src/Annotation/AnnotationReader.php index 059c73b..e045675 100644 --- a/src/Annotation/AnnotationReader.php +++ b/src/Annotation/AnnotationReader.php @@ -5,167 +5,171 @@ namespace Axpecto\Annotation; use Axpecto\Collection\Klist; -use Axpecto\Container\Container; use Axpecto\Reflection\ReflectionUtils; use ReflectionAttribute; use ReflectionException; +use ReflectionMethod; use ReflectionParameter; /** - * Reads PHP8 attributes and turns them into AOP-style Annotation instances, - * filtering by class vs. method targets and injecting their properties via DI. + * AnnotationReader + * + * Reads PHP 8 attributes and turns them into Annotation instances, + * filtering by class, and annotating each instance + * with its declaring class and/or method name. * * @template A of Annotation * @psalm-consistent-constructor */ class AnnotationReader { + /** + * @param ReflectionUtils $reflection + * Used to fetch native PHP ReflectionAttribute instances. + */ public function __construct( - private readonly Container $container, private readonly ReflectionUtils $reflection ) { } /** - * Fetch all annotations of a given type on a class. + * Fetch all annotations of the given type on a class. * - * @template T - * @param class-string $class - * @param class-string $annotationClass + * @template T of Annotation + * @param class-string $class + * @param class-string $annotationClass + * + * @return Klist A list of instantiated annotations, each with its + * ->setAnnotatedClass($class) already applied. * - * @return Klist * @throws ReflectionException */ public function getClassAnnotations( string $class, - string $annotationClass, + string $annotationClass ): Klist { - $raw = $this->reflection->getClassAttributes( $class ); - - return $this - ->filterAndInject( $raw, $annotationClass ) - ->map( fn( Annotation $ann ): Annotation => $ann->setAnnotatedClass( $class ) ); + return $this->reflection + ->getClassAttributes( $class ) + ->filter( fn( Annotation $ann ) => $ann instanceof $annotationClass ) + ->map( fn( Annotation $ann ) => $ann->setAnnotatedClass( $class ) ); } /** - * Fetch all annotations of a given type on a method. + * Fetch all annotations of the given type on a specific method. * - * @template T - * @param class-string $class + * @template T of Annotation + * @param class-string $class * @param string $method - * @param class-string $annotationClass + * @param class-string $annotationClass + * + * @return Klist A list of instantiated annotations, each with + * ->setAnnotatedClass($class) + * and ->setAnnotatedMethod($method) applied. * - * @return Klist * @throws ReflectionException */ public function getMethodAnnotations( string $class, string $method, - string $annotationClass, + string $annotationClass ): Klist { - $raw = $this->reflection->getMethodAttributes( $class, $method ); - - return $this - ->filterAndInject( $raw, $annotationClass ) - ->map( fn( Annotation $ann ): Annotation => $ann + return $this->reflection + ->getMethodAttributes( $class, $method ) + ->filter( fn( Annotation $ann ) => $ann instanceof $annotationClass ) + ->map( fn( Annotation $ann ) => $ann ->setAnnotatedClass( $class ) ->setAnnotatedMethod( $method ) ); } /** - * Fetch both class‑level and method‑level annotations of a given type. + * Fetch both class-level and method-level annotations of a given type. * - * @template T - * @param class-string $class - * @param class-string $annotationClass + * @template T of Annotation + * @param class-string $class + * @param class-string $annotationClass + * + * @return Klist All matching annotations on the class itself + * and on any of its methods. * - * @return Klist * @throws ReflectionException */ public function getAllAnnotations( string $class, - string $annotationClass = Annotation::class + string $annotationClass, ): Klist { - $classAnns = $this->getClassAnnotations( $class, $annotationClass ); + $classAnns = $this->getClassAnnotations( $class, $annotationClass ); + $methodAnns = $this->reflection ->getAnnotatedMethods( $class, $annotationClass ) - ->map( fn( \ReflectionMethod $m ) => $this->getMethodAnnotations( $class, $m->getName(), $annotationClass ) - ) + ->map( fn( ReflectionMethod $m ) => $this->getMethodAnnotations( $class, $m->getName(), $annotationClass ) ) ->flatten(); return $classAnns->merge( $methodAnns ); } /** - * Fetch all annotations of a given type on one of a method’s parameters. + * Fetch all annotations of the given type on a single method parameter. * - * @template T - * @param class-string $class + * @template T of Annotation + * @param class-string $class * @param string $method * @param string $parameterName - * @param class-string $annotationClass + * @param class-string $annotationClass + * + * @return Klist A list (possibly empty) of annotations on that parameter, + * each with ->setAnnotatedClass() and ->setAnnotatedMethod(). * - * @return Klist * @throws ReflectionException */ public function getParameterAnnotations( string $class, string $method, string $parameterName, - string $annotationClass, + string $annotationClass ): Klist { - $parameter = listFrom( $this->reflection->getClassMethod( $class, $method )->getParameters() ) + $param = listFrom( $this->reflection->getClassMethod( $class, $method )->getParameters() ) ->filter( fn( ReflectionParameter $p ) => $p->getName() === $parameterName ) ->firstOrNull(); - if ( ! $parameter ) { + if ( $param === null ) { return emptyList(); } - return listFrom( $parameter->getAttributes() ) - ->map( fn( ReflectionAttribute $p ) => $p->newInstance() ) - ->maybe( fn( Klist $attributes ) => $this->filterAndInject( $attributes, $annotationClass ) ) - ->foreach( fn( Annotation $ann ) => $ann->setAnnotatedClass( $class )->setAnnotatedMethod( $method ) ); + return listFrom( $param->getAttributes() ) + ->map( fn( ReflectionAttribute $attr ) => $attr->newInstance() ) + ->filter( fn( $inst ) => $inst instanceof $annotationClass ) + ->map( fn( Annotation $ann ) => $ann + ->setAnnotatedClass( $class ) + ->setAnnotatedMethod( $method ) + ); } /** - * Fetch a single annotation of a given type on a property. + * Fetch exactly one annotation of the given type on a class property. * - * @template T - * @param class-string $class + * @template T of Annotation + * @param class-string $class * @param string $property - * @param class-string $annotationClass + * @param class-string $annotationClass + * + * @return T The first matching annotation, or null if none. * - * @return A|null * @throws ReflectionException */ public function getPropertyAnnotation( string $class, string $property, string $annotationClass = Annotation::class - ): mixed { - $attributes = $this->reflection + ): ?Annotation { + $attrs = $this->reflection ->getReflectionClass( $class ) ->getProperty( $property ) ->getAttributes(); - return listFrom( $attributes ) - ->map( fn( ReflectionAttribute $a ) => $a->newInstance() ) - ->maybe( fn( Klist $attributes ) => $this->filterAndInject( $attributes, $annotationClass ) ) + return listFrom( $attrs ) + ->map( fn( ReflectionAttribute $attr ) => $attr->newInstance() ) + ->filter( fn( $inst ) => $inst instanceof $annotationClass ) ->firstOrNull() ?->setAnnotatedClass( $class ); } - - /** - * @template T of Annotation - * @param Klist $instances - * @param class-string $annotationClass - * - * @return Klist - */ - private function filterAndInject( Klist $instances, string $annotationClass ): Klist { - return $instances - ->filter( fn( $i ) => is_a( $i, $annotationClass, true ) ) - ->foreach( fn( Annotation $ann ) => $this->container->applyPropertyInjection( $ann ) ); - } } diff --git a/src/Annotation/AnnotationService.php b/src/Annotation/AnnotationService.php new file mode 100644 index 0000000..f475bfe --- /dev/null +++ b/src/Annotation/AnnotationService.php @@ -0,0 +1,123 @@ + $class + * @param class-string $annotationClass + * + * @return Klist + * @throws ReflectionException + */ + public function getClassAnnotations( + string $class, + string $annotationClass, + ): Klist { + return $this->reader->getClassAnnotations( $class, $annotationClass ) + ->foreach( fn( Annotation $a ) => $this->dependencyResolver->applyPropertyInjection( $a ) ); + } + + /** + * Fetch all annotations of a given type on a method. + * + * @template T + * @param class-string $class + * @param string $method + * @param class-string $annotationClass + * + * @return Klist + * @throws ReflectionException + */ + public function getMethodAnnotations( + string $class, + string $method, + string $annotationClass, + ): Klist { + return $this->reader->getMethodAnnotations( $class, $method, $annotationClass ) + ->foreach( fn( Annotation $a ) => $this->dependencyResolver->applyPropertyInjection( $a ) ); + } + + /** + * Fetch both class‑level and method‑level annotations of a given type. + * + * @template T + * @param class-string $class + * @param class-string $annotationClass + * + * @return Klist + * @throws ReflectionException + */ + public function getAllAnnotations( + string $class, + string $annotationClass = Annotation::class + ): Klist { + return $this->reader->getAllAnnotations( $class, $annotationClass ) + ->foreach( fn( Annotation $a ) => $this->dependencyResolver->applyPropertyInjection( $a ) ); + } + + /** + * Fetch all annotations of a given type on one of a method’s parameters. + * + * @template T + * @param class-string $class + * @param string $method + * @param string $parameterName + * @param class-string $annotationClass + * + * @return Klist + * @throws ReflectionException + */ + public function getParameterAnnotations( + string $class, + string $method, + string $parameterName, + string $annotationClass, + ): Klist { + return $this->reader->getParameterAnnotations( $class, $method, $parameterName, $annotationClass ) + ->foreach( fn( Annotation $a ) => $this->dependencyResolver->applyPropertyInjection( $a ) ); + } + + /** + * Fetch a single annotation of a given type on a property. + * + * @template T + * @param class-string $class + * @param string $property + * @param class-string $annotationClass + * + * @return A|null + * @throws ReflectionException + */ + public function getPropertyAnnotation( + string $class, + string $property, + string $annotationClass = Annotation::class + ): mixed { + $annotation = $this->reader->getPropertyAnnotation( $class, $property, $annotationClass ); + $this->dependencyResolver->applyPropertyInjection( $annotation ); + + return $annotation; + } +} \ No newline at end of file diff --git a/src/Annotation/MethodExecutionAnnotation.php b/src/Annotation/MethodExecutionAnnotation.php index bbdf0c3..a266ac6 100644 --- a/src/Annotation/MethodExecutionAnnotation.php +++ b/src/Annotation/MethodExecutionAnnotation.php @@ -3,10 +3,16 @@ namespace Axpecto\Annotation; use Attribute; +use Axpecto\ClassBuilder\BuildHandler; +use Axpecto\Container\Annotation\Inject; +use Axpecto\MethodExecution\Builder\MethodExecutionBuildHandler; use Axpecto\MethodExecution\MethodExecutionHandler; #[Attribute] class MethodExecutionAnnotation extends BuildAnnotation { + #[Inject( class: MethodExecutionBuildHandler::class )] + protected ?BuildHandler $builder = null; + /** * The handler for processing the method execution annotation. * diff --git a/src/Cache/Annotation/Cache.php b/src/Cache/Annotation/Cache.php new file mode 100644 index 0000000..fc0fbec --- /dev/null +++ b/src/Cache/Annotation/Cache.php @@ -0,0 +1,23 @@ +getAnnotation( Cache::class ); + + $this->cacheService->enableTelemetry( $annotation->telemetry ); + + $cacheKey = $this->getCacheKey( $annotation, $context ); + + return $this->cacheService->runCached( + $cacheKey, + $annotation->group, + $annotation->ttl, + fn() => $context->proceed(), + ); + } + + private function getCacheKey( Cache $ann, MethodExecutionContext $context ): string { + return $ann->key ?? $context->className . '::' . $context->methodName . '(' . md5( serialize( $context->arguments ) ) . ')'; + } +} \ No newline at end of file diff --git a/src/Cache/CacheService.php b/src/Cache/CacheService.php new file mode 100644 index 0000000..1eb8b16 --- /dev/null +++ b/src/Cache/CacheService.php @@ -0,0 +1,22 @@ + [ + * 'cacheKey' => ['value' => mixed, 'expiresAt' => DateTimeImmutable|null], + * … + * ], + * … + * ] + * + * @var array> + */ + private array $store = []; + private bool $telemetryEnabled; + + public function __construct( + private readonly TelemetryService $telemetryService, + ) {} + + /** + * @inheritDoc + */ + #[Override] + public function runCached( + string $cacheKey, + ?string $group, + ?int $expiration, + Closure $lambda + ): mixed { + $group = $group ?? self::DEFAULT_GROUP; + + // initialize group if needed + if ( ! isset( $this->store[ $group ] ) ) { + $this->store[ $group ] = []; + } + + // return cached if still valid + if ( isset( $this->store[ $group ][ $cacheKey ] ) ) { + $entry = $this->store[ $group ][ $cacheKey ]; + if ( $entry['expiresAt'] === null || $entry['expiresAt'] > new DateTimeImmutable() ) { + $this->recordEvent( hit: true, group: $group, cacheKey: $cacheKey, expires: $entry['expiresAt']?->format( 'c' ) ?? '' ); + return $entry['value']; + } + // expired + unset( $this->store[ $group ][ $cacheKey ] ); + } + + // compute and store + $value = $lambda(); + $expiresAt = $expiration !== null + ? ( new DateTimeImmutable() )->modify( "+{$expiration} seconds" ) + : null; + + $this->store[ $group ][ $cacheKey ] = [ + 'value' => $value, + 'expiresAt' => $expiresAt, + ]; + + $this->recordEvent( hit: false, group: $group, cacheKey: $cacheKey, expires: $expiresAt?->format( 'c' ) ?? '' ); + + return $value; + } + + /** + * Delete a cached entry. + * + * @param string $cacheKey + * @param string|null $group Optional group name (will default to "__default"). + */ + public function delete( string $cacheKey, ?string $group = null ): void { + $group = $group ?? self::DEFAULT_GROUP; + if ( isset( $this->store[ $group ][ $cacheKey ] ) ) { + unset( $this->store[ $group ][ $cacheKey ] ); + } + } + + /** + * Clear an entire group of cache entries (or all if group is null). + * + * @param string|null $group + */ + public function clear( ?string $group = null ): void { + if ( $group === null ) { + $this->store = []; + } else { + unset( $this->store[ $group ] ); + } + } + + public function enableTelemetry( bool $enable ): void { + $this->telemetryEnabled = $enable; + } + + private function recordEvent( bool $hit, string $group, string $cacheKey, string $expires ): void { + $this->telemetryEnabled && $this->telemetryService->recordEvent( + 'in_memory.cache.' . ($hit ? 'hit' : 'miss') . ' => ' . $cacheKey, + [ + 'group' => $group, + 'cacheKey' => $cacheKey, + 'expiresAt' => $expires, + ] + ); + } +} diff --git a/src/ClassBuilder/BuildOutput.php b/src/ClassBuilder/BuildOutput.php index a916008..08d8d0c 100644 --- a/src/ClassBuilder/BuildOutput.php +++ b/src/ClassBuilder/BuildOutput.php @@ -2,6 +2,8 @@ namespace Axpecto\ClassBuilder; +use Axpecto\Annotation\Annotation; +use Axpecto\Collection\Klist; use Axpecto\Collection\Kmap; use Axpecto\Container\Annotation\Inject; use Exception; @@ -26,6 +28,7 @@ class BuildOutput { /** * Constructor for the BuildOutput class. ** + * * @param Kmap $methods List of methods in the output. * @param Kmap $properties List of class properties in the output. */ @@ -34,6 +37,8 @@ public function __construct( public readonly Kmap $methods = new Kmap( mutable: true ), public readonly Kmap $properties = new Kmap( mutable: true ), public readonly Kmap $traits = new Kmap( mutable: true ), + // @TODO I might change this. + private array $methodAnnotations = [], ) { } @@ -52,6 +57,21 @@ public function addMethod( string $name, string $signature, string $implementati $this->methods->add( $name, "$signature {\n\t\t$implementation\n\t}\n" ); } + /** + * @template T of Annotation + * @param string $methodName + * @param class-string $annotation + * + * @throws Exception + */ + public function annotateMethod( string $methodName, string $annotation ): void { + $this->methodAnnotations[$methodName][] = '#[' . $annotation . ']'; + } + + public function getMethodAnnotations( string $methodName ): Klist { + return listFrom( $this->methodAnnotations[$methodName] ?? [] ); + } + /** * Add a property to the output. * Modifies the internal state directly. @@ -80,7 +100,7 @@ public function addProperty( string $name, string $implementation ): void { public function injectProperty( string $name, string $class ): string { $this->addProperty( name: $class, - implementation: "#[" . Inject::class . "] private $class \$$name;", + implementation: "#[" . Inject::class . "] protected $class \$$name;", ); return $name; diff --git a/src/ClassBuilder/ClassBuilder.php b/src/ClassBuilder/ClassBuilder.php index a1af7e7..cd06706 100644 --- a/src/ClassBuilder/ClassBuilder.php +++ b/src/ClassBuilder/ClassBuilder.php @@ -2,10 +2,12 @@ namespace Axpecto\ClassBuilder; -use Axpecto\Annotation\AnnotationReader; +use Axpecto\Annotation\Annotation; +use Axpecto\Annotation\AnnotationService; use Axpecto\Annotation\BuildAnnotation; use Axpecto\Container\Exception\ClassAlreadyBuiltException; use Axpecto\Reflection\ReflectionUtils; +use Exception; use ReflectionException; /** @@ -23,7 +25,7 @@ class ClassBuilder { */ public function __construct( private readonly ReflectionUtils $reflect, - private readonly AnnotationReader $reader, + private readonly AnnotationService $annotationService, private array $builtClasses = [], ) { } @@ -36,30 +38,45 @@ public function __construct( * @return string The name of the proxied class. * @throws ReflectionException * @throws ClassAlreadyBuiltException + * @throws Exception */ - public function build( string $class ): string { + public function build( string $class, int $pass = 0, ?string $extends = null ): string { + // “current” is the class we’re actually building this pass + $current = $extends ?? $class; + // Check if the class has already been built - if ( isset( $this->builtClasses[ $class ] ) ) { - throw new ClassAlreadyBuiltException( $class ); + if ( isset( $this->builtClasses[ $current ] ) ) { + throw new ClassAlreadyBuiltException( $current ); } // Get all the Build annotations for the class and its methods - $buildAnnotations = $this->reader->getAllAnnotations( $class, BuildAnnotation::class ); + $buildAnnotations = $this->annotationService->getAllAnnotations( $current, BuildAnnotation::class ); // Create and proceed with the build chain - $context = new BuildOutput( $class ); + $context = new BuildOutput( $current ); $buildAnnotations->foreach( fn( BuildAnnotation $a ) => $a->getBuilder()?->intercept( $a, $context ) ); + // Annotate the methods with the existing annotations, we exclude BuildAnnotations. + $context->methods->foreach( fn( string $methodName ) => $this->annotationService->getMethodAnnotations( $current, $methodName, Annotation::class ) + ->filter( fn( Annotation $a ) => ! $a instanceof BuildAnnotation ) + ->foreach( fn( Annotation $a ) => $context->annotateMethod( $methodName, $a::class ) ) + ); + // If the build output is empty, return the original class if ( $context->isEmpty() ) { - return $class; + return $current; } // Generate and evaluate the proxy class - $proxiedClass = $this->generateProxyClass( $class, $context ); + $proxiedClass = $this->buildClass( $class, $context, $pass, $extends ); // Cache the built class - $this->builtClasses[ $class ] = $proxiedClass; + $this->builtClasses[ $current ] = $proxiedClass; + + // Check if the class has any Build annotations and trigger a new build pass. + if ( $this->annotationService->getAllAnnotations( $proxiedClass, BuildAnnotation::class )->isNotEmpty() ) { + return $this->build( $current, ++$pass, $proxiedClass ); + } // Return the proxy class name return $proxiedClass; @@ -77,9 +94,9 @@ public function build( string $class ): string { * @return string The name of the generated proxy class. * @throws ReflectionException */ - private function generateProxyClass( string $class, BuildOutput $buildOutput ): string { + private function buildClass( string $class, BuildOutput $buildOutput, int $pass, ?string $extends = null ): string { // Generate a unique proxy class name by replacing backslashes in the class name. - $proxiedClassName = str_replace( "\\", '_', $class ) . 'Proxy'; + $proxiedClassName = str_replace( "\\", '_', $class ) ."__x$pass"; // Define whether the proxy class extends or implements the original class. $inheritanceType = $this->reflect->isInterface( $class ) ? 'implements' : 'extends'; @@ -89,19 +106,25 @@ private function generateProxyClass( string $class, BuildOutput $buildOutput ): $traits = "\tuse " . $buildOutput->traits->join( "," ) . ';'; } + $methods = $buildOutput->methods->map( fn( $method, $code ) => [ $method => $buildOutput->getMethodAnnotations( $method )->join() . $code ] ); + // Construct the full class definition. $proxiedClass = sprintf( - "\nclass %s %s %s {\n%s\n%s\n%s\n}", + "\nclass %s %s %s {\n%s\n%s\n\n%s\n}", $proxiedClassName, $inheritanceType, - $class, + $extends ?? $class, $traits, "\t" . $buildOutput->properties->join( "\n\t" ), - "\t" . $buildOutput->methods->join( "\n\t" ), + "\t" . $methods->join( "\n\t" ), ); /* @TODO Replace with a component that allows for different behaviors besides eval. */ // Dynamically evaluate the class definition using eval. + if ( defined( "DEBUG_CLASS_BUILD_OUTPUT" ) && DEBUG_CLASS_BUILD_OUTPUT ) { + var_dump( $proxiedClass ); + } + eval( $proxiedClass ); return $proxiedClassName; diff --git a/src/Code/AnnotationCodeGenerator.php b/src/Code/AnnotationCodeGenerator.php new file mode 100644 index 0000000..47e7a70 --- /dev/null +++ b/src/Code/AnnotationCodeGenerator.php @@ -0,0 +1,107 @@ +reflect->getReflectionClass( $annotation::class ); + + $ctor = $refClass->getConstructor(); + $params = $ctor ? $ctor->getParameters() : []; + + $parts = []; + foreach ( $params as $param ) { + $name = $param->getName(); + // read the public property of the same name + $value = $annotation->$name; + + // skip if matches default + if ( $param->isDefaultValueAvailable() && + $param->getDefaultValue() === $value + ) { + continue; + } + + if ( $param->isVariadic() ) { + $parts[] = '...' . $this->serializeValue( $value ); + } else { + $parts[] = $name . ': ' . $this->serializeValue( $value ); + } + } + + return $annotation::class . '(' . implode( ', ', $parts ) . ')'; + } + + /** + * Recursively serialize PHP values into valid PHP code. + * + * @param mixed $v + * + * @return string + * @throws InvalidArgumentException|ReflectionException + */ + private function serializeValue( mixed $v ): string { + if ( is_array( $v ) ) { + $items = []; + foreach ( $v as $k => $e ) { + if ( is_string( $k ) ) { + $items[] = var_export( $k, true ) . ' => ' . $this->serializeValue( $e ); + } else { + $items[] = $this->serializeValue( $e ); + } + } + + return '[' . implode( ', ', $items ) . ']'; + } + + if ( is_string( $v ) || is_int( $v ) || is_float( $v ) || is_bool( $v ) || is_null( $v ) ) { + if ( is_string( $v ) || is_null( $v ) ) { + return var_export( $v, true ); + } + + return $v ? 'true' : ( $v === false ? 'false' : (string) $v ); + } + + // nested attribute? + if ( is_object( $v ) ) { + $nestedRef = new ReflectionClass( $v ); + if ( $nestedRef->isAttribute() ) { + // strip leading "#[" and trailing "]" from nested serialization + $raw = $this->serializeAnnotation( $v ); + + return substr( $raw, 2, - 1 ); + } + } + + throw new InvalidArgumentException( 'Cannot serialize value of type ' . get_debug_type( $v ) ); + } +} diff --git a/src/Code/MethodCodeGenerator.php b/src/Code/MethodCodeGenerator.php index cbd8ae9..90523f3 100644 --- a/src/Code/MethodCodeGenerator.php +++ b/src/Code/MethodCodeGenerator.php @@ -39,10 +39,6 @@ public function __construct( public function implementMethodSignature( string $class, string $method ): string { $rMethod = $this->reflectionUtils->getClassMethod( $class, $method ); - if ( $rMethod->isPrivate() || ! $rMethod->isAbstract() ) { - throw new Exception( "Can't implement non-abstract or private method $class::{$method}()" ); - } - $visibility = $rMethod->isPublic() ? 'public' : 'protected'; $arguments = listFrom( $rMethod->getParameters() ) diff --git a/src/Collection/Klist.php b/src/Collection/Klist.php index 5b61a11..6dcf0fd 100644 --- a/src/Collection/Klist.php +++ b/src/Collection/Klist.php @@ -17,7 +17,7 @@ class Klist implements CollectionInterface { /** * @param array $array - * @param bool $mutable + * @param bool $mutable */ public function __construct( array $array = [], private readonly bool $mutable = false ) { $this->internalMap = new Kmap( $array, $this->mutable ); @@ -116,7 +116,7 @@ public function merge( CollectionInterface $collection ): static { } #[Override] - public function join( string $separator ): string { + public function join( string $separator = '' ): string { return $this->internalMap->join( $separator ); } diff --git a/src/Container/Annotation/DefaultImplementation.php b/src/Container/Annotation/DefaultImplementation.php new file mode 100644 index 0000000..06556b8 --- /dev/null +++ b/src/Container/Annotation/DefaultImplementation.php @@ -0,0 +1,20 @@ + $className + */ + public function __construct( + public readonly string $className, + ) { + } +} \ No newline at end of file diff --git a/src/Container/Annotation/Inject.php b/src/Container/Annotation/Inject.php index f5d3f0f..2fd9c89 100644 --- a/src/Container/Annotation/Inject.php +++ b/src/Container/Annotation/Inject.php @@ -14,13 +14,13 @@ #[Attribute( Attribute::TARGET_PROPERTY )] class Inject extends Annotation { /** + * @template T * Constructor for the Inject annotation. * - * @param array $args Arguments to be injected into the property. + * @param class-string|null $class */ public function __construct( public readonly ?string $class = null, - public readonly array $args = [], ) { } } diff --git a/src/Container/CircularReferenceGuard.php b/src/Container/CircularReferenceGuard.php new file mode 100644 index 0000000..552cfaa --- /dev/null +++ b/src/Container/CircularReferenceGuard.php @@ -0,0 +1,25 @@ + */ + private array $stack = []; + + public function enter( string $class ): void { + if ( isset( $this->stack[ $class ] ) ) { + throw new CircularReferenceException( $class ); + } + $this->stack[ $class ] = true; + } + + public function leave( string $class ): void { + unset( $this->stack[ $class ] ); + } +} diff --git a/src/Container/Container.php b/src/Container/Container.php index 550bab4..b642b51 100644 --- a/src/Container/Container.php +++ b/src/Container/Container.php @@ -1,295 +1,97 @@ reflect = new ReflectionUtils(); - $this->instances[ ReflectionUtils::class ] = $this->reflect; - - $this->annotationReader = new AnnotationReader( - container: $this, - reflection: $this->reflect, - ); - $this->instances[ AnnotationReader::class ] = $this->annotationReader; - - $this->classBuilder = new ClassBuilder( - reflect: $this->reflect, - reader: $this->annotationReader, - ); - $this->instances[ ClassBuilder::class ] = $this->classBuilder; - $this->instances[ self::class ] = $this; - } - - /** - * Adds a class instance to the container. - * - * @param string $class The class name. - * @param object $instance The instance of the class. - */ - public function addClassInstance( string $class, object $instance ): void { - $this->instances[ $class ] = $instance; + $this->guard = new CircularReferenceGuard; + $reflect = new ReflectionUtils; + $reader = new AnnotationReader( $reflect ); + $this->resolver = new DependencyResolver( $reflect, $this, $reader ); + $annotationService = new AnnotationService( $reader, $this->resolver ); + $this->builder = new ClassBuilder( $reflect, $annotationService ); + + // preset self + $this->singletons->add( self::class, $this ); } /** - * Adds a value (e.g., config or constant) to the container. - * - * @psalm-suppress PossiblyUnusedMethod - * - * @param string $name The name of the value. - * @param mixed $value The value to add. + * @throws Exception */ - public function addValue( string $name, mixed $value ): void { - $this->values[ $this->getValueKey( $name ) ] = $value; + public function addValue( string $key, mixed $value ): void { + $this->values->add( $key, $value ); } /** - * Binds an interface or class to a specific implementation. - * - * @param string $classOrInterface The class or interface name. - * @param string $class The class name to bind. + * @throws Exception */ - public function bind( string $classOrInterface, string $class ): void { - $this->bindings[ $classOrInterface ] = $class; + public function addClassInstance( string $class, object $instance ): void { + $this->singletons->add( $class, $instance ); } /** - * Retrieves a dependency from the container. - * - * @param string | string $dependencyName The name of the dependency. - * - * @return T| mixed The resolved dependency. - * @throws Exception If the dependency cannot be resolved. + * @throws Exception */ - public function get( string $dependencyName ): mixed { - try { - return $this->seekDependency( $dependencyName ); - } catch ( UnresolvedDependencyException ) { - // If not found, attempt autowiring - $class = $this->buildClass( $dependencyName ); - - return $this->autoWire( $class ); - } + public function bind( string $abstract, string $concrete ): void { + $this->bindings->add( $abstract, $concrete ); } /** - * Autowires the class, injecting its dependencies. - * - * @param string $class The class to autowire. - * - * @return object The autowired instance. - * @throws Exception If autowiring fails or circular references are detected. + * @template T + * @throws Exception + * @param class-string $id + * @return T|mixed */ - private function autoWire( string $class ): object { - $this->checkCircularReference( $class ); - $this->addAutoWiring( $class ); - - // Instantiate and inject dependencies - $instance = new $class( ...$this->autoWireConstructorArguments( $class ) ); - $this->applyPropertyInjection( $instance ); - - $this->addClassInstance( $class, $instance ); - $this->removeAutoWiring( $class ); - - return $instance; - } - - /** - * Applies property injection to an instance based on the Inject annotation. - * - * @param object $instance The instance to inject. - * - * @throws Exception If the dependency cannot be resolved. - */ - public function applyPropertyInjection( object $instance ): void { - $propertiesToInject = $this->reflect->getAnnotatedProperties( $instance::class, Inject::class ); - - foreach ( $propertiesToInject as $property ) { - /** @var Inject $annotation */ - $annotation = $this->annotationReader->getPropertyAnnotation( $instance::class, $property->name, Inject::class ); - - if ( ! empty( $annotation->args ) ) { - $type = $property->type; - - if ( ! is_string( $type ) || ! class_exists( $type ) ) { - throw new RuntimeException( "Cannot instantiate property {$property->name}: missing or invalid type." ); - } - - $value = new $type( ...$annotation->args ); - } elseif ( ! empty( $annotation->class ) ) { - $value = $this->get( $annotation->class ); - } else { - $value = $this->get( $property->type ); - } - - // Set the property value - $this->reflect->setPropertyValue( $instance, $property->name, $value ); + public function get( string $id ): mixed { + // Do we have a singleton for it + if ( isset( $this->singletons[ $id ] ) ) { + return $this->singletons[ $id ]; } - } - - /** - * Resolves constructor arguments via autowiring. - * - * @param string $class The class name. - * - * @return array The resolved constructor arguments. - * @throws Exception If the dependencies cannot be resolved. - */ - private function autoWireConstructorArguments( string $class ): array { - return $this->reflect - ->getConstructorArguments( $class ) - ->map( fn( Argument $arg ) => $this->getFromArgument( $arg ) ) - ->toArray(); - } - - /** - * Resolves the value or service for the given argument. - * - * @param Argument $arg The argument to resolve. - * - * @return mixed The resolved value or dependency. - * @throws Exception If the dependency cannot be resolved. - */ - private function getFromArgument( Argument $arg ): mixed { - return $this->get( in_array( $arg->type, [ 'string', 'int', 'bool' ] ) ? $arg->name : $arg->type ); - } - /** - * Checks if there is a circular reference during autowiring. - * - * @param string $class The class name. - * - * @throws CircularReferenceException If a circular reference is detected. - */ - private function checkCircularReference( string $class ): void { - if ( isset( $this->autoWiring[ $class ] ) ) { - throw new CircularReferenceException( $class ); + // Do we hava a value stored for it + if ( isset( $this->values[ $id ] ) ) { + return $this->values[ $id ]; } - } - /** - * Seeks and returns the dependency from bindings, instances, or values. - * - * @param string $dependencyName The name of the dependency. - * - * @return mixed The resolved dependency. - * @throws UnresolvedDependencyException If the dependency cannot be resolved. - */ - private function seekDependency( string $dependencyName ): mixed { - $dependencyName = $this->bindings[ $dependencyName ] ?? $dependencyName; + // Check for a concrete binding or fallback to the id + $type = $this->bindings[ $id ] ?? $id; - return $this->instances[ $dependencyName ] - ?? $this->values[ $this->getValueKey( $dependencyName ) ] - ?? throw new UnresolvedDependencyException( $dependencyName ); - } + // Build class + $className = $this->builder->build( $type ); - /** - * Builds a class and binds it to the container. - * - * @param string $dependency The dependency name. - * - * @return string The built class name. - * @throws AutowireDependencyException If the class cannot be built. - */ - private function buildClass( string $dependency ): string { - $dependency = $this->bindings[ $dependency ] ?? $dependency; + // Autowire the class try { - $class = $this->classBuilder->build( $dependency ); - $this->bind( $dependency, $class ); - - return $class; - } catch ( ClassAlreadyBuiltException ) { - return $this->bindings[ $dependency ]; - } catch ( ReflectionException $exception ) { - throw new AutowireDependencyException( end( $this->autoWiring ), $dependency, $exception ); + $this->guard->enter( $className ); + $instance = $this->resolver->autowire( $className ); + $this->resolver->applyPropertyInjection( $instance ); + $this->singletons->add( $id, $instance ); + $this->guard->leave( $className ); + + return $instance; + } catch ( ReflectionException $e ) { + // Map a generic ReflectionException to a more specific one. + throw new AutowireDependencyException( $className, $type, $e ); } } - - /** - * Adds a class to the auto-wiring tracking to detect circular references. - * - * @param string $class The class name. - */ - private function addAutoWiring( string $class ): void { - $this->autoWiring[ $class ] = $class; - } - - /** - * Removes a class from the auto-wiring tracking. - * - * @param string $class The class name. - */ - private function removeAutoWiring( string $class ): void { - unset( $this->autoWiring[ $class ] ); - } - - /** - * Generates a value key for internal value storage. - * - * @param string $name The base name. - * - * @return string The generated value key. - */ - private function getValueKey( string $name ): string { - return "container.value.$name"; - } } diff --git a/src/Container/DependencyResolver.php b/src/Container/DependencyResolver.php new file mode 100644 index 0000000..00f5265 --- /dev/null +++ b/src/Container/DependencyResolver.php @@ -0,0 +1,76 @@ +reflect + ->getConstructorArguments( $class ) + ->map( fn( Argument $arg ) => $this->resolveArgument( $arg ) ) + ->toArray(); + + if ( ! class_exists( $class ) ) { + $defaultImplementation = $this->annotationReader->getClassAnnotations( $class, DefaultImplementation::class )->firstOrNull(); + if ( $defaultImplementation === null ) { + throw new Exception( "Class $class not found and no default implementation provided." ); + } + + return $this->autowire( $defaultImplementation->className ); + } + + return new $class( ...$args ); + } + + /** + * @template T + * @param object $instance + * + * @return T + * @throws ReflectionException + * @throws Exception + */ + public function applyPropertyInjection( object $instance ): object { + foreach ( $this->reflect->getAnnotatedProperties( $instance::class, Inject::class ) as $arg ) { + $annotation = $this->annotationReader->getPropertyAnnotation( $instance::class, $arg->name, Inject::class ); + + $value = $this->container->get( $annotation->class ?? $arg->type ?? $arg->name ); + + $this->reflect->setPropertyValue( $instance, $arg->name, $value ); + } + + return $instance; + } + + /** + * @throws Exception + */ + private function resolveArgument( Argument $arg ): mixed { + $dependencyOrValue = in_array( $arg->type, [ 'string', 'int', 'bool' ] ) ? $arg->name : $arg->type; + + return $this->container->get( $dependencyOrValue ); + } +} diff --git a/src/MethodExecution/Builder/MethodExecutionBuildHandler.php b/src/MethodExecution/Builder/MethodExecutionBuildHandler.php index d5e4531..ca0fcc5 100644 --- a/src/MethodExecution/Builder/MethodExecutionBuildHandler.php +++ b/src/MethodExecution/Builder/MethodExecutionBuildHandler.php @@ -5,7 +5,10 @@ use Axpecto\Annotation\BuildAnnotation; use Axpecto\ClassBuilder\BuildHandler; use Axpecto\ClassBuilder\BuildOutput; +use Axpecto\Code\AnnotationCodeGenerator; use Axpecto\Code\MethodCodeGenerator; +use Axpecto\Reflection\ReflectionUtils; +use Exception; use Override; use ReflectionException; @@ -22,10 +25,14 @@ class MethodExecutionBuildHandler implements BuildHandler { /** * MethodExecutionBuildHandler constructor. * - * @param MethodCodeGenerator $code + * @param MethodCodeGenerator $methodCoder + * @param AnnotationCodeGenerator $annotationCoder + * @param ReflectionUtils $reflection */ public function __construct( - protected readonly MethodCodeGenerator $code, + protected readonly MethodCodeGenerator $methodCoder, + protected readonly AnnotationCodeGenerator $annotationCoder, + protected readonly ReflectionUtils $reflection, ) { } @@ -38,17 +45,27 @@ public function __construct( * @param BuildOutput $buildOutput The current build context to modify. * * @throws ReflectionException If reflection on the method or class fails. + * @throws Exception */ #[Override] public function intercept( BuildAnnotation $annotation, BuildOutput $buildOutput ): void { $class = $annotation->getAnnotatedClass(); $method = $annotation->getAnnotatedMethod(); + $isAbstract = $this->reflection->getClassMethod( $class, $method )->isAbstract(); + + if ( $isAbstract ) { + // Interfaces don't have parent implementations, we add the annotation back and wait for the next build pass. + $annotationCode = $this->annotationCoder->serializeAnnotation( $annotation ); + $buildOutput->annotateMethod( $method, $annotationCode ); + return; + } + // Define properties and add them as injectable dependencies. $buildOutput->injectProperty( self::PROXY_PROPERTY_NAME, MethodExecutionProxy::class ); // Generate the method signature and implementation using reflection. - $methodSignature = $this->code->implementMethodSignature( $class, $method ); + $methodSignature = $this->methodCoder->implementMethodSignature( $class, $method ); $implementation = "return \$this->" . self::PROXY_PROPERTY_NAME . "->handle('$class', '$method', parent::$method(...), func_get_args());"; // Add the method and proxy property to the context output. diff --git a/src/MethodExecution/Builder/MethodExecutionProxy.php b/src/MethodExecution/Builder/MethodExecutionProxy.php index 78467f2..afefbe2 100644 --- a/src/MethodExecution/Builder/MethodExecutionProxy.php +++ b/src/MethodExecution/Builder/MethodExecutionProxy.php @@ -2,8 +2,7 @@ namespace Axpecto\MethodExecution\Builder; -use Axpecto\Annotation\Annotation; -use Axpecto\Annotation\AnnotationReader; +use Axpecto\Annotation\AnnotationService; use Axpecto\Annotation\MethodExecutionAnnotation; use Axpecto\MethodExecution\MethodExecutionContext; use Axpecto\Reflection\ReflectionUtils; @@ -23,11 +22,11 @@ class MethodExecutionProxy { * @psalm-suppress PossiblyUnusedMethod * * @param ReflectionUtils $reflect The reflection utility instance for handling class/method reflection. - * @param AnnotationReader $reader Reads annotations for the given class and method. + * @param AnnotationService $annotationService Reads annotations for the given class and method. */ public function __construct( private readonly ReflectionUtils $reflect, - private readonly AnnotationReader $reader, + private readonly AnnotationService $annotationService, ) { } @@ -54,7 +53,7 @@ public function handle( array $arguments, ): mixed { // Get method annotations - $annotations = $this->reader->getMethodAnnotations( $class, $method, MethodExecutionAnnotation::class ); + $annotations = $this->annotationService->getMethodAnnotations( $class, $method, MethodExecutionAnnotation::class ); // Resolve method arguments using reflection $mappedArguments = $this->reflect->mapValuesToArguments( $class, $method, $arguments ); diff --git a/src/MethodExecution/MethodExecutionContext.php b/src/MethodExecution/MethodExecutionContext.php index 5bd3aef..f3ccfd6 100644 --- a/src/MethodExecution/MethodExecutionContext.php +++ b/src/MethodExecution/MethodExecutionContext.php @@ -36,13 +36,12 @@ public function __construct( } /** - * Get the current annotation being processed. - * - * @psalm-suppress PossiblyUnusedMethod - * - * @return Annotation|null + * @template T of Annotation + * @psalm-param class-string|null $_class the FQCN of the annotation you want + * @psalm-return T|null the current annotation, or null if none/mismatched */ - public function getAnnotation(): ?Annotation { + public function getAnnotation( ?string $_class = null ): ?Annotation { + /** @var T|null */ return $this->currentAnnotation; } diff --git a/src/MethodExecution/MethodExecutionHandler.php b/src/MethodExecution/MethodExecutionHandler.php index 609efde..54c9d8e 100644 --- a/src/MethodExecution/MethodExecutionHandler.php +++ b/src/MethodExecution/MethodExecutionHandler.php @@ -21,9 +21,9 @@ interface MethodExecutionHandler { * based on the annotation and execution context. Subclasses should override this method to * provide custom behavior during method execution. * - * @param MethodExecutionContext $methodExecutionContext The context of the method being executed. + * @param MethodExecutionContext $context The context of the method being executed. * * @return MethodExecutionContext Modified or original method execution context after interception. */ - public function intercept( MethodExecutionContext $methodExecutionContext ): mixed; + public function intercept( MethodExecutionContext $context ): mixed; } diff --git a/src/Reflection/ReflectionUtils.php b/src/Reflection/ReflectionUtils.php index 0107df1..4f33228 100644 --- a/src/Reflection/ReflectionUtils.php +++ b/src/Reflection/ReflectionUtils.php @@ -12,6 +12,7 @@ use ReflectionMethod; use ReflectionParameter; use ReflectionProperty; +use RuntimeException; /** * ReflectionUtils @@ -72,7 +73,7 @@ public function getMethodAttributes( string $class, string $method ): Klist { public function getAnnotatedMethods( string $class, string $with = Annotation::class ): Klist { return listFrom( $this->getReflectionClass( $class )->getMethods() ) ->filter( fn( ReflectionMethod $m ) => listFrom( $m->getAttributes() ) - ->filter( fn( ReflectionAttribute $attribute ) => $attribute->getName() === $with ) + ->filter( fn( ReflectionAttribute $attribute ) => $attribute->newInstance() instanceof $with ) ->isNotEmpty() ); } @@ -117,10 +118,11 @@ public function getConstructorArguments( string $class ): Klist { } /** - * Fetches properties annotated with a specific annotation and returns an Argument list. + * Fetches all properties (including inherited ones) annotated with a specific annotation, + * and returns an Argument list for each. * - * @param string $class - * @param string $annotationClass + * @param class-string $class The class to inspect. + * @param class-string $annotationClass The annotation you’re looking for. * * @return Klist * @throws ReflectionException diff --git a/src/Repository/Handler/RepositoryBuildHandler.php b/src/Repository/Handler/RepositoryBuildHandler.php index afafaee..0d1e2bf 100644 --- a/src/Repository/Handler/RepositoryBuildHandler.php +++ b/src/Repository/Handler/RepositoryBuildHandler.php @@ -178,7 +178,7 @@ private function generateCriteriaBody( $code .= "\t\treturn \$this->" . self::STORAGE_PROP . "->findAllByCriteria(\$criteria, '$entityClass')\n"; $code .= "\t\t ->map(fn(\$item) => \$this->" . - self::MAPPER_PROP . "->map('$entityClass', \$item));"; + self::MAPPER_PROP . "->mapEntityFromArray('$entityClass', \$item));"; return $code; } diff --git a/src/Telemetry/Annotation/RecordTiming.php b/src/Telemetry/Annotation/RecordTiming.php new file mode 100644 index 0000000..fd614fc --- /dev/null +++ b/src/Telemetry/Annotation/RecordTiming.php @@ -0,0 +1,20 @@ +getAnnotation( RecordTiming::class ); + + if ( ! $annotation->enabled ) { + return $context->proceed(); + } + + $start = microtime( true ); + $result = $context->proceed(); + $end = microtime( true ); + $duration = $end - $start; + + $this->telemetryService->recordTiming( $context->className . '::' . $context->methodName, $duration, $annotation->labels ); + + return $result; + } +} \ No newline at end of file diff --git a/src/Telemetry/EchoTelemetryService.php b/src/Telemetry/EchoTelemetryService.php new file mode 100644 index 0000000..ce2f8e6 --- /dev/null +++ b/src/Telemetry/EchoTelemetryService.php @@ -0,0 +1,95 @@ +format( 'c' ) ?? ( new DateTimeImmutable() )->format( 'c' ); + echo $this->getCurrentTime() . "[Telemetry][Event] $name\n"; + if ( $this->context['verbose'] ?? false ) { + var_dump( $payload ); + } + } + + public function recordCounter( + string $name, + int|float $value = 1, + array $labels = [] + ): void { + echo "[Telemetry][Counter] {$name} += {$value}\n"; + var_dump( $labels ); + } + + public function recordGauge( + string $name, + int|float $value, + array $labels = [] + ): void { + echo "[Telemetry][Gauge] {$name} = {$value}\n"; + var_dump( $labels ); + } + + public function recordTiming( + string $name, + int|float $milliseconds, + array $labels = [] + ): void { + echo $this->getCurrentTime() . "[Telemetry][Timing] {$name} took {$milliseconds} ms\n"; + if ( $this->context['verbose'] ?? false ) { + var_dump( [ 'labels' => $labels, 'context' => $this->context ] ); + } + } + + public function startTrace( string $spanName ): string { + $traceId = uniqid( 'trace_', true ); + echo "[Telemetry][Trace:start] {$spanName} → {$traceId}\n"; + + return $traceId; + } + + public function endTrace( + string $traceId, + bool $success = true, + ?string $error = null + ): void { + $status = $success ? 'success' : 'failure'; + echo "[Telemetry][Trace:end] {$traceId} → {$status}\n"; + if ( $error !== null ) { + echo " Error: {$error}\n"; + } + } + + public function setContext( string $key, string $value ): void { + echo "[Telemetry][Context:set] {$key} = {$value}\n"; + $this->context[ $key ] = $value; + } + + public function unsetContext( string $key ): void { + echo "[Telemetry][Context:unset] {$key}\n"; + if ( isset( $this->context[ $key ] ) ) { + unset( $this->context[ $key ] ); + } + } + + public function flush(): void { + echo "[Telemetry][Flush] all buffered data (no-op)\n"; + } + + private function getCurrentTime(): string { + return "[" . ( new DateTimeImmutable() )->format( 'c' ) . "]"; + } +} \ No newline at end of file diff --git a/src/Telemetry/TelemetryService.php b/src/Telemetry/TelemetryService.php new file mode 100644 index 0000000..ec6b845 --- /dev/null +++ b/src/Telemetry/TelemetryService.php @@ -0,0 +1,128 @@ + $payload Key/value data associated with the event. + * @param DateTimeInterface|null $timestamp When the event occurred (default: now). + * + * @return void + */ + public function recordEvent( + string $name, + array $payload = [], + ?DateTimeInterface $timestamp = null + ): void; + + /** + * Increment (or set) a numeric counter metric. + * + * @param string $name Metric name (e.g. "http_requests_total"). + * @param int|float $value Amount to add (default: 1). + * @param array $labels Labels/dimensions for this metric (e.g. ["method" => "POST"]). + * + * @return void + */ + public function recordCounter( + string $name, + int|float $value = 1, + array $labels = [] + ): void; + + /** + * Record a gauge metric (arbitrary value), e.g. memory usage. + * + * @param string $name Metric name (e.g. "memory_usage_bytes"). + * @param int|float $value Gauge value. + * @param array $labels Labels/dimensions for this metric. + * + * @return void + */ + public function recordGauge( + string $name, + int|float $value, + array $labels = [] + ): void; + + /** + * Record a timing/latency measurement. + * + * @param string $name Metric name (e.g. "db_query_duration_ms"). + * @param int|float $milliseconds Duration in milliseconds. + * @param array $labels Labels/dimensions for this measurement. + * + * @return void + */ + public function recordTiming( + string $name, + int|float $milliseconds, + array $labels = [] + ): void; + + /** + * Begin a named trace/span. + * + * @param string $spanName A human-readable span name (e.g. "http_request"). + * + * @return string A trace/span identifier to pass to `endTrace()`. + */ + public function startTrace( string $spanName ): string; + + /** + * End a previously started trace/span, optionally with success/failure status. + * + * @param string $traceId The ID returned by `startTrace()`. + * @param bool $success Whether the operation succeeded (default: true). + * @param string|null $error Optional error message if `success === false`. + * + * @return void + */ + public function endTrace( + string $traceId, + bool $success = true, + ?string $error = null + ): void; + + /** + * Add a key/value pair to the current telemetry context (tags/labels + * that will be automatically attached to all subsequent events/metrics). + * + * @param string $key + * @param string $value + * + * @return void + */ + public function setContext( string $key, string $value ): void; + + /** + * Remove a key from the current telemetry context. + * + * @param string $key + * + * @return void + */ + public function unsetContext( string $key ): void; + + /** + * Flush any buffered telemetry data to the back-end. + * + * @return void + */ + public function flush(): void; +} \ No newline at end of file