[Mate][Symfony] Add optional profiler data access capabilities

This commit is contained in:
Johannes Wachter
2026-01-02 22:50:20 +01:00
committed by Christopher Hertel
parent bf209846cf
commit 3c8ba99e60
33 changed files with 2382 additions and 13 deletions

View File

@@ -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

View File

@@ -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
--------------

View File

@@ -7,6 +7,8 @@ parameters:
paths:
- src/
- tests/
excludePaths:
- src/Bridge/
treatPhpDocTypesAsCertain: false
ignoreErrors:
-

View File

@@ -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

View File

@@ -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()]) ?: '{}',
];
}
}
}

View 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;
}
}

View File

@@ -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.

View File

@@ -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
{
}

View File

@@ -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
{
}

View 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,
) {
}
}

View 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,
) {
}
}

View 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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View 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,
);
}
}

View File

@@ -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;
}
}

View File

@@ -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
---------

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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 = [];
}
}

View File

@@ -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
1 abc123 127.0.0.1 GET /api/users 1704448800 200 request
2 def456 192.168.1.1 POST /api/users 1704448900 201 request
3 ghi789 127.0.0.1 GET /api/posts 1704449000 404 request

View File

@@ -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());
}
}

View File

@@ -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());
}
}

View File

@@ -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']);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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": {

View File

@@ -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)]);
}
};