mirror of
https://github.com/symfony/ai.git
synced 2026-03-23 23:42:18 +01:00
[Mate][Symfony] Add optional profiler data access capabilities
This commit is contained in:
committed by
Christopher Hertel
parent
bf209846cf
commit
3c8ba99e60
@@ -53,6 +53,10 @@ final class ForbidNativeExceptionRule implements Rule
|
||||
'Symfony\\AI\\Store' => 'Symfony\\AI\\Store\\Exception\\',
|
||||
'Symfony\\AI\\AiBundle' => 'Symfony\\AI\\AiBundle\\Exception\\',
|
||||
'Symfony\\AI\\McpBundle' => 'Symfony\\AI\\McpBundle\\Exception\\',
|
||||
'Symfony\\AI\\Mate\\Bridge\\Profiler' => 'Symfony\\AI\\Mate\\Bridge\\Profiler\\Exception\\',
|
||||
'Symfony\\AI\\Mate\\Bridge\\Monolog' => 'Symfony\\AI\\Mate\\Bridge\\Monolog\\Exception\\',
|
||||
'Symfony\\AI\\Mate\\Bridge\\Symfony' => 'Symfony\\AI\\Mate\\Bridge\\Symfony\\Exception\\',
|
||||
'Symfony\\AI\\Mate' => 'Symfony\\AI\\Mate\\Exception\\',
|
||||
];
|
||||
|
||||
public function getNodeType(): string
|
||||
|
||||
@@ -260,6 +260,96 @@ log files are stored.
|
||||
2. Check file permissions on log files
|
||||
3. Ensure log files are not empty or corrupted
|
||||
|
||||
Profiler Bridge
|
||||
~~~~~~~~~~~~~~~
|
||||
|
||||
The Profiler bridge (``symfony/ai-profiler-mate-extension``) provides MCP tools and resources
|
||||
for accessing Symfony profiler data:
|
||||
|
||||
**MCP Tools:**
|
||||
|
||||
* ``profiler-list`` - List available profiler profiles with summary data
|
||||
* ``profiler-latest`` - Get the latest profiler profile summary
|
||||
* ``profiler-search`` - Search profiles by criteria (route, method, status code, date range)
|
||||
* ``profiler-get`` - Get a specific profile by token
|
||||
|
||||
All tools return profiles with a ``resource_uri`` field that points to the full profile resource.
|
||||
|
||||
**MCP Resources:**
|
||||
|
||||
* ``profiler://profile/{token}`` - Full profile details including metadata and list of available collectors with URIs
|
||||
* ``profiler://profile/{token}/{collector}`` - Detailed collector-specific data (request, response, exception, events, etc.)
|
||||
|
||||
**Configuration:**
|
||||
|
||||
Single profiler directory (default)::
|
||||
|
||||
$container->parameters()
|
||||
->set('ai_mate_profiler.profiler_dir', '%mate.root_dir%/var/cache/dev/profiler');
|
||||
|
||||
Multiple directories with contexts (e.g., for multi-kernel applications)::
|
||||
|
||||
$container->parameters()
|
||||
->set('ai_mate_profiler.profiler_dir', [
|
||||
'website' => '%mate.root_dir%/var/cache/website/dev/profiler',
|
||||
'admin' => '%mate.root_dir%/var/cache/admin/dev/profiler',
|
||||
]);
|
||||
|
||||
When using multiple directories, profiles include a ``context`` field for filtering.
|
||||
|
||||
**Example Usage:**
|
||||
|
||||
Search for errors::
|
||||
|
||||
// Using profiler-search tool
|
||||
{
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "profiler-search",
|
||||
"arguments": {
|
||||
"statusCode": 500,
|
||||
"limit": 20
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Access full profile via resource::
|
||||
|
||||
// Using resource template
|
||||
{
|
||||
"method": "resources/read",
|
||||
"params": {
|
||||
"uri": "profiler://profile/abc123"
|
||||
}
|
||||
}
|
||||
|
||||
Access specific collector::
|
||||
|
||||
{
|
||||
"method": "resources/read",
|
||||
"params": {
|
||||
"uri": "profiler://profile/abc123/exception"
|
||||
}
|
||||
}
|
||||
|
||||
**Extensibility:**
|
||||
|
||||
Create custom collector formatters by implementing ``CollectorFormatterInterface`` and
|
||||
registering via DI tag ``ai_mate.profiler_collector_formatter``.
|
||||
|
||||
**Troubleshooting:**
|
||||
|
||||
*Profiles not found*:
|
||||
|
||||
1. Ensure the profiler directory parameter points to the correct location
|
||||
2. Verify Symfony profiler is enabled in your environment
|
||||
3. Generate some HTTP requests to create profile data
|
||||
|
||||
*Collector data not available*:
|
||||
|
||||
1. Check that the specific collector is enabled in Symfony profiler configuration
|
||||
2. Verify the profile was captured with that collector active
|
||||
|
||||
Built-in Tools
|
||||
--------------
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ parameters:
|
||||
paths:
|
||||
- src/
|
||||
- tests/
|
||||
excludePaths:
|
||||
- src/Bridge/
|
||||
treatPhpDocTypesAsCertain: false
|
||||
ignoreErrors:
|
||||
-
|
||||
|
||||
@@ -4,6 +4,7 @@ CHANGELOG
|
||||
0.3
|
||||
---
|
||||
|
||||
* Add profiler data access capabilities
|
||||
* Add `INSTRUCTIONS.md` with AI agent guidance for container introspection tools
|
||||
|
||||
0.1
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Mate\Bridge\Symfony\Capability;
|
||||
|
||||
use Mcp\Capability\Attribute\McpResourceTemplate;
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Profiler\Service\ProfilerDataProvider;
|
||||
|
||||
/**
|
||||
* MCP resource templates for accessing Symfony profiler data.
|
||||
*
|
||||
* @phpstan-type ProfileResourceData array{
|
||||
* uri: string,
|
||||
* mimeType: string,
|
||||
* text: string,
|
||||
* }
|
||||
*
|
||||
* @author Johannes Wachter <johannes@sulu.io>
|
||||
*/
|
||||
final class ProfilerResourceTemplate
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProfilerDataProvider $dataProvider,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ProfileResourceData
|
||||
*/
|
||||
#[McpResourceTemplate(
|
||||
uriTemplate: 'symfony-profiler://profile/{token}',
|
||||
name: 'symfony-profile-data',
|
||||
description: 'Full profile details including metadata (method, url, status, time, ip) and complete list of available collectors with URIs for accessing collector-specific data'
|
||||
)]
|
||||
public function getProfileResource(string $token): array
|
||||
{
|
||||
$profileData = $this->dataProvider->findProfile($token);
|
||||
if (null === $profileData) {
|
||||
return [
|
||||
'uri' => "symfony-profiler://profile/{$token}",
|
||||
'mimeType' => 'application/json',
|
||||
'text' => json_encode(['error' => 'Profile not found'], \JSON_PRETTY_PRINT) ?: '{}',
|
||||
];
|
||||
}
|
||||
|
||||
$profile = $profileData->profile;
|
||||
$collectors = $this->dataProvider->listAvailableCollectors($token);
|
||||
|
||||
$collectorResources = [];
|
||||
foreach ($collectors as $collectorName) {
|
||||
$collectorResources[] = [
|
||||
'name' => $collectorName,
|
||||
'uri' => \sprintf('symfony-profiler://profile/%s/%s', $token, $collectorName),
|
||||
];
|
||||
}
|
||||
|
||||
$data = [
|
||||
'token' => $profile->getToken(),
|
||||
'method' => $profile->getMethod(),
|
||||
'url' => $profile->getUrl(),
|
||||
'status_code' => $profile->getStatusCode(),
|
||||
'time' => $profile->getTime(),
|
||||
'ip' => $profile->getIp(),
|
||||
'parent_profile' => $profile->getParentToken() ? \sprintf('symfony-profiler://profile/%s', $profile->getParentToken()) : null,
|
||||
'collectors' => $collectorResources,
|
||||
];
|
||||
|
||||
if (null !== $profileData->context) {
|
||||
$data['context'] = $profileData->context;
|
||||
}
|
||||
|
||||
return [
|
||||
'uri' => "symfony-profiler://profile/{$token}",
|
||||
'mimeType' => 'application/json',
|
||||
'text' => json_encode($data, \JSON_PRETTY_PRINT) ?: '{}',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ProfileResourceData
|
||||
*/
|
||||
#[McpResourceTemplate(
|
||||
uriTemplate: 'symfony-profiler://profile/{token}/{collector}',
|
||||
name: 'symfony-collector-data',
|
||||
description: 'Detailed collector-specific data (e.g., request parameters, response content, database queries, events, exceptions). Use symfony-profiler://profile/{token} resource to discover available collectors'
|
||||
)]
|
||||
public function getCollectorResource(string $token, string $collector): array
|
||||
{
|
||||
try {
|
||||
$data = $this->dataProvider->getCollectorData($token, $collector);
|
||||
|
||||
return [
|
||||
'uri' => "symfony-profiler://profile/{$token}/{$collector}",
|
||||
'mimeType' => 'application/json',
|
||||
'text' => json_encode($data, \JSON_PRETTY_PRINT) ?: '{}',
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return [
|
||||
'uri' => "symfony-profiler://profile/{$token}/{$collector}",
|
||||
'mimeType' => 'application/json',
|
||||
'text' => json_encode(['error' => $e->getMessage()]) ?: '{}',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
133
src/mate/src/Bridge/Symfony/Capability/ProfilerTool.php
Normal file
133
src/mate/src/Bridge/Symfony/Capability/ProfilerTool.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Mate\Bridge\Symfony\Capability;
|
||||
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Profiler\Model\ProfileIndex;
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Profiler\Service\ProfilerDataProvider;
|
||||
use Symfony\AI\Mate\Exception\InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* MCP tools for accessing Symfony profiler data.
|
||||
*
|
||||
* @author Johannes Wachter <johannes@sulu.io>
|
||||
*
|
||||
* @phpstan-import-type ProfileIndexData from ProfileIndex
|
||||
*/
|
||||
final class ProfilerTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProfilerDataProvider $dataProvider,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ProfileIndexData>
|
||||
*/
|
||||
#[McpTool('symfony-profiler-list', 'List available profiler profiles. Returns summary data with resource_uri field - use the resource_uri to fetch full profile details including collectors')]
|
||||
public function listProfiles(
|
||||
int $limit = 20,
|
||||
?string $method = null,
|
||||
?string $url = null,
|
||||
?string $ip = null,
|
||||
?int $statusCode = null,
|
||||
?string $context = null,
|
||||
): array {
|
||||
$criteria = [
|
||||
'context' => $context,
|
||||
'method' => $method,
|
||||
'url' => $url,
|
||||
'ip' => $ip,
|
||||
'statusCode' => $statusCode,
|
||||
];
|
||||
|
||||
$profiles = $this->dataProvider->searchProfiles(array_filter($criteria), $limit);
|
||||
|
||||
return array_values(array_map(
|
||||
static fn (ProfileIndex $profile): array => $profile->toArray(),
|
||||
$profiles,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ProfileIndexData|null
|
||||
*/
|
||||
#[McpTool('symfony-profiler-latest', 'Get the latest profiler profile. Returns summary data with resource_uri field - use the resource_uri to fetch full profile details including collectors')]
|
||||
public function getLatestProfile(): ?array
|
||||
{
|
||||
$profile = $this->dataProvider->getLatestProfile();
|
||||
|
||||
return $profile?->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ProfileIndexData>
|
||||
*/
|
||||
#[McpTool('symfony-profiler-search', 'Search profiles by criteria. Returns summary data with resource_uri field - use the resource_uri to fetch full profile details including collectors')]
|
||||
public function searchProfiles(
|
||||
?string $route = null,
|
||||
?string $method = null,
|
||||
?int $statusCode = null,
|
||||
?string $from = null,
|
||||
?string $to = null,
|
||||
?string $context = null,
|
||||
int $limit = 20,
|
||||
): array {
|
||||
$criteria = [
|
||||
'context' => $context,
|
||||
'url' => $route,
|
||||
'method' => $method,
|
||||
'statusCode' => $statusCode,
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
];
|
||||
|
||||
$profiles = $this->dataProvider->searchProfiles(array_filter($criteria), $limit);
|
||||
|
||||
return array_values(array_map(
|
||||
static fn (ProfileIndex $profile): array => $profile->toArray(),
|
||||
$profiles,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ProfileIndexData
|
||||
*/
|
||||
#[McpTool('symfony-profiler-get', 'Get a specific profile by token. Returns summary data with resource_uri field - use the resource_uri to fetch full profile details including collectors')]
|
||||
public function getProfile(string $token): array
|
||||
{
|
||||
$profileData = $this->dataProvider->findProfile($token);
|
||||
|
||||
if (null === $profileData) {
|
||||
throw new InvalidArgumentException(\sprintf('Profile with token "%s" not found', $token));
|
||||
}
|
||||
|
||||
$profile = $profileData->profile;
|
||||
$data = [
|
||||
'token' => $profile->getToken(),
|
||||
'ip' => $profile->getIp(),
|
||||
'method' => $profile->getMethod(),
|
||||
'url' => $profile->getUrl(),
|
||||
'time' => $profile->getTime(),
|
||||
'time_formatted' => date(\DateTimeInterface::ATOM, $profile->getTime()),
|
||||
'status_code' => $profile->getStatusCode(),
|
||||
'parent_token' => $profile->getParentToken(),
|
||||
'resource_uri' => \sprintf('symfony-profiler://profile/%s', $profile->getToken()),
|
||||
];
|
||||
|
||||
if (null !== $profileData->context) {
|
||||
$data['context'] = $profileData->context;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,27 @@
|
||||
## Symfony Bridge
|
||||
|
||||
Use MCP tools instead of CLI for container introspection:
|
||||
### Container Introspection
|
||||
|
||||
| Instead of... | Use |
|
||||
|--------------------------------|---------------------|
|
||||
| `bin/console debug:container` | `symfony-services` |
|
||||
|
||||
### Benefits
|
||||
|
||||
- Direct access to compiled container
|
||||
- Environment-aware (auto-detects dev/test/prod)
|
||||
- Structured service ID → class mapping
|
||||
|
||||
### Profiler Access
|
||||
|
||||
When `symfony/http-kernel` is installed, profiler tools become available:
|
||||
|
||||
| Tool | Description |
|
||||
|-----------------------------|--------------------------------------------|
|
||||
| `symfony-profiler-list` | List profiles with optional filtering |
|
||||
| `symfony-profiler-latest` | Get the most recent profile |
|
||||
| `symfony-profiler-search` | Search by route, method, status, date |
|
||||
| `symfony-profiler-get` | Get profile by token |
|
||||
|
||||
**Resources:**
|
||||
- `symfony-profiler://profile/{token}` - Full profile with collector list
|
||||
- `symfony-profiler://profile/{token}/{collector}` - Collector-specific data
|
||||
|
||||
**Security:** Cookies, session data, auth headers, and sensitive env vars are automatically redacted.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Mate\Bridge\Symfony\Profiler\Exception;
|
||||
|
||||
use Symfony\AI\Mate\Exception\InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* @author Johannes Wachter <johannes@sulu.io>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class InvalidCollectorException extends InvalidArgumentException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Mate\Bridge\Symfony\Profiler\Exception;
|
||||
|
||||
use Symfony\AI\Mate\Exception\InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* @author Johannes Wachter <johannes@sulu.io>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class ProfileNotFoundException extends InvalidArgumentException
|
||||
{
|
||||
}
|
||||
31
src/mate/src/Bridge/Symfony/Profiler/Model/CollectorData.php
Normal file
31
src/mate/src/Bridge/Symfony/Profiler/Model/CollectorData.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Mate\Bridge\Symfony\Profiler\Model;
|
||||
|
||||
/**
|
||||
* @author Johannes Wachter <johannes@sulu.io>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class CollectorData
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @param array<string, mixed> $summary
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $name,
|
||||
public readonly array $data,
|
||||
public readonly array $summary,
|
||||
) {
|
||||
}
|
||||
}
|
||||
30
src/mate/src/Bridge/Symfony/Profiler/Model/ProfileData.php
Normal file
30
src/mate/src/Bridge/Symfony/Profiler/Model/ProfileData.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Mate\Bridge\Symfony\Profiler\Model;
|
||||
|
||||
use Symfony\Component\HttpKernel\Profiler\Profile;
|
||||
|
||||
/**
|
||||
* Wrapper around Symfony's Profile class with context tracking for multi-directory support.
|
||||
*
|
||||
* @author Johannes Wachter <johannes@sulu.io>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class ProfileData
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Profile $profile,
|
||||
public readonly ?string $context = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
70
src/mate/src/Bridge/Symfony/Profiler/Model/ProfileIndex.php
Normal file
70
src/mate/src/Bridge/Symfony/Profiler/Model/ProfileIndex.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Mate\Bridge\Symfony\Profiler\Model;
|
||||
|
||||
/**
|
||||
* @author Johannes Wachter <johannes@sulu.io>
|
||||
*
|
||||
* @phpstan-type ProfileIndexData array{
|
||||
* token: string,
|
||||
* ip: string,
|
||||
* method: string,
|
||||
* url: string,
|
||||
* time: int,
|
||||
* time_formatted: non-falsy-string,
|
||||
* status_code: int|null,
|
||||
* parent_token: string|null,
|
||||
* context?: string,
|
||||
* resource_uri: string,
|
||||
* }
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class ProfileIndex
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $token,
|
||||
public readonly string $ip,
|
||||
public readonly string $method,
|
||||
public readonly string $url,
|
||||
public readonly int $time,
|
||||
public readonly ?int $statusCode = null,
|
||||
public readonly ?string $parentToken = null,
|
||||
public readonly ?string $context = null,
|
||||
public readonly ?string $type = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ProfileIndexData
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$data = [
|
||||
'token' => $this->token,
|
||||
'ip' => $this->ip,
|
||||
'method' => $this->method,
|
||||
'url' => $this->url,
|
||||
'time' => $this->time,
|
||||
'time_formatted' => date(\DateTimeInterface::ATOM, $this->time),
|
||||
'status_code' => $this->statusCode,
|
||||
'parent_token' => $this->parentToken,
|
||||
'resource_uri' => \sprintf('symfony-profiler://profile/%s', $this->token),
|
||||
];
|
||||
|
||||
if (null !== $this->context) {
|
||||
$data['context'] = $this->context;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Mate\Bridge\Symfony\Profiler\Service;
|
||||
|
||||
use Symfony\Component\HttpKernel\DataCollector\DataCollectorInterface;
|
||||
|
||||
/**
|
||||
* Interface for formatting collector data for AI consumption.
|
||||
*
|
||||
* @author Johannes Wachter <johannes@sulu.io>
|
||||
*
|
||||
* @template TCollector of DataCollectorInterface
|
||||
*/
|
||||
interface CollectorFormatterInterface
|
||||
{
|
||||
public function getName(): string;
|
||||
|
||||
/**
|
||||
* @param TCollector $collector
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function format(DataCollectorInterface $collector): array;
|
||||
|
||||
/**
|
||||
* @param TCollector $collector
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getSummary(DataCollectorInterface $collector): array;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Mate\Bridge\Symfony\Profiler\Service;
|
||||
|
||||
use Symfony\Component\HttpKernel\DataCollector\DataCollectorInterface;
|
||||
|
||||
/**
|
||||
* Registry for collector formatters.
|
||||
*
|
||||
* @author Johannes Wachter <johannes@sulu.io>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CollectorRegistry
|
||||
{
|
||||
/**
|
||||
* @var array<string, CollectorFormatterInterface<DataCollectorInterface>>
|
||||
*/
|
||||
private array $formatters = [];
|
||||
|
||||
/**
|
||||
* @param iterable<CollectorFormatterInterface<DataCollectorInterface>> $formatters
|
||||
*/
|
||||
public function __construct(iterable $formatters = [])
|
||||
{
|
||||
foreach ($formatters as $formatter) {
|
||||
$this->register($formatter);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param CollectorFormatterInterface<DataCollectorInterface> $formatter
|
||||
*/
|
||||
public function register(CollectorFormatterInterface $formatter): void
|
||||
{
|
||||
$this->formatters[$formatter->getName()] = $formatter;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return CollectorFormatterInterface<DataCollectorInterface>|null
|
||||
*/
|
||||
public function get(string $name): ?CollectorFormatterInterface
|
||||
{
|
||||
return $this->formatters[$name] ?? null;
|
||||
}
|
||||
|
||||
public function has(string $name): bool
|
||||
{
|
||||
return isset($this->formatters[$name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, CollectorFormatterInterface<DataCollectorInterface>>
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
return $this->formatters;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Mate\Bridge\Symfony\Profiler\Service\Formatter;
|
||||
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Profiler\Service\CollectorFormatterInterface;
|
||||
use Symfony\Component\HttpKernel\DataCollector\DataCollectorInterface;
|
||||
use Symfony\Component\HttpKernel\DataCollector\ExceptionDataCollector;
|
||||
|
||||
/**
|
||||
* Formats exception collector data.
|
||||
*
|
||||
* @author Johannes Wachter <johannes@sulu.io>
|
||||
*
|
||||
* @internal
|
||||
*
|
||||
* @implements CollectorFormatterInterface<ExceptionDataCollector>
|
||||
*/
|
||||
final class ExceptionCollectorFormatter implements CollectorFormatterInterface
|
||||
{
|
||||
public function getName(): string
|
||||
{
|
||||
return 'exception';
|
||||
}
|
||||
|
||||
public function format(DataCollectorInterface $collector): array
|
||||
{
|
||||
\assert($collector instanceof ExceptionDataCollector);
|
||||
|
||||
if (!$collector->hasException()) {
|
||||
return [
|
||||
'has_exception' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$exception = $collector->getException();
|
||||
|
||||
return [
|
||||
'has_exception' => true,
|
||||
'message' => $collector->getMessage(),
|
||||
'status_code' => $collector->getStatusCode(),
|
||||
'class' => $exception->getClass(),
|
||||
'file' => $exception->getFile(),
|
||||
'line' => $exception->getLine(),
|
||||
'trace' => $this->formatTrace($exception->getTrace()),
|
||||
];
|
||||
}
|
||||
|
||||
public function getSummary(DataCollectorInterface $collector): array
|
||||
{
|
||||
\assert($collector instanceof ExceptionDataCollector);
|
||||
|
||||
if (!$collector->hasException()) {
|
||||
return [
|
||||
'has_exception' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$exception = $collector->getException();
|
||||
|
||||
return [
|
||||
'has_exception' => true,
|
||||
'message' => $collector->getMessage(),
|
||||
'class' => $exception->getClass(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array<string, mixed>> $trace
|
||||
*
|
||||
* @return array<array<string, mixed>>
|
||||
*/
|
||||
private function formatTrace(array $trace): array
|
||||
{
|
||||
$formatted = [];
|
||||
$maxFrames = 10;
|
||||
|
||||
foreach (\array_slice($trace, 0, $maxFrames) as $frame) {
|
||||
$formattedFrame = [];
|
||||
|
||||
if (isset($frame['file'])) {
|
||||
$formattedFrame['file'] = $frame['file'];
|
||||
}
|
||||
|
||||
if (isset($frame['line'])) {
|
||||
$formattedFrame['line'] = $frame['line'];
|
||||
}
|
||||
|
||||
if (isset($frame['class'])) {
|
||||
$formattedFrame['class'] = $frame['class'];
|
||||
}
|
||||
|
||||
if (isset($frame['function'])) {
|
||||
$formattedFrame['function'] = $frame['function'];
|
||||
}
|
||||
|
||||
$formatted[] = $formattedFrame;
|
||||
}
|
||||
|
||||
return $formatted;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Mate\Bridge\Symfony\Profiler\Service\Formatter;
|
||||
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Profiler\Service\CollectorFormatterInterface;
|
||||
use Symfony\Component\HttpKernel\DataCollector\DataCollectorInterface;
|
||||
use Symfony\Component\HttpKernel\DataCollector\RequestDataCollector;
|
||||
|
||||
/**
|
||||
* Formats HTTP request collector data with security sanitization.
|
||||
*
|
||||
* Redacts sensitive information including:
|
||||
* - Session cookies and tokens
|
||||
* - API keys and secrets in environment variables
|
||||
* - Authorization headers
|
||||
* - Password and authentication data
|
||||
*
|
||||
* @author Johannes Wachter <johannes@sulu.io>
|
||||
*
|
||||
* @internal
|
||||
*
|
||||
* @implements CollectorFormatterInterface<RequestDataCollector>
|
||||
*/
|
||||
final class RequestCollectorFormatter implements CollectorFormatterInterface
|
||||
{
|
||||
/**
|
||||
* @var array<string>
|
||||
*/
|
||||
private const SENSITIVE_ENV_PATTERNS = [
|
||||
'SECRET',
|
||||
'KEY',
|
||||
'PASSWORD',
|
||||
'TOKEN',
|
||||
'BEARER',
|
||||
'AUTH',
|
||||
'CREDENTIAL',
|
||||
'PRIVATE',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<string>
|
||||
*/
|
||||
private const SENSITIVE_HEADERS = [
|
||||
'authorization',
|
||||
'cookie',
|
||||
'set-cookie',
|
||||
'x-api-key',
|
||||
'x-auth-token',
|
||||
];
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'request';
|
||||
}
|
||||
|
||||
public function format(DataCollectorInterface $collectorData): array
|
||||
{
|
||||
\assert($collectorData instanceof RequestDataCollector);
|
||||
|
||||
$class = new \ReflectionClass($collectorData);
|
||||
$property = $class->getProperty('data');
|
||||
$data = $property->getValue($collectorData);
|
||||
|
||||
$formatted = $data->getValue(true);
|
||||
|
||||
return $this->sanitizeData($formatted);
|
||||
}
|
||||
|
||||
public function getSummary(DataCollectorInterface $collectorData): array
|
||||
{
|
||||
\assert($collectorData instanceof RequestDataCollector);
|
||||
|
||||
return [
|
||||
'method' => $collectorData->getMethod(),
|
||||
'path' => $collectorData->getPathInfo(),
|
||||
'route' => $collectorData->getRoute(),
|
||||
'status_code' => $collectorData->getStatusCode(),
|
||||
'content_type' => $collectorData->getContentType(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function sanitizeData(array $data): array
|
||||
{
|
||||
// Sanitize cookies - redact all cookie values
|
||||
if (isset($data['request_cookies']) && \is_array($data['request_cookies'])) {
|
||||
$data['request_cookies'] = $this->redactArray($data['request_cookies']);
|
||||
}
|
||||
|
||||
if (isset($data['response_cookies']) && \is_array($data['response_cookies'])) {
|
||||
$data['response_cookies'] = $this->redactArray($data['response_cookies']);
|
||||
}
|
||||
|
||||
// Sanitize request headers
|
||||
if (isset($data['request_headers']) && \is_array($data['request_headers'])) {
|
||||
$data['request_headers'] = $this->sanitizeHeaders($data['request_headers']);
|
||||
}
|
||||
|
||||
// Sanitize response headers
|
||||
if (isset($data['response_headers']) && \is_array($data['response_headers'])) {
|
||||
$data['response_headers'] = $this->sanitizeHeaders($data['response_headers']);
|
||||
}
|
||||
|
||||
// Sanitize server variables (environment)
|
||||
if (isset($data['request_server']) && \is_array($data['request_server'])) {
|
||||
$data['request_server'] = $this->sanitizeEnvironment($data['request_server']);
|
||||
}
|
||||
|
||||
// Sanitize dotenv vars
|
||||
if (isset($data['dotenv_vars']) && \is_array($data['dotenv_vars'])) {
|
||||
$data['dotenv_vars'] = $this->sanitizeEnvironment($data['dotenv_vars']);
|
||||
}
|
||||
|
||||
// Sanitize session data
|
||||
if (isset($data['session_attributes']) && \is_array($data['session_attributes'])) {
|
||||
$data['session_attributes'] = $this->redactArray($data['session_attributes']);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $headers
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function sanitizeHeaders(array $headers): array
|
||||
{
|
||||
foreach ($headers as $key => $value) {
|
||||
$lowerKey = strtolower($key);
|
||||
if (\in_array($lowerKey, self::SENSITIVE_HEADERS, true)) {
|
||||
$headers[$key] = '***REDACTED***';
|
||||
}
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $env
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function sanitizeEnvironment(array $env): array
|
||||
{
|
||||
foreach ($env as $key => $value) {
|
||||
$upperKey = strtoupper($key);
|
||||
foreach (self::SENSITIVE_ENV_PATTERNS as $pattern) {
|
||||
if (str_contains($upperKey, $pattern)) {
|
||||
$env[$key] = '***REDACTED***';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $env;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function redactArray(array $data): array
|
||||
{
|
||||
foreach ($data as $key => $value) {
|
||||
$data[$key] = '***REDACTED***';
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
199
src/mate/src/Bridge/Symfony/Profiler/Service/ProfileIndexer.php
Normal file
199
src/mate/src/Bridge/Symfony/Profiler/Service/ProfileIndexer.php
Normal file
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Mate\Bridge\Symfony\Profiler\Service;
|
||||
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Profiler\Model\ProfileIndex;
|
||||
|
||||
/**
|
||||
* Parses and filters profiler index CSV files.
|
||||
*
|
||||
* @author Johannes Wachter <johannes@sulu.io>
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProfileIndexer
|
||||
{
|
||||
/**
|
||||
* @param string|array<string, string> $profilerDir
|
||||
*
|
||||
* @return array<ProfileIndex>
|
||||
*/
|
||||
public function indexProfiles(string|array $profilerDir): array
|
||||
{
|
||||
if (\is_string($profilerDir)) {
|
||||
return $this->indexDirectory($profilerDir, null);
|
||||
}
|
||||
|
||||
$profiles = [];
|
||||
foreach ($profilerDir as $context => $dir) {
|
||||
$profiles = array_merge($profiles, $this->indexDirectory($dir, $context));
|
||||
}
|
||||
|
||||
return $profiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<ProfileIndex> $profiles
|
||||
*
|
||||
* @return array<ProfileIndex>
|
||||
*/
|
||||
public function filterByMethod(array $profiles, string $method): array
|
||||
{
|
||||
return array_filter(
|
||||
$profiles,
|
||||
static fn (ProfileIndex $profile) => strtoupper($profile->method) === strtoupper($method)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<ProfileIndex> $profiles
|
||||
*
|
||||
* @return array<ProfileIndex>
|
||||
*/
|
||||
public function filterByStatusCode(array $profiles, int $statusCode): array
|
||||
{
|
||||
return array_filter(
|
||||
$profiles,
|
||||
static fn (ProfileIndex $profile) => $profile->statusCode === $statusCode
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<ProfileIndex> $profiles
|
||||
*
|
||||
* @return array<ProfileIndex>
|
||||
*/
|
||||
public function filterByDateRange(array $profiles, string $from, string $to): array
|
||||
{
|
||||
$fromTime = strtotime($from);
|
||||
$toTime = strtotime($to);
|
||||
|
||||
if (false === $fromTime || false === $toTime) {
|
||||
return $profiles;
|
||||
}
|
||||
|
||||
return array_filter(
|
||||
$profiles,
|
||||
static fn (ProfileIndex $profile) => $profile->time >= $fromTime && $profile->time <= $toTime
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<ProfileIndex> $profiles
|
||||
*
|
||||
* @return array<ProfileIndex>
|
||||
*/
|
||||
public function filterByUrl(array $profiles, string $urlPattern): array
|
||||
{
|
||||
return array_filter(
|
||||
$profiles,
|
||||
static fn (ProfileIndex $profile) => str_contains($profile->url, $urlPattern)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<ProfileIndex> $profiles
|
||||
*
|
||||
* @return array<ProfileIndex>
|
||||
*/
|
||||
public function filterByIp(array $profiles, string $ip): array
|
||||
{
|
||||
return array_filter(
|
||||
$profiles,
|
||||
static fn (ProfileIndex $profile) => $profile->ip === $ip
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<ProfileIndex> $profiles
|
||||
*
|
||||
* @return array<ProfileIndex>
|
||||
*/
|
||||
public function filterByContext(array $profiles, string $context): array
|
||||
{
|
||||
return array_filter(
|
||||
$profiles,
|
||||
static fn (ProfileIndex $profile) => $profile->context === $context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<ProfileIndex>
|
||||
*/
|
||||
private function indexDirectory(string $dir, ?string $context): array
|
||||
{
|
||||
$indexFile = $dir.'/index.csv';
|
||||
|
||||
if (!file_exists($indexFile) || !is_readable($indexFile)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$profiles = [];
|
||||
$handle = fopen($indexFile, 'r');
|
||||
|
||||
if (false === $handle) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
while (false !== ($line = fgets($handle))) {
|
||||
$profile = $this->parseLine(trim($line), $context);
|
||||
if (null !== $profile) {
|
||||
$profiles[] = $profile;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fclose($handle);
|
||||
}
|
||||
|
||||
return $profiles;
|
||||
}
|
||||
|
||||
private function parseLine(string $line, ?string $context): ?ProfileIndex
|
||||
{
|
||||
if ('' === $line) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// CSV format: token,ip,method,url,time,parent,statusCode,type
|
||||
$parts = str_getcsv($line, ',', '"', '\\');
|
||||
|
||||
if (\count($parts) < 5) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$token = $parts[0] ?? '';
|
||||
$ip = $parts[1] ?? '';
|
||||
$method = $parts[2] ?? '';
|
||||
$url = $parts[3] ?? '';
|
||||
$time = (int) ($parts[4] ?? 0);
|
||||
$parent = $parts[5] ?? null;
|
||||
$statusCode = isset($parts[6]) && '' !== $parts[6] ? (int) $parts[6] : null;
|
||||
$type = $parts[7] ?? null;
|
||||
|
||||
if ('' === $token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ProfileIndex(
|
||||
token: $token,
|
||||
ip: $ip,
|
||||
method: $method,
|
||||
url: $url,
|
||||
time: $time,
|
||||
statusCode: $statusCode,
|
||||
parentToken: null !== $parent && '' !== $parent ? $parent : null,
|
||||
context: $context,
|
||||
type: null !== $type && '' !== $type ? $type : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Mate\Bridge\Symfony\Profiler\Service;
|
||||
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Profiler\Exception\InvalidCollectorException;
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Profiler\Exception\ProfileNotFoundException;
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Profiler\Model\ProfileData;
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Profiler\Model\ProfileIndex;
|
||||
use Symfony\Component\HttpKernel\Profiler\FileProfilerStorage;
|
||||
|
||||
/**
|
||||
* Reads and parses profiler data files.
|
||||
*
|
||||
* @author Johannes Wachter <johannes@sulu.io>
|
||||
*
|
||||
* @phpstan-type CollectorDataArray array{
|
||||
* name: string,
|
||||
* data: array<string, mixed>,
|
||||
* summary: array<string, mixed>,
|
||||
* }
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProfilerDataProvider
|
||||
{
|
||||
/**
|
||||
* @var array<string|int, FileProfilerStorage>
|
||||
*/
|
||||
private readonly array $storages;
|
||||
|
||||
/**
|
||||
* @param string|array<string, string> $profilerDir
|
||||
*/
|
||||
public function __construct(
|
||||
string|array $profilerDir,
|
||||
private readonly CollectorRegistry $collectorRegistry,
|
||||
) {
|
||||
$this->storages = $this->createStorages($profilerDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<ProfileIndex>
|
||||
*/
|
||||
public function readIndex(?int $limit = null): array
|
||||
{
|
||||
$allProfiles = [];
|
||||
|
||||
foreach ($this->storages as $context => $storage) {
|
||||
$profiles = $storage->find(null, null, \PHP_INT_MAX, null);
|
||||
|
||||
foreach ($profiles as $profileData) {
|
||||
$allProfiles[] = new ProfileIndex(
|
||||
token: $profileData['token'],
|
||||
ip: $profileData['ip'],
|
||||
method: $profileData['method'],
|
||||
url: $profileData['url'],
|
||||
time: $profileData['time'],
|
||||
statusCode: $profileData['status_code'] ?? null,
|
||||
parentToken: $profileData['parent'] ?? null,
|
||||
context: \is_string($context) ? $context : null,
|
||||
type: $profileData['virtual_type'] ?? null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
usort($allProfiles, static fn ($a, $b) => $b->time <=> $a->time);
|
||||
|
||||
if (null !== $limit) {
|
||||
return \array_slice($allProfiles, 0, $limit);
|
||||
}
|
||||
|
||||
return $allProfiles;
|
||||
}
|
||||
|
||||
public function findProfile(string $token): ?ProfileData
|
||||
{
|
||||
foreach ($this->storages as $context => $storage) {
|
||||
$profile = $storage->read($token);
|
||||
|
||||
if (null !== $profile) {
|
||||
return new ProfileData(
|
||||
profile: $profile,
|
||||
context: \is_string($context) ? $context : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $criteria
|
||||
*
|
||||
* @return array<ProfileIndex>
|
||||
*/
|
||||
public function searchProfiles(array $criteria, int $limit = 20): array
|
||||
{
|
||||
$allResults = [];
|
||||
|
||||
$start = isset($criteria['from']) ? strtotime($criteria['from']) : null;
|
||||
$end = isset($criteria['to']) ? strtotime($criteria['to']) : null;
|
||||
|
||||
foreach ($this->storages as $context => $storage) {
|
||||
$profiles = $storage->find(
|
||||
ip: $criteria['ip'] ?? null,
|
||||
url: $criteria['url'] ?? null,
|
||||
limit: \PHP_INT_MAX,
|
||||
method: $criteria['method'] ?? null,
|
||||
start: false !== $start ? $start : null,
|
||||
end: false !== $end ? $end : null,
|
||||
statusCode: isset($criteria['statusCode']) ? (string) $criteria['statusCode'] : null,
|
||||
);
|
||||
|
||||
foreach ($profiles as $profileData) {
|
||||
if (isset($criteria['context']) && $criteria['context'] !== $context) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$allResults[] = new ProfileIndex(
|
||||
token: $profileData['token'],
|
||||
ip: $profileData['ip'],
|
||||
method: $profileData['method'],
|
||||
url: $profileData['url'],
|
||||
time: $profileData['time'],
|
||||
statusCode: $profileData['status_code'] ?? null,
|
||||
parentToken: $profileData['parent'] ?? null,
|
||||
context: \is_string($context) ? $context : null,
|
||||
type: $profileData['virtual_type'] ?? null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
usort($allResults, static fn ($a, $b) => $b->time <=> $a->time);
|
||||
|
||||
return \array_slice($allResults, 0, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return CollectorDataArray
|
||||
*/
|
||||
public function getCollectorData(string $token, string $collectorName): array
|
||||
{
|
||||
$profileData = $this->findProfile($token);
|
||||
|
||||
if (null === $profileData) {
|
||||
throw new ProfileNotFoundException(\sprintf('Profile not found for token: "%s"', $token));
|
||||
}
|
||||
|
||||
$profile = $profileData->profile;
|
||||
|
||||
if (!$profile->hasCollector($collectorName)) {
|
||||
throw new InvalidCollectorException(\sprintf('Collector "%s" not found in profile "%s"', $collectorName, $token));
|
||||
}
|
||||
|
||||
$collectorData = $profile->getCollector($collectorName);
|
||||
$formatter = $this->collectorRegistry->get($collectorName);
|
||||
|
||||
if (null === $formatter) {
|
||||
return [
|
||||
'name' => $collectorName,
|
||||
'data' => [],
|
||||
'summary' => [],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => $collectorName,
|
||||
'data' => $formatter->format($collectorData),
|
||||
'summary' => $formatter->getSummary($collectorData),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string>
|
||||
*/
|
||||
public function listAvailableCollectors(string $token): array
|
||||
{
|
||||
$profileData = $this->findProfile($token);
|
||||
|
||||
if (null === $profileData) {
|
||||
throw new ProfileNotFoundException(\sprintf('Profile not found for token: "%s"', $token));
|
||||
}
|
||||
|
||||
return array_keys($profileData->profile->getCollectors());
|
||||
}
|
||||
|
||||
public function getLatestProfile(): ?ProfileIndex
|
||||
{
|
||||
$profiles = $this->readIndex(1);
|
||||
|
||||
return $profiles[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|array<string, string> $profilerDir
|
||||
*
|
||||
* @return array<string|int, FileProfilerStorage>
|
||||
*/
|
||||
private function createStorages(string|array $profilerDir): array
|
||||
{
|
||||
if (\is_string($profilerDir)) {
|
||||
return [0 => new FileProfilerStorage('file:'.$profilerDir)];
|
||||
}
|
||||
|
||||
$storages = [];
|
||||
foreach ($profilerDir as $context => $dir) {
|
||||
$storages[$context] = new FileProfilerStorage('file:'.$dir);
|
||||
}
|
||||
|
||||
return $storages;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
Symfony Bridge
|
||||
==============
|
||||
|
||||
Provides Symfony container introspection tools for Symfony AI Mate.
|
||||
Provides Symfony container introspection and profiler data access tools for Symfony AI Mate.
|
||||
|
||||
Resources
|
||||
---------
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Mate\Bridge\Symfony\Tests\Capability;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Capability\ProfilerResourceTemplate;
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Profiler\Service\CollectorRegistry;
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Profiler\Service\ProfilerDataProvider;
|
||||
|
||||
/**
|
||||
* @author Johannes Wachter <johannes@sulu.io>
|
||||
*/
|
||||
final class ProfilerResourceTemplateTest extends TestCase
|
||||
{
|
||||
private ProfilerResourceTemplate $template;
|
||||
private string $fixtureDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->fixtureDir = __DIR__.'/../Fixtures/profiler';
|
||||
$registry = new CollectorRegistry([]);
|
||||
$provider = new ProfilerDataProvider($this->fixtureDir, $registry);
|
||||
|
||||
$this->template = new ProfilerResourceTemplate($provider);
|
||||
}
|
||||
|
||||
public function testGetProfileResourceReturnsValidStructure()
|
||||
{
|
||||
$resource = $this->template->getProfileResource('abc123');
|
||||
|
||||
$this->assertArrayHasKey('uri', $resource);
|
||||
$this->assertArrayHasKey('mimeType', $resource);
|
||||
$this->assertArrayHasKey('text', $resource);
|
||||
$this->assertSame('symfony-profiler://profile/abc123', $resource['uri']);
|
||||
$this->assertSame('application/json', $resource['mimeType']);
|
||||
}
|
||||
|
||||
public function testGetProfileResourceIncludesMetadata()
|
||||
{
|
||||
$resource = $this->template->getProfileResource('abc123');
|
||||
|
||||
$data = json_decode($resource['text'], true);
|
||||
$this->assertIsArray($data);
|
||||
|
||||
$this->assertArrayHasKey('token', $data);
|
||||
$this->assertArrayHasKey('method', $data);
|
||||
$this->assertArrayHasKey('url', $data);
|
||||
$this->assertArrayHasKey('status_code', $data);
|
||||
$this->assertArrayHasKey('collectors', $data);
|
||||
|
||||
$this->assertSame('abc123', $data['token']);
|
||||
}
|
||||
|
||||
public function testGetProfileResourceIncludesCollectorUris()
|
||||
{
|
||||
$resource = $this->template->getProfileResource('abc123');
|
||||
|
||||
$data = json_decode($resource['text'], true);
|
||||
$this->assertIsArray($data);
|
||||
|
||||
$this->assertArrayHasKey('collectors', $data);
|
||||
$this->assertIsArray($data['collectors']);
|
||||
$this->assertNotEmpty($data['collectors']);
|
||||
|
||||
// Check collector structure
|
||||
foreach ($data['collectors'] as $collector) {
|
||||
$this->assertArrayHasKey('name', $collector);
|
||||
$this->assertArrayHasKey('uri', $collector);
|
||||
$this->assertStringStartsWith('symfony-profiler://profile/abc123/', $collector['uri']);
|
||||
}
|
||||
}
|
||||
|
||||
public function testGetProfileResourceForNonExistentProfileReturnsError()
|
||||
{
|
||||
$resource = $this->template->getProfileResource('nonexistent');
|
||||
|
||||
$data = json_decode($resource['text'], true);
|
||||
$this->assertIsArray($data);
|
||||
|
||||
$this->assertArrayHasKey('error', $data);
|
||||
$this->assertSame('Profile not found', $data['error']);
|
||||
}
|
||||
|
||||
public function testGetCollectorResourceReturnsValidStructure()
|
||||
{
|
||||
$resource = $this->template->getCollectorResource('abc123', 'request');
|
||||
|
||||
$this->assertArrayHasKey('uri', $resource);
|
||||
$this->assertArrayHasKey('mimeType', $resource);
|
||||
$this->assertArrayHasKey('text', $resource);
|
||||
$this->assertSame('symfony-profiler://profile/abc123/request', $resource['uri']);
|
||||
}
|
||||
|
||||
public function testGetCollectorResourceReturnsJsonContent()
|
||||
{
|
||||
$resource = $this->template->getCollectorResource('abc123', 'request');
|
||||
|
||||
$data = json_decode($resource['text'], true);
|
||||
|
||||
$this->assertIsArray($data);
|
||||
$this->assertArrayHasKey('name', $data);
|
||||
$this->assertSame('request', $data['name']);
|
||||
}
|
||||
|
||||
public function testResourcesReturnNonEmptyText()
|
||||
{
|
||||
$resources = [
|
||||
$this->template->getProfileResource('abc123'),
|
||||
$this->template->getCollectorResource('abc123', 'request'),
|
||||
];
|
||||
|
||||
foreach ($resources as $resource) {
|
||||
$this->assertNotEmpty($resource['text']);
|
||||
$this->assertIsString($resource['text']);
|
||||
}
|
||||
}
|
||||
|
||||
public function testResourcesHandleErrorsGracefully()
|
||||
{
|
||||
$resource = $this->template->getCollectorResource('nonexistent', 'request');
|
||||
|
||||
$this->assertArrayHasKey('text', $resource);
|
||||
$data = json_decode($resource['text'], true);
|
||||
$this->assertIsArray($data);
|
||||
$this->assertArrayHasKey('error', $data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Mate\Bridge\Symfony\Tests\Capability;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Capability\ProfilerTool;
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Profiler\Service\CollectorRegistry;
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Profiler\Service\ProfilerDataProvider;
|
||||
|
||||
/**
|
||||
* @author Johannes Wachter <johannes@sulu.io>
|
||||
*/
|
||||
final class ProfilerToolTest extends TestCase
|
||||
{
|
||||
private ProfilerTool $tool;
|
||||
private string $fixtureDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->fixtureDir = __DIR__.'/../Fixtures/profiler';
|
||||
$registry = new CollectorRegistry([]);
|
||||
$provider = new ProfilerDataProvider($this->fixtureDir, $registry);
|
||||
|
||||
$this->tool = new ProfilerTool($provider);
|
||||
}
|
||||
|
||||
public function testListProfilesReturnsAllProfiles()
|
||||
{
|
||||
$profiles = $this->tool->listProfiles();
|
||||
|
||||
$this->assertCount(3, $profiles);
|
||||
$this->assertArrayHasKey('token', $profiles[0]);
|
||||
$this->assertArrayHasKey('time_formatted', $profiles[0]);
|
||||
$this->assertSame('ghi789', $profiles[0]['token']);
|
||||
}
|
||||
|
||||
public function testListProfilesWithLimit()
|
||||
{
|
||||
$profiles = $this->tool->listProfiles(limit: 2);
|
||||
|
||||
$this->assertCount(2, $profiles);
|
||||
}
|
||||
|
||||
public function testListProfilesFilterByMethod()
|
||||
{
|
||||
$profiles = $this->tool->listProfiles(method: 'POST');
|
||||
|
||||
$this->assertCount(1, $profiles);
|
||||
$this->assertSame('def456', $profiles[0]['token']);
|
||||
}
|
||||
|
||||
public function testListProfilesFilterByStatusCode()
|
||||
{
|
||||
$profiles = $this->tool->listProfiles(statusCode: 404);
|
||||
|
||||
$this->assertCount(1, $profiles);
|
||||
$this->assertSame('ghi789', $profiles[0]['token']);
|
||||
}
|
||||
|
||||
public function testListProfilesFilterByUrl()
|
||||
{
|
||||
$profiles = $this->tool->listProfiles(url: 'users');
|
||||
|
||||
$this->assertCount(2, $profiles);
|
||||
}
|
||||
|
||||
public function testListProfilesFilterByIp()
|
||||
{
|
||||
$profiles = $this->tool->listProfiles(ip: '127.0.0.1');
|
||||
|
||||
$this->assertCount(2, $profiles);
|
||||
}
|
||||
|
||||
public function testGetLatestProfileReturnsFirstProfile()
|
||||
{
|
||||
$profile = $this->tool->getLatestProfile();
|
||||
|
||||
$this->assertNotNull($profile);
|
||||
$this->assertArrayHasKey('token', $profile);
|
||||
$this->assertArrayHasKey('time_formatted', $profile);
|
||||
$this->assertArrayHasKey('resource_uri', $profile);
|
||||
$this->assertSame('ghi789', $profile['token']);
|
||||
}
|
||||
|
||||
public function testGetLatestProfileIncludesResourceUri()
|
||||
{
|
||||
$profile = $this->tool->getLatestProfile();
|
||||
|
||||
$this->assertNotNull($profile);
|
||||
$this->assertArrayHasKey('resource_uri', $profile);
|
||||
$this->assertSame(
|
||||
'symfony-profiler://profile/'.$profile['token'],
|
||||
$profile['resource_uri']
|
||||
);
|
||||
}
|
||||
|
||||
public function testSearchProfilesWithoutCriteria()
|
||||
{
|
||||
$profiles = $this->tool->searchProfiles();
|
||||
|
||||
$this->assertCount(3, $profiles);
|
||||
}
|
||||
|
||||
public function testSearchProfilesByMethod()
|
||||
{
|
||||
$profiles = $this->tool->searchProfiles(method: 'GET');
|
||||
|
||||
$this->assertCount(2, $profiles);
|
||||
}
|
||||
|
||||
public function testSearchProfilesByStatusCode()
|
||||
{
|
||||
$profiles = $this->tool->searchProfiles(statusCode: 200);
|
||||
|
||||
$this->assertCount(1, $profiles);
|
||||
$this->assertSame('abc123', $profiles[0]['token']);
|
||||
}
|
||||
|
||||
public function testSearchProfilesByRoute()
|
||||
{
|
||||
$profiles = $this->tool->searchProfiles(route: '/api/users');
|
||||
|
||||
$this->assertCount(2, $profiles);
|
||||
}
|
||||
|
||||
public function testSearchProfilesWithLimit()
|
||||
{
|
||||
$profiles = $this->tool->searchProfiles(limit: 1);
|
||||
|
||||
$this->assertCount(1, $profiles);
|
||||
}
|
||||
|
||||
public function testGetProfileReturnsProfileWithResourceUri()
|
||||
{
|
||||
$profile = $this->tool->getProfile('abc123');
|
||||
|
||||
$this->assertArrayHasKey('token', $profile);
|
||||
$this->assertArrayHasKey('resource_uri', $profile);
|
||||
$this->assertSame('abc123', $profile['token']);
|
||||
$this->assertSame('symfony-profiler://profile/abc123', $profile['resource_uri']);
|
||||
}
|
||||
|
||||
public function testGetProfileThrowsExceptionForNonExistentToken()
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Profile with token "nonexistent" not found');
|
||||
|
||||
$this->tool->getProfile('nonexistent');
|
||||
}
|
||||
|
||||
public function testListProfilesIncludesResourceUri()
|
||||
{
|
||||
$profiles = $this->tool->listProfiles();
|
||||
|
||||
$this->assertCount(3, $profiles);
|
||||
foreach ($profiles as $profile) {
|
||||
$this->assertArrayHasKey('resource_uri', $profile);
|
||||
$this->assertStringStartsWith('symfony-profiler://profile/', $profile['resource_uri']);
|
||||
$this->assertSame(
|
||||
'symfony-profiler://profile/'.$profile['token'],
|
||||
$profile['resource_uri']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function testSearchProfilesIncludesResourceUri()
|
||||
{
|
||||
$profiles = $this->tool->searchProfiles();
|
||||
|
||||
foreach ($profiles as $profile) {
|
||||
$this->assertArrayHasKey('resource_uri', $profile);
|
||||
$this->assertStringStartsWith('symfony-profiler://profile/', $profile['resource_uri']);
|
||||
}
|
||||
}
|
||||
|
||||
public function testListProfilesReturnsIntegerKeys()
|
||||
{
|
||||
$profiles = $this->tool->listProfiles();
|
||||
|
||||
$keys = array_keys($profiles);
|
||||
$this->assertSame([0, 1, 2], $keys);
|
||||
}
|
||||
|
||||
public function testSearchProfilesReturnsIntegerKeys()
|
||||
{
|
||||
$profiles = $this->tool->searchProfiles();
|
||||
|
||||
$keys = array_keys($profiles);
|
||||
$this->assertSame([0, 1, 2], $keys);
|
||||
}
|
||||
}
|
||||
72
src/mate/src/Bridge/Symfony/Tests/Fixtures/TestCollector.php
Normal file
72
src/mate/src/Bridge/Symfony/Tests/Fixtures/TestCollector.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Mate\Bridge\Symfony\Tests\Fixtures;
|
||||
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\DataCollector\DataCollector;
|
||||
|
||||
/**
|
||||
* Test collector for profiler fixtures.
|
||||
*
|
||||
* @author Johannes Wachter <johannes@sulu.io>
|
||||
*/
|
||||
class TestCollector extends DataCollector
|
||||
{
|
||||
private string $collectorName = 'test';
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function __construct(
|
||||
string $name,
|
||||
array $data = [],
|
||||
) {
|
||||
$this->collectorName = $name;
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function __serialize(): array
|
||||
{
|
||||
return [
|
||||
'collectorName' => $this->collectorName,
|
||||
'data' => $this->data,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function __unserialize(array $data): void
|
||||
{
|
||||
$this->collectorName = $data['collectorName'];
|
||||
$this->data = $data['data'];
|
||||
}
|
||||
|
||||
public function collect(Request $request, Response $response, ?\Throwable $exception = null): void
|
||||
{
|
||||
// No collection needed for test fixtures
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->collectorName;
|
||||
}
|
||||
|
||||
public function reset(): void
|
||||
{
|
||||
$this->data = [];
|
||||
}
|
||||
}
|
||||
BIN
src/mate/src/Bridge/Symfony/Tests/Fixtures/profiler/23/c1/abc123
Normal file
BIN
src/mate/src/Bridge/Symfony/Tests/Fixtures/profiler/23/c1/abc123
Normal file
Binary file not shown.
BIN
src/mate/src/Bridge/Symfony/Tests/Fixtures/profiler/56/f4/def456
Normal file
BIN
src/mate/src/Bridge/Symfony/Tests/Fixtures/profiler/56/f4/def456
Normal file
Binary file not shown.
BIN
src/mate/src/Bridge/Symfony/Tests/Fixtures/profiler/89/i7/ghi789
Normal file
BIN
src/mate/src/Bridge/Symfony/Tests/Fixtures/profiler/89/i7/ghi789
Normal file
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
abc123,127.0.0.1,GET,/api/users,1704448800,,200,request
|
||||
def456,192.168.1.1,POST,/api/users,1704448900,,201,request
|
||||
ghi789,127.0.0.1,GET,/api/posts,1704449000,,404,request
|
||||
|
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Mate\Bridge\Symfony\Tests\Profiler\Model;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Profiler\Model\CollectorData;
|
||||
|
||||
/**
|
||||
* @author Johannes Wachter <johannes@sulu.io>
|
||||
*/
|
||||
final class CollectorDataTest extends TestCase
|
||||
{
|
||||
public function testConstructorSetsProperties()
|
||||
{
|
||||
$data = ['method' => 'GET', 'path' => '/test'];
|
||||
$summary = ['requests' => 1, 'status' => 200];
|
||||
|
||||
$collectorData = new CollectorData(
|
||||
name: 'request',
|
||||
data: $data,
|
||||
summary: $summary
|
||||
);
|
||||
|
||||
$this->assertSame('request', $collectorData->name);
|
||||
$this->assertSame($data, $collectorData->data);
|
||||
$this->assertSame($summary, $collectorData->summary);
|
||||
}
|
||||
|
||||
public function testDataCanBeEmpty()
|
||||
{
|
||||
$collectorData = new CollectorData(
|
||||
name: 'request',
|
||||
data: [],
|
||||
summary: []
|
||||
);
|
||||
|
||||
$this->assertIsArray($collectorData->data);
|
||||
$this->assertEmpty($collectorData->data);
|
||||
}
|
||||
|
||||
public function testSummaryCanBeEmpty()
|
||||
{
|
||||
$collectorData = new CollectorData(
|
||||
name: 'request',
|
||||
data: ['method' => 'GET'],
|
||||
summary: []
|
||||
);
|
||||
|
||||
$this->assertIsArray($collectorData->summary);
|
||||
$this->assertEmpty($collectorData->summary);
|
||||
}
|
||||
|
||||
public function testPropertiesAreReadonly()
|
||||
{
|
||||
$collectorData = new CollectorData(
|
||||
name: 'request',
|
||||
data: [],
|
||||
summary: []
|
||||
);
|
||||
|
||||
$reflection = new \ReflectionClass($collectorData);
|
||||
|
||||
$nameProperty = $reflection->getProperty('name');
|
||||
$this->assertTrue($nameProperty->isReadOnly());
|
||||
|
||||
$dataProperty = $reflection->getProperty('data');
|
||||
$this->assertTrue($dataProperty->isReadOnly());
|
||||
|
||||
$summaryProperty = $reflection->getProperty('summary');
|
||||
$this->assertTrue($summaryProperty->isReadOnly());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Mate\Bridge\Symfony\Tests\Profiler\Model;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Profiler\Model\ProfileData;
|
||||
use Symfony\Component\HttpKernel\Profiler\Profile;
|
||||
|
||||
/**
|
||||
* @author Johannes Wachter <johannes@sulu.io>
|
||||
*/
|
||||
final class ProfileDataTest extends TestCase
|
||||
{
|
||||
public function testConstructorSetsProperties()
|
||||
{
|
||||
$profile = new Profile('abc123');
|
||||
$profile->setMethod('GET');
|
||||
$profile->setUrl('/test');
|
||||
|
||||
$profileData = new ProfileData(
|
||||
profile: $profile,
|
||||
context: 'website'
|
||||
);
|
||||
|
||||
$this->assertSame($profile, $profileData->profile);
|
||||
$this->assertSame('website', $profileData->context);
|
||||
}
|
||||
|
||||
public function testContextCanBeNull()
|
||||
{
|
||||
$profile = new Profile('abc123');
|
||||
|
||||
$profileData = new ProfileData(
|
||||
profile: $profile,
|
||||
context: null
|
||||
);
|
||||
|
||||
$this->assertSame($profile, $profileData->profile);
|
||||
$this->assertNull($profileData->context);
|
||||
}
|
||||
|
||||
public function testContextDefaultsToNull()
|
||||
{
|
||||
$profile = new Profile('abc123');
|
||||
|
||||
$profileData = new ProfileData(
|
||||
profile: $profile
|
||||
);
|
||||
|
||||
$this->assertSame($profile, $profileData->profile);
|
||||
$this->assertNull($profileData->context);
|
||||
}
|
||||
|
||||
public function testPropertiesAreReadonly()
|
||||
{
|
||||
$profile = new Profile('abc123');
|
||||
$profileData = new ProfileData(
|
||||
profile: $profile,
|
||||
context: 'admin'
|
||||
);
|
||||
|
||||
$reflection = new \ReflectionClass($profileData);
|
||||
|
||||
$profileProperty = $reflection->getProperty('profile');
|
||||
$this->assertTrue($profileProperty->isReadOnly());
|
||||
|
||||
$contextProperty = $reflection->getProperty('context');
|
||||
$this->assertTrue($contextProperty->isReadOnly());
|
||||
}
|
||||
|
||||
public function testProfileMethodsAreAccessible()
|
||||
{
|
||||
$profile = new Profile('abc123');
|
||||
$profile->setMethod('POST');
|
||||
$profile->setUrl('/api/users');
|
||||
$profile->setIp('192.168.1.1');
|
||||
|
||||
$profileData = new ProfileData(profile: $profile);
|
||||
|
||||
$this->assertSame('abc123', $profileData->profile->getToken());
|
||||
$this->assertSame('POST', $profileData->profile->getMethod());
|
||||
$this->assertSame('/api/users', $profileData->profile->getUrl());
|
||||
$this->assertSame('192.168.1.1', $profileData->profile->getIp());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Mate\Bridge\Symfony\Tests\Profiler\Model;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Profiler\Model\ProfileIndex;
|
||||
|
||||
/**
|
||||
* @author Johannes Wachter <johannes@sulu.io>
|
||||
*/
|
||||
final class ProfileIndexTest extends TestCase
|
||||
{
|
||||
public function testToArrayReturnsCorrectStructure()
|
||||
{
|
||||
$profile = new ProfileIndex(
|
||||
token: 'abc123',
|
||||
ip: '127.0.0.1',
|
||||
method: 'GET',
|
||||
url: '/api/users',
|
||||
time: 1704448800,
|
||||
statusCode: 200,
|
||||
parentToken: null,
|
||||
context: null,
|
||||
type: 'request',
|
||||
);
|
||||
|
||||
$array = $profile->toArray();
|
||||
|
||||
$this->assertSame('abc123', $array['token']);
|
||||
$this->assertSame('127.0.0.1', $array['ip']);
|
||||
$this->assertSame('GET', $array['method']);
|
||||
$this->assertSame('/api/users', $array['url']);
|
||||
$this->assertSame(1704448800, $array['time']);
|
||||
$this->assertIsString($array['time_formatted']);
|
||||
$this->assertSame(200, $array['status_code']);
|
||||
$this->assertNull($array['parent_token']);
|
||||
$this->assertArrayNotHasKey('context', $array);
|
||||
}
|
||||
|
||||
public function testToArrayIncludesContextWhenPresent()
|
||||
{
|
||||
$profile = new ProfileIndex(
|
||||
token: 'abc123',
|
||||
ip: '127.0.0.1',
|
||||
method: 'GET',
|
||||
url: '/api/users',
|
||||
time: 1704448800,
|
||||
statusCode: 200,
|
||||
parentToken: null,
|
||||
context: 'test-context',
|
||||
type: 'request',
|
||||
);
|
||||
|
||||
$array = $profile->toArray();
|
||||
|
||||
$this->assertArrayHasKey('context', $array);
|
||||
$this->assertSame('test-context', $array['context']);
|
||||
}
|
||||
|
||||
public function testToArrayFormatsTimeCorrectly()
|
||||
{
|
||||
$timestamp = 1704448800;
|
||||
$profile = new ProfileIndex(
|
||||
token: 'abc123',
|
||||
ip: '127.0.0.1',
|
||||
method: 'GET',
|
||||
url: '/api/users',
|
||||
time: $timestamp,
|
||||
);
|
||||
|
||||
$array = $profile->toArray();
|
||||
|
||||
$this->assertSame(date(\DateTimeInterface::ATOM, $timestamp), $array['time_formatted']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Mate\Bridge\Symfony\Tests\Profiler\Service;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Profiler\Service\ProfileIndexer;
|
||||
|
||||
/**
|
||||
* @author Johannes Wachter <johannes@sulu.io>
|
||||
*/
|
||||
final class ProfileIndexerTest extends TestCase
|
||||
{
|
||||
private ProfileIndexer $indexer;
|
||||
private string $fixtureDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->indexer = new ProfileIndexer();
|
||||
$this->fixtureDir = __DIR__.'/../../Fixtures/profiler';
|
||||
}
|
||||
|
||||
public function testIndexProfilesReturnsEmptyArrayWhenFileNotFound()
|
||||
{
|
||||
$profiles = $this->indexer->indexProfiles('/non/existent/dir');
|
||||
|
||||
$this->assertSame([], $profiles);
|
||||
}
|
||||
|
||||
public function testIndexProfilesParsesValidCsv()
|
||||
{
|
||||
$profiles = $this->indexer->indexProfiles($this->fixtureDir);
|
||||
|
||||
$this->assertCount(3, $profiles);
|
||||
|
||||
$first = $profiles[0];
|
||||
$this->assertSame('abc123', $first->token);
|
||||
$this->assertSame('127.0.0.1', $first->ip);
|
||||
$this->assertSame('GET', $first->method);
|
||||
$this->assertSame('/api/users', $first->url);
|
||||
$this->assertSame(200, $first->statusCode);
|
||||
}
|
||||
|
||||
public function testFilterByMethod()
|
||||
{
|
||||
$profiles = $this->indexer->indexProfiles($this->fixtureDir);
|
||||
$filtered = $this->indexer->filterByMethod($profiles, 'POST');
|
||||
|
||||
$this->assertCount(1, $filtered);
|
||||
$this->assertSame('def456', array_values($filtered)[0]->token);
|
||||
}
|
||||
|
||||
public function testFilterByStatusCode()
|
||||
{
|
||||
$profiles = $this->indexer->indexProfiles($this->fixtureDir);
|
||||
$filtered = $this->indexer->filterByStatusCode($profiles, 404);
|
||||
|
||||
$this->assertCount(1, $filtered);
|
||||
$this->assertSame('ghi789', array_values($filtered)[0]->token);
|
||||
}
|
||||
|
||||
public function testFilterByUrl()
|
||||
{
|
||||
$profiles = $this->indexer->indexProfiles($this->fixtureDir);
|
||||
$filtered = $this->indexer->filterByUrl($profiles, 'users');
|
||||
|
||||
$this->assertCount(2, $filtered);
|
||||
}
|
||||
|
||||
public function testFilterByIp()
|
||||
{
|
||||
$profiles = $this->indexer->indexProfiles($this->fixtureDir);
|
||||
$filtered = $this->indexer->filterByIp($profiles, '127.0.0.1');
|
||||
|
||||
$this->assertCount(2, $filtered);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Mate\Bridge\Symfony\Tests\Profiler\Service;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Profiler\Exception\InvalidCollectorException;
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Profiler\Exception\ProfileNotFoundException;
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Profiler\Service\CollectorRegistry;
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Profiler\Service\ProfilerDataProvider;
|
||||
use Symfony\Component\HttpKernel\Profiler\Profile;
|
||||
|
||||
/**
|
||||
* @author Johannes Wachter <johannes@sulu.io>
|
||||
*/
|
||||
final class ProfilerDataProviderTest extends TestCase
|
||||
{
|
||||
private ProfilerDataProvider $provider;
|
||||
private string $fixtureDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->fixtureDir = __DIR__.'/../../Fixtures/profiler';
|
||||
$registry = new CollectorRegistry([]);
|
||||
|
||||
$this->provider = new ProfilerDataProvider(
|
||||
$this->fixtureDir,
|
||||
$registry
|
||||
);
|
||||
}
|
||||
|
||||
public function testReadIndexReturnsAllProfiles()
|
||||
{
|
||||
$profiles = $this->provider->readIndex();
|
||||
|
||||
$this->assertCount(3, $profiles);
|
||||
$this->assertSame('ghi789', $profiles[0]->token);
|
||||
$this->assertSame('def456', $profiles[1]->token);
|
||||
$this->assertSame('abc123', $profiles[2]->token);
|
||||
}
|
||||
|
||||
public function testReadIndexWithLimit()
|
||||
{
|
||||
$profiles = $this->provider->readIndex(2);
|
||||
|
||||
$this->assertCount(2, $profiles);
|
||||
$this->assertSame('ghi789', $profiles[0]->token);
|
||||
$this->assertSame('def456', $profiles[1]->token);
|
||||
}
|
||||
|
||||
public function testFindProfileReturnsProfileData()
|
||||
{
|
||||
$profileData = $this->provider->findProfile('abc123');
|
||||
|
||||
$this->assertNotNull($profileData);
|
||||
$this->assertSame('abc123', $profileData->profile->getToken());
|
||||
$this->assertInstanceOf(Profile::class, $profileData->profile);
|
||||
$this->assertIsArray($profileData->profile->getCollectors());
|
||||
}
|
||||
|
||||
public function testFindProfileReturnsNullForNonExistentToken()
|
||||
{
|
||||
$profile = $this->provider->findProfile('nonexistent');
|
||||
|
||||
$this->assertNull($profile);
|
||||
}
|
||||
|
||||
public function testFindProfileUsesNestedDirectoryStructure()
|
||||
{
|
||||
// Token 'abc123': last 2 chars = '23', next-to-last 2 chars = 'c1'
|
||||
// Should find file at: profiler/23/c1/abc123
|
||||
$profileData = $this->provider->findProfile('abc123');
|
||||
|
||||
$this->assertNotNull($profileData);
|
||||
$this->assertSame('abc123', $profileData->profile->getToken());
|
||||
}
|
||||
|
||||
public function testSearchProfilesWithoutCriteria()
|
||||
{
|
||||
$profiles = $this->provider->searchProfiles([]);
|
||||
|
||||
$this->assertCount(3, $profiles);
|
||||
}
|
||||
|
||||
public function testSearchProfilesByMethod()
|
||||
{
|
||||
$profiles = $this->provider->searchProfiles(['method' => 'POST']);
|
||||
|
||||
$this->assertCount(1, $profiles);
|
||||
$this->assertSame('def456', $profiles[0]->token);
|
||||
}
|
||||
|
||||
public function testSearchProfilesByStatusCode()
|
||||
{
|
||||
$profiles = $this->provider->searchProfiles(['statusCode' => 404]);
|
||||
|
||||
$this->assertCount(1, $profiles);
|
||||
$this->assertSame('ghi789', $profiles[0]->token);
|
||||
}
|
||||
|
||||
public function testSearchProfilesByUrl()
|
||||
{
|
||||
$profiles = $this->provider->searchProfiles(['url' => 'users']);
|
||||
|
||||
$this->assertCount(2, $profiles);
|
||||
}
|
||||
|
||||
public function testSearchProfilesByIp()
|
||||
{
|
||||
$profiles = $this->provider->searchProfiles(['ip' => '127.0.0.1']);
|
||||
|
||||
$this->assertCount(2, $profiles);
|
||||
}
|
||||
|
||||
public function testSearchProfilesWithLimit()
|
||||
{
|
||||
$profiles = $this->provider->searchProfiles(['method' => 'GET'], 1);
|
||||
|
||||
$this->assertCount(1, $profiles);
|
||||
}
|
||||
|
||||
public function testGetCollectorDataThrowsExceptionForNonExistentProfile()
|
||||
{
|
||||
$this->expectException(ProfileNotFoundException::class);
|
||||
$this->expectExceptionMessage('Profile not found for token: "nonexistent"');
|
||||
|
||||
$this->provider->getCollectorData('nonexistent', 'request');
|
||||
}
|
||||
|
||||
public function testGetCollectorDataThrowsExceptionForInvalidCollector()
|
||||
{
|
||||
$this->expectException(InvalidCollectorException::class);
|
||||
|
||||
$this->provider->getCollectorData('abc123', 'nonexistent');
|
||||
}
|
||||
|
||||
public function testGetCollectorDataReturnsFormattedData()
|
||||
{
|
||||
$data = $this->provider->getCollectorData('abc123', 'request');
|
||||
|
||||
$this->assertIsArray($data);
|
||||
$this->assertArrayHasKey('name', $data);
|
||||
$this->assertArrayHasKey('data', $data);
|
||||
$this->assertArrayHasKey('summary', $data);
|
||||
$this->assertSame('request', $data['name']);
|
||||
}
|
||||
|
||||
public function testListAvailableCollectorsThrowsExceptionForNonExistentProfile()
|
||||
{
|
||||
$this->expectException(ProfileNotFoundException::class);
|
||||
|
||||
$this->provider->listAvailableCollectors('nonexistent');
|
||||
}
|
||||
|
||||
public function testListAvailableCollectorsReturnsCollectorNames()
|
||||
{
|
||||
$collectors = $this->provider->listAvailableCollectors('abc123');
|
||||
|
||||
$this->assertIsArray($collectors);
|
||||
$this->assertContains('request', $collectors);
|
||||
}
|
||||
|
||||
public function testGetLatestProfileReturnsFirstProfile()
|
||||
{
|
||||
$profile = $this->provider->getLatestProfile();
|
||||
|
||||
$this->assertNotNull($profile);
|
||||
$this->assertSame('ghi789', $profile->token);
|
||||
}
|
||||
|
||||
public function testGetLatestProfileReturnsNullWhenNoProfiles()
|
||||
{
|
||||
$registry = new CollectorRegistry([]);
|
||||
$emptyDir = sys_get_temp_dir().'/profiler_empty_'.uniqid();
|
||||
mkdir($emptyDir);
|
||||
|
||||
try {
|
||||
$provider = new ProfilerDataProvider($emptyDir, $registry);
|
||||
|
||||
$profile = $provider->getLatestProfile();
|
||||
|
||||
$this->assertNull($profile);
|
||||
} finally {
|
||||
rmdir($emptyDir);
|
||||
}
|
||||
}
|
||||
|
||||
public function testSupportsMultipleProfilerDirectories()
|
||||
{
|
||||
$registry = new CollectorRegistry([]);
|
||||
$provider = new ProfilerDataProvider(
|
||||
['context1' => $this->fixtureDir],
|
||||
$registry
|
||||
);
|
||||
|
||||
$profiles = $provider->readIndex();
|
||||
|
||||
$this->assertCount(3, $profiles);
|
||||
$this->assertSame('context1', $profiles[0]->context);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "symfony/ai-symfony-mate-extension",
|
||||
"description": "Symfony bridge for AI Mate - provides Symfony container introspection tools",
|
||||
"description": "Symfony bridge for AI Mate - provides Symfony container introspection and optional profiler data access tools",
|
||||
"license": "MIT",
|
||||
"type": "symfony-ai-mate",
|
||||
"keywords": [
|
||||
"ai",
|
||||
"mcp",
|
||||
"profiler",
|
||||
"symfony",
|
||||
"debug",
|
||||
"bridge",
|
||||
@@ -34,7 +35,14 @@
|
||||
"phpstan/phpstan": "^2.1",
|
||||
"phpstan/phpstan-phpunit": "^2.0",
|
||||
"phpstan/phpstan-strict-rules": "^2.0",
|
||||
"phpunit/phpunit": "^11.5.46"
|
||||
"phpunit/phpunit": "^11.5.46",
|
||||
"symfony/http-kernel": "^7.3|^8.0",
|
||||
"symfony/var-dumper": "^7.3|^8.0",
|
||||
"symfony/web-profiler-bundle": "^7.3|^8.0"
|
||||
},
|
||||
"suggest": {
|
||||
"symfony/http-kernel": "Required for profiler data access tools",
|
||||
"symfony/web-profiler-bundle": "Required for profiler data access tools"
|
||||
},
|
||||
"minimum-stability": "dev",
|
||||
"autoload": {
|
||||
|
||||
@@ -9,22 +9,62 @@
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Capability\ProfilerResourceTemplate;
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Capability\ProfilerTool;
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Capability\ServiceTool;
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Profiler\Service\CollectorRegistry;
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Profiler\Service\Formatter\ExceptionCollectorFormatter;
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Profiler\Service\Formatter\RequestCollectorFormatter;
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Profiler\Service\ProfilerDataProvider;
|
||||
use Symfony\AI\Mate\Bridge\Symfony\Service\ContainerProvider;
|
||||
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
|
||||
use Symfony\Component\HttpKernel\Profiler\Profile;
|
||||
|
||||
use function Symfony\Component\DependencyInjection\Loader\Configurator\service;
|
||||
use function Symfony\Component\DependencyInjection\Loader\Configurator\tagged_iterator;
|
||||
|
||||
return function (ContainerConfigurator $configurator) {
|
||||
return static function (ContainerConfigurator $configurator) {
|
||||
// Parameters
|
||||
$configurator->parameters()
|
||||
->set('ai_mate_symfony.cache_dir', '%mate.root_dir%/var/cache');
|
||||
|
||||
$configurator->services()
|
||||
->set(ContainerProvider::class)
|
||||
$services = $configurator->services();
|
||||
|
||||
->set(ServiceTool::class)
|
||||
// Container introspection services (always available)
|
||||
$services->set(ContainerProvider::class);
|
||||
|
||||
$services->set(ServiceTool::class)
|
||||
->args([
|
||||
'%ai_mate_symfony.cache_dir%',
|
||||
service(ContainerProvider::class),
|
||||
]);
|
||||
|
||||
// Profiler services (optional - only if profiler classes are available)
|
||||
if (class_exists(Profile::class)) {
|
||||
$configurator->parameters()
|
||||
->set('ai_mate_symfony.profiler_dir', '%mate.root_dir%/var/cache/dev/profiler');
|
||||
|
||||
$services->set(CollectorRegistry::class)
|
||||
->args([tagged_iterator('ai_mate.profiler_collector_formatter')]);
|
||||
|
||||
$services->set(ProfilerDataProvider::class)
|
||||
->args([
|
||||
'%ai_mate_symfony.cache_dir%',
|
||||
service(ContainerProvider::class),
|
||||
'%ai_mate_symfony.profiler_dir%',
|
||||
service(CollectorRegistry::class),
|
||||
]);
|
||||
|
||||
// Built-in collector formatters
|
||||
$services->set(RequestCollectorFormatter::class)
|
||||
->tag('ai_mate.profiler_collector_formatter');
|
||||
|
||||
$services->set(ExceptionCollectorFormatter::class)
|
||||
->tag('ai_mate.profiler_collector_formatter');
|
||||
|
||||
// MCP Capabilities
|
||||
$services->set(ProfilerTool::class)
|
||||
->args([service(ProfilerDataProvider::class)]);
|
||||
|
||||
$services->set(ProfilerResourceTemplate::class)
|
||||
->args([service(ProfilerDataProvider::class)]);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user