diff --git a/README.md b/README.md index ab7383a..b985b46 100644 --- a/README.md +++ b/README.md @@ -4,20 +4,79 @@ [![PHP Version Require](http://poser.pugx.org/flightphp/apm/require/php)](https://packagist.org/packages/flightphp/apm) [![Dependencies](http://poser.pugx.org/flightphp/apm/dependents)](https://packagist.org/packages/flightphp/apm) -An APM (Application Performance Monitoring) library for [FlightPHP](https://github.com/flightphp/core) framework. +A lightweight Application Performance Monitoring (APM) library for the [FlightPHP](https://github.com/flightphp/core) framework. Keep your app fast, spot bottlenecks, and sleep better at night! + +## What is FlightPHP APM? + +FlightPHP APM helps you track how your app performs in real-time—think of it as a fitness tracker for your code! It logs metrics like request times, memory usage, database queries, and custom events, then gives you a slick dashboard to see it all. Why care? Because slow apps lose users, and finding performance hiccups *before* they bite saves you headaches (and maybe a few angry emails). + +Built to be fast and simple, it slots right into your FlightPHP project with minimal fuss. ## Installation -Simply install with Composer +Grab it with Composer: ```bash composer require flightphp/apm ``` +## Quick Start + +1. **Log APM Metrics** + Add this to your `index.php` or services file to start tracking: + ```php + use flight\apm\logger\LoggerFactory; + use flight\Apm; + + $ApmLogger = LoggerFactory::create(__DIR__ . '/../../.runway-config.json'); + $Apm = new Apm($ApmLogger); + $Apm->bindEventsToFlightInstance($app); + ``` + +2. **Set Up Config** + Run this to create your `.runway-config.json`: + ```bash + php vendor/bin/runway apm:init + ``` + +3. **Process Metrics** + Fire up the worker to crunch those metrics (runs once by default): + ```bash + php vendor/bin/runway apm:worker + ``` + Want it continuous? Try `--daemon`: + ```bash + php vendor/bin/runway apm:worker --daemon + ``` + +4. **View Your Dashboard** + Launch the dashboard to see your app’s pulse: + ```bash + php vendor/bin/runway apm:dashboard --host localhost --port 8001 + ``` + +## Keeping the Worker Running + +The worker processes your metrics—here’s how to keep it humming: +- **Daemon Mode**: `php vendor/bin/runway apm:worker --daemon` (runs forever!) +- **Crontab**: `* * * * * php /path/to/project/vendor/bin/runway apm:worker` (runs every minute) +- **Tmux/Screen**: Start it in a detachable session with `tmux` or `screen` for easy monitoring. + +## Requirements + +- PHP 7.4 or higher +- [FlightPHP Core](https://github.com/flightphp/core) v3.15+ + ## Documentation -Coming soon +Want the full scoop? Check out the [FlightPHP APM Documentation](https://docs.flightphp.com/awesome-plugins/apm) for setup details, worker options, dashboard tricks, and more! + +## Community + +Join us on [Matrix IRC #flight-php-framework:matrix.org](https://matrix.to/#/#flight-php-framework:matrix.org) to chat, ask questions, or share your APM wins! + +[![](https://dcbadge.limes.pink/api/server/https://discord.gg/Ysr4zqHfbX)](https://discord.gg/Ysr4zqHfbX) ## License -MIT +MIT—free and open for all! \ No newline at end of file diff --git a/dashboard/css/style.css b/dashboard/css/style.css new file mode 100644 index 0000000..1242d7f --- /dev/null +++ b/dashboard/css/style.css @@ -0,0 +1,291 @@ +:root { + --bg-color: #f8f9fa; + --text-color: #333; + --card-bg: #fff; + --card-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + --header-bg: #fff; + --badge-primary: #0d6efd; + --badge-success: #198754; + --badge-danger: #dc3545; + --badge-info: #0dcaf0; + --badge-warning: #ffc107; +} + +[data-theme="dark"] { + --bg-color: #212529; + --text-color: #f8f9fa; + --card-bg: #343a40; + --card-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); + --header-bg: #2c3034; + --badge-primary: #6ea8fe; + --badge-success: #20c997; + --badge-danger: #f27474; + --badge-info: #6edff6; + --badge-warning: #ffca2c; +} + +[data-theme="dark"] .card-header { + color: var(--text-color); +} + +[data-theme="dark"] .list-group-item { + color: var(--text-color); +} + +[data-theme="dark"] .card-body { + color: var(--text-color); +} + +[data-theme="dark"] .badge { + color: #fff; /* Ensure badge text is always white for readability */ +} + +[data-theme="dark"] .table { + background-color: var(--card-bg); /* Match the card background */ + color: var(--text-color); +} + +[data-theme="dark"] .table thead th, .table>:not(caption)>*>* { + background-color: var(--header-bg); /* Match the header background */ + color: var(--text-color); + border-bottom: 2px solid #495057; /* Slightly lighter border for contrast */ +} + +[data-theme="dark"] .table tbody tr, .table>:not(caption)>*>* { + background-color: var(--card-bg); + border-bottom: 1px solid #495057; /* Subtle border between rows */ +} + +[data-theme="dark"] .table-hover tbody tr:hover { + background-color: rgba(255, 255, 255, 0.1); /* Keep the hover effect */ +} + +[data-theme="dark"] .form-control { + background-color: #495057; + color: var(--text-color); + border-color: #6c757d; +} + +[data-theme="dark"] .form-control::placeholder { + color: #ced4da; /* Lighter gray for better visibility */ + opacity: 1; /* Ensure full opacity for readability */ +} + +body { + background-color: var(--bg-color); + color: var(--text-color); + font-family: 'Inter', sans-serif; + transition: background-color 0.3s, color 0.3s; +} + +.card { + border: none; + border-radius: 10px; + background-color: var(--card-bg); + box-shadow: var(--card-shadow); + transition: transform 0.2s, background-color 0.3s; +} + +.card:hover { + transform: translateY(-5px); +} + +.card-header { + background-color: var(--header-bg); + border-bottom: none; + font-weight: 600; + color: var(--text-color); +} + +.list-group-item { + border: none; + padding: 0.5rem 1rem; + font-size: 0.95rem; + background-color: var(--card-bg); + color: var(--text-color); +} + +.badge { + font-size: 0.9rem; + font-weight: 500; +} + +.badge-primary { background-color: var(--badge-primary); } +.badge-success { background-color: var(--badge-success); } +.badge-danger { background-color: var(--badge-danger); } +.badge-info { background-color: var(--badge-info); } +.badge-warning { background-color: var(--badge-warning); } + +.chart-container { + background-color: var(--card-bg); + border-radius: 10px; + padding: 1rem; + box-shadow: var(--card-shadow); +} + +.btn-outline-primary { + transition: all 0.3s; + color: var(--text-color); + border-color: var(--badge-primary); +} + +.btn-outline-primary:hover { + background-color: var(--badge-primary); + color: #fff; +} + +/* Truncate long URLs with ellipsis and show full text on hover */ +#request-log td:nth-child(2) { + max-width: 200px; /* Adjust this width as needed */ + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +#request-log td:nth-child(2):hover { + position: relative; +} + +#request-log td:nth-child(2):hover::after { + content: attr(data-full-url); + position: absolute; + left: 0; + top: 100%; + z-index: 10; + background-color: var(--card-bg); + color: var(--text-color); + padding: 5px 10px; + border-radius: 4px; + box-shadow: var(--card-shadow); + white-space: normal; + max-width: 400px; + word-wrap: break-word; +} + +/* Ensure table header stays fixed while body scrolls */ +.table-responsive table { + width: 100%; +} + +.table-responsive thead { + position: sticky; + top: 0; + background-color: var(--header-bg); + z-index: 1; +} + +/* Fix for request log table scrolling */ +.table-responsive { + max-height: 400px; + overflow-y: auto; +} + +/* Important: Make tbody display as table-row-group, not block */ +#request-log { + display: table-row-group !important; +} + +/* Truncate long URLs with ellipsis and show full text on hover */ +#request-log td:nth-child(2) { + max-width: 300px; /* Adjust this width as needed */ + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + position: relative; +} + +/* Only show hover effect for elements with the 'truncated-url' class */ +#request-log td.truncated-url:hover { + overflow: visible; + white-space: normal; + word-break: break-all; + z-index: 1; +} + +#request-log td.truncated-url:hover::before { + content: attr(data-full-url); + position: absolute; + left: 0; + top: 100%; + width: 300px; + background-color: var(--card-bg); + border: 1px solid var(--text-color); + padding: 5px; + border-radius: 3px; + box-shadow: var(--card-shadow); + word-break: break-all; + white-space: normal; + z-index: 10; +} + +/* Ensure table column widths are appropriate */ +#request-log td:nth-child(1) { /* Timestamp */ + width: 160px; +} +#request-log td:nth-child(3) { /* Total Time */ + width: 120px; +} +#request-log td:nth-child(4) { /* Response Code */ + width: 120px; +} +#request-log td:nth-child(5) { /* Details */ + width: 100px; +} + +/* JSON formatter styles */ +.json-formatter { + background: #f8f9fa; + border-radius: 4px; + padding: 10px; + font-family: monospace; + font-size: 13px; + overflow-x: auto; + white-space: pre-wrap; + max-height: 300px; + overflow-y: auto; +} + +[data-theme="dark"] .json-formatter { + background: #2c3034; +} + +.json-key { + color: #0d6efd; +} + +.json-string { + color: #20c997; +} + +.json-number { + color: #fd7e14; +} + +.json-boolean { + color: #dc3545; +} + +.json-null { + color: #6c757d; +} + +/* Adjust table styles */ +.card-body .table { + font-size: 0.9rem; +} + +.card-body .table td code { + white-space: pre-wrap; + max-width: 300px; + display: block; + overflow-x: auto; +} + +/* Enhance accordion for custom events */ +.accordion-button { + padding: 0.5rem 1rem; +} + +.accordion-body { + padding: 1rem; +} diff --git a/dashboard/js/script.js b/dashboard/js/script.js new file mode 100644 index 0000000..06120cd --- /dev/null +++ b/dashboard/js/script.js @@ -0,0 +1,544 @@ +// Initialize variables +const rangeSelector = document.getElementById('range'); +const timezoneSelector = document.getElementById('timezone'); +const themeToggle = document.getElementById('theme-toggle'); +let isDarkMode = localStorage.getItem('darkMode') === 'true'; +let currentPage = 1; +let totalPages = 1; +let perPage = 50; +let searchTerm = ''; +let dashboardData = null; +let latencyChart, responseCodeChart; +let searchDebounceTimer = null; +const DEBOUNCE_DELAY = 300; // milliseconds +let selectedTimezone = localStorage.getItem('selectedTimezone') || 'UTC'; + +// Apply stored theme or default to light +if (isDarkMode) { + document.documentElement.setAttribute('data-theme', 'dark'); + themeToggle.innerHTML = ' Light Mode'; +} + +// Set the timezone dropdown to the stored value +if (selectedTimezone) { + timezoneSelector.value = selectedTimezone; +} + +// Toggle theme +themeToggle.addEventListener('click', () => { + isDarkMode = !isDarkMode; + document.documentElement.setAttribute('data-theme', isDarkMode ? 'dark' : ''); + themeToggle.innerHTML = ` ${isDarkMode ? 'Light' : 'Dark'} Mode`; + localStorage.setItem('darkMode', isDarkMode); +}); + +// Function to format a UTC timestamp to the selected timezone +function formatTimestamp(utcTimestamp) { + try { + let date; + + // Check if timestamp is in ISO format or contains 'Z' (indicating UTC) + if (utcTimestamp.includes('T') && (utcTimestamp.includes('Z') || utcTimestamp.includes('+'))) { + // Already in ISO format with timezone info, just parse it + date = new Date(utcTimestamp); + } else { + // Assume format: YYYY-MM-DD HH:MM:SS and explicitly treat as UTC + // MySQL timestamp format from database + const parts = utcTimestamp.split(/[- :]/); + if (parts.length >= 6) { + date = new Date(Date.UTC( + parseInt(parts[0]), + parseInt(parts[1])-1, + parseInt(parts[2]), + parseInt(parts[3]), + parseInt(parts[4]), + parseInt(parts[5]) + )); + } else { + // If no specific format is detected, append 'Z' to signal UTC + date = new Date(utcTimestamp + 'Z'); + } + } + + // Check if date parsing succeeded + if (isNaN(date.getTime())) { + throw new Error("Invalid timestamp format"); + } + + // get the locale from the browser + const locale = navigator.language || 'en-US'; + + // Format the date in the selected timezone + return date.toLocaleString(locale, { + timeZone: selectedTimezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + } catch (error) { + console.error("Error formatting timestamp:", error, utcTimestamp); + return utcTimestamp; // Return original if there's an error + } +} + +// Main data loading functions +function loadData() { + loadDashboardData(); + loadRequestLogData(); +} + +// Function to load dashboard widget data +function loadDashboardData() { + const range = rangeSelector.value; + fetch(`/apm/data/dashboard?range=${range}`) + .then(response => { + if (!response.ok) throw new Error('Network response was not ok'); + return response.json(); + }) + .then(data => { + console.log('Dashboard data received:', data); + dashboardData = data; + populateWidgets(data); + drawCharts(data); + }) + .catch(error => console.error('Error loading dashboard data:', error)); +} + +// Function to load only request log data +function loadRequestLogData() { + const range = rangeSelector.value; + fetch(`/apm/data/requests?range=${range}&page=${currentPage}&search=${encodeURIComponent(searchTerm)}`) + .then(response => { + if (!response.ok) throw new Error('Network response was not ok'); + return response.json(); + }) + .then(data => { + console.log('Request log data received:', data); + populateRequestLog(data.requests); + updatePagination(data.pagination); + }) + .catch(error => console.error('Error loading request log data:', error)); +} + +// Populate widgets with data +function populateWidgets(data) { + const slowRequests = document.getElementById('slow-requests'); + slowRequests.innerHTML = data.slowRequests.map(r => { + const time = parseFloat(r.total_time) * 1000; + return `
  • + ${r.request_url} + ${time.toFixed(3)} ms +
  • `; + }).join(''); + + const slowRoutes = document.getElementById('slow-routes'); + slowRoutes.innerHTML = data.slowRoutes.map(r => { + const time = parseFloat(r.avg_time) * 1000; + return `
  • + ${r.route_pattern} + ${time.toFixed(3)} ms +
  • `; + }).join(''); + + document.getElementById('error-rate').textContent = `${(data.errorRate * 100).toFixed(2)}%`; + + const longQueries = document.getElementById('long-queries'); + longQueries.innerHTML = data.longQueries.map(q => { + const time = parseFloat(q.execution_time) * 1000; + const queryText = q.query.length > 50 ? q.query.substring(0, 50) + '...' : q.query; + return `
  • + ${queryText} + ${time.toFixed(3)} ms +
  • `; + }).join(''); + + const slowMiddleware = document.getElementById('slow-middleware'); + slowMiddleware.innerHTML = data.slowMiddleware.map(m => { + const time = parseFloat(m.execution_time) * 1000; + return `
  • + ${m.middleware_name} + ${time.toFixed(3)} ms +
  • `; + }).join(''); + + const allRequestsCount = document.getElementById('all-requests-count'); + allRequestsCount.textContent = data.allRequestsCount; + + document.getElementById('cache-hit-rate').textContent = `${(data.cacheHitRate * 100).toFixed(2)}% Hits`; + + document.getElementById('p95').textContent = (data.p95 * 1000).toFixed(3); + document.getElementById('p99').textContent = (data.p99 * 1000).toFixed(3); +} + +// Pretty format JSON +function formatJson(json) { + if (!json) return 'No data'; + try { + // If it's already a string, parse it to ensure valid JSON + const obj = typeof json === 'string' ? JSON.parse(json) : json; + return JSON.stringify(obj, null, 2) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function (match) { + let cls = 'json-number'; + if (/^"/.test(match)) { + if (/:$/.test(match)) { + cls = 'json-key'; + } else { + cls = 'json-string'; + } + } else if (/true|false/.test(match)) { + cls = 'json-boolean'; + } else if (/null/.test(match)) { + cls = 'json-null'; + } + return '' + match + ''; + }); + } catch (e) { + return String(json); + } +} + +// Populate request log table with improved details +function populateRequestLog(requests) { + const requestLog = document.getElementById('request-log'); + requestLog.innerHTML = requests.map((r, index) => { + const time = parseFloat(r.total_time) * 1000; + + // Convert UTC timestamp to selected timezone + const formattedTimestamp = formatTimestamp(r.timestamp); + + // Build details sections conditionally + let detailSections = []; + + // Middleware section + if (r.middleware && r.middleware.length > 0) { + detailSections.push(` +
    +
    Middleware
    +
    + + + + + + + + + ${r.middleware.map(m => ` + + + + + `).join('')} + +
    NameExecution Time
    ${m.middleware_name}${(parseFloat(m.execution_time) * 1000).toFixed(3)} ms
    +
    +
    + `); + } + + // Database Queries section + if (r.queries && r.queries.length > 0) { + detailSections.push(` +
    +
    Database Queries
    +
    + + + + + + + + + + ${r.queries.map(q => ` + + + + + + `).join('')} + +
    QueryExecution TimeRows
    ${q.query}${(parseFloat(q.execution_time) * 1000).toFixed(3)} ms${q.row_count}
    +
    +
    + `); + } + + // Errors section + if (r.errors && r.errors.length > 0) { + detailSections.push(` +
    +
    Errors
    +
    + + + + + + + + + ${r.errors.map(e => ` + + + + + `).join('')} + +
    Error MessageError Code
    ${e.error_message}${e.error_code}
    +
    +
    + `); + } + + // Cache Operations section + if (r.cache && r.cache.length > 0) { + detailSections.push(` +
    +
    Cache Operations
    +
    + + + + + + + + + + ${r.cache.map(c => ` + + + + + + `).join('')} + +
    KeyResultTime
    ${c.cache_key}${c.hit ? 'Hit' : 'Miss'}${(parseFloat(c.execution_time) * 1000).toFixed(3)} ms
    +
    +
    + `); + } + + // Custom Events section + if (r.custom_events && r.custom_events.length > 0) { + detailSections.push(` +
    +
    Custom Events
    +
    + ${r.custom_events.map((event, eventIndex) => ` +
    +

    + +

    +
    +
    +
    ${formatJson(event.data)}
    +
    +
    +
    + `).join('')} +
    +
    + `); + } + + // If no details available + if (detailSections.length === 0) { + detailSections.push(`
    No detailed information available for this request.
    `); + } + + // Check if URL is long enough to need truncation + const urlTooLong = r.request_url.length > 40; + const urlCellClass = urlTooLong ? 'truncated-url' : ''; + + return ` + + ${formattedTimestamp} + ${r.request_url} + ${time.toFixed(3)} ms + ${r.response_code} + + +
    +
    + ${detailSections.join('')} +
    +
    + + + `; + }).join(''); +} + +// Update pagination controls +function updatePagination(pagination) { + currentPage = pagination.currentPage; + totalPages = pagination.totalPages; + perPage = pagination.perPage; + + // Update pagination info + const start = (currentPage - 1) * perPage + 1; + const end = Math.min(currentPage * perPage, pagination.totalRequests); + document.getElementById('pagination-info').textContent = `Showing ${start} to ${end} of ${pagination.totalRequests} requests`; + + // Update pagination buttons + const prevPage = document.getElementById('prev-page'); + const nextPage = document.getElementById('next-page'); + prevPage.classList.toggle('disabled', currentPage === 1); + nextPage.classList.toggle('disabled', currentPage === totalPages); + + prevPage.onclick = () => { + if (currentPage > 1) { + currentPage--; + loadRequestLogData(); // Only reload request log data + } + return false; + }; + + nextPage.onclick = () => { + if (currentPage < totalPages) { + currentPage++; + loadRequestLogData(); // Only reload request log data + } + return false; + }; +} + +// Draw charts with data +function drawCharts(data) { + // Latency Chart + const ctxLatency = document.getElementById('latencyChart').getContext('2d'); + if (latencyChart) latencyChart.destroy(); + latencyChart = new Chart(ctxLatency, { + type: 'line', + data: { + labels: data.chartData.map(d => d.timestamp), + datasets: [{ + label: 'Average Latency (ms)', + data: data.chartData.map(d => d.average_time * 1000), + borderColor: '#0d6efd', + backgroundColor: 'rgba(13, 110, 253, 0.1)', + fill: true, + tension: 0.4, + }] + }, + options: { + scales: { + x: { title: { display: true, text: 'Time' } }, + y: { + title: { display: true, text: 'Latency (ms)' }, + beginAtZero: true, + suggestedMax: 1 + } + }, + plugins: { + legend: { + display: true, + position: 'top', + } + } + } + }); + + // Response Code Distribution Over Time (Stacked Bar Chart) + const ctxResponse = document.getElementById('responseCodeChart').getContext('2d'); + if (responseCodeChart) responseCodeChart.destroy(); + + // Extract all unique response codes + const responseCodes = [...new Set(data.responseCodeOverTime.flatMap(d => Object.keys(d).filter(k => k !== 'timestamp')))]; + + // Create datasets for each response code + const datasets = responseCodes.map(code => { + // Determine color based on response code + let color; + if (code >= 500 && code <= 599) { + color = '#dc3545'; // Red for 5xx errors + } else if (code >= 400 && code <= 499) { + color = '#ffc107'; // Yellow for 4xx errors + } else if (code >= 300 && code <= 399) { + color = '#0dcaf0'; // Cyan for 3xx redirects + } else { + color = '#198754'; // Green for 2xx success + } + + return { + label: `Code ${code}`, + data: data.responseCodeOverTime.map(d => d[code] || 0), + backgroundColor: color, + borderWidth: 1, + }; + }); + + responseCodeChart = new Chart(ctxResponse, { + type: 'bar', + data: { + labels: data.responseCodeOverTime.map(d => d.timestamp), + datasets: datasets + }, + options: { + scales: { + x: { + title: { display: true, text: 'Time' }, + stacked: true, + }, + y: { + title: { display: true, text: 'Request Count' }, + beginAtZero: true, + stacked: true, + } + }, + plugins: { + legend: { + position: 'bottom', + } + } + } + }); +} + +// Debounce function +function debounce(func, delay) { + return function() { + const context = this; + const args = arguments; + clearTimeout(searchDebounceTimer); + searchDebounceTimer = setTimeout(() => func.apply(context, args), delay); + }; +} + +// Event listeners +rangeSelector.addEventListener('change', loadData); + +// Add event listener for timezone selector +timezoneSelector.addEventListener('change', () => { + selectedTimezone = timezoneSelector.value; + localStorage.setItem('selectedTimezone', selectedTimezone); + // Refresh only the request log data since it contains timestamps + loadRequestLogData(); +}); + +// Search functionality with debounce +const searchInput = document.getElementById('request-search'); +searchInput.addEventListener('input', debounce(() => { + searchTerm = searchInput.value; + currentPage = 1; // Reset to first page on search + loadRequestLogData(); // Only reload request log data +}, DEBOUNCE_DELAY)); + +// Initialize dashboard on page load +document.addEventListener('DOMContentLoaded', () => { + loadData(); +}); diff --git a/dashboard/views/dashboard.php b/dashboard/views/dashboard.php index e51aa58..122fbf1 100644 --- a/dashboard/views/dashboard.php +++ b/dashboard/views/dashboard.php @@ -9,134 +9,7 @@ - +
    @@ -149,12 +22,39 @@ + +
    + +
    + +
    + Total Requests: Loading... +
    +
    +
    @@ -299,531 +199,6 @@ - - + \ No newline at end of file diff --git a/dashboard/views/slow_requests.php b/dashboard/views/slow_requests.php deleted file mode 100644 index a51378d..0000000 --- a/dashboard/views/slow_requests.php +++ /dev/null @@ -1,148 +0,0 @@ - - - - - - Slow Requests - APM - - - - - - - - -
    -
    -

    - Slow Requests () -

    -
    - - - Back to Dashboard - -
    -
    - -
    -
    - - - - - - - - - - - - - - - -
    Request URLTotal Time (ms)
    - - ms - -
    -
    -
    -
    - - - - - - - \ No newline at end of file diff --git a/src/Apm.php b/src/Apm.php index 6875511..4fe20fb 100644 --- a/src/Apm.php +++ b/src/Apm.php @@ -77,7 +77,7 @@ public function bindEventsToFlightInstance(Engine $app): void ]; $this->metrics['cache'] = []; $this->metrics['custom'] = []; - + $this->metrics['is_bot'] = false; $dispatcher = $app->eventDispatcher(); $dispatcher->on('flight.request.received', function (Request $request) { @@ -85,6 +85,10 @@ public function bindEventsToFlightInstance(Engine $app): void $this->metrics['start_memory'] = memory_get_usage(); $this->metrics['request_method'] = $request->method; $this->metrics['request_url'] = $request->url; + + // Check if the request is from a bot + $userAgent = $request->headers['User-Agent'] ?? ''; + $this->metrics['is_bot'] = $this->isBot($userAgent); }); $dispatcher->on('flight.route.executed', function (Route $route, float $executionTime) { @@ -184,4 +188,38 @@ public function getMetrics(): array { return $this->metrics; } + + /** + * Checks if the user agent string belongs to a known bot. + * + * This method checks the user agent string against a list of known bot user agents + * + * @param string $userAgent The user agent string to check. + * + * @return bool Returns true if the user agent is a bot, false otherwise. + */ + public function isBot(string $userAgent): bool + { + // List of known bot user agents + $botUserAgents = [ + 'Googlebot', + 'Bingbot', + 'Slurp', + 'DuckDuckBot', + 'Baiduspider', + 'YandexBot', + 'Sogou', + 'Exabot', + 'facebot', + 'ia_archiver' + ]; + + foreach ($botUserAgents as $bot) { + if (stripos($userAgent, $bot) !== false) { + return true; + } + } + + return false; + } } \ No newline at end of file diff --git a/src/apm/migration/sqlite/0001-initial-schema.sql b/src/apm/migration/sqlite/0001-initial-schema.sql new file mode 100644 index 0000000..44ad094 --- /dev/null +++ b/src/apm/migration/sqlite/0001-initial-schema.sql @@ -0,0 +1,132 @@ +-- Main requests table +CREATE TABLE IF NOT EXISTS apm_requests ( + request_id TEXT PRIMARY KEY, + timestamp TEXT NOT NULL, + request_method TEXT, + request_url TEXT, + total_time REAL, + peak_memory INTEGER, + response_code INTEGER, + response_size INTEGER, + response_build_time REAL +); + +-- Create indexes for the main table +CREATE INDEX IF NOT EXISTS idx_apm_requests_timestamp ON apm_requests(timestamp); +CREATE INDEX IF NOT EXISTS idx_apm_requests_url ON apm_requests(request_url); +CREATE INDEX IF NOT EXISTS idx_apm_requests_response_code ON apm_requests(response_code); +CREATE INDEX IF NOT EXISTS idx_apm_requests_composite ON apm_requests(timestamp, response_code, request_method); + +-- Routes table +CREATE TABLE IF NOT EXISTS apm_routes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id TEXT NOT NULL, + route_pattern TEXT, + execution_time REAL, + memory_used INTEGER, + FOREIGN KEY (request_id) REFERENCES apm_requests(request_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_apm_routes_request_id ON apm_routes(request_id); +CREATE INDEX IF NOT EXISTS idx_apm_routes_pattern ON apm_routes(route_pattern); + +-- Middleware table +CREATE TABLE IF NOT EXISTS apm_middleware ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id TEXT NOT NULL, + route_pattern TEXT, + middleware_name TEXT, + execution_time REAL, + FOREIGN KEY (request_id) REFERENCES apm_requests(request_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_apm_middleware_request_id ON apm_middleware(request_id); +CREATE INDEX IF NOT EXISTS idx_apm_middleware_name ON apm_middleware(middleware_name); + +-- Views table +CREATE TABLE IF NOT EXISTS apm_views ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id TEXT NOT NULL, + view_file TEXT, + render_time REAL, + FOREIGN KEY (request_id) REFERENCES apm_requests(request_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_apm_views_request_id ON apm_views(request_id); +CREATE INDEX IF NOT EXISTS idx_apm_views_file ON apm_views(view_file); + +-- DB Connections table +CREATE TABLE IF NOT EXISTS apm_db_connections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id TEXT NOT NULL, + engine TEXT, + host TEXT, + database_name TEXT, + FOREIGN KEY (request_id) REFERENCES apm_requests(request_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_apm_db_connections_request_id ON apm_db_connections(request_id); +CREATE INDEX IF NOT EXISTS idx_apm_db_connections_engine ON apm_db_connections(engine); + +-- DB Queries table +CREATE TABLE IF NOT EXISTS apm_db_queries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id TEXT NOT NULL, + query TEXT, + params TEXT, + execution_time REAL, + row_count INTEGER, + memory_usage INTEGER, + FOREIGN KEY (request_id) REFERENCES apm_requests(request_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_apm_db_queries_request_id ON apm_db_queries(request_id); +CREATE INDEX IF NOT EXISTS idx_apm_db_queries_execution_time ON apm_db_queries(execution_time); + +-- Errors table +CREATE TABLE IF NOT EXISTS apm_errors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id TEXT NOT NULL, + error_message TEXT, + error_code INTEGER, + error_trace TEXT, + FOREIGN KEY (request_id) REFERENCES apm_requests(request_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_apm_errors_request_id ON apm_errors(request_id); +CREATE INDEX IF NOT EXISTS idx_apm_errors_code ON apm_errors(error_code); + +-- Cache operations table +CREATE TABLE IF NOT EXISTS apm_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id TEXT NOT NULL, + cache_key TEXT, + hit INTEGER, + execution_time REAL, + FOREIGN KEY (request_id) REFERENCES apm_requests(request_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_apm_cache_request_id ON apm_cache(request_id); +CREATE INDEX IF NOT EXISTS idx_apm_cache_key ON apm_cache(cache_key); +CREATE INDEX IF NOT EXISTS idx_apm_cache_hit ON apm_cache(hit); + +-- Custom events table +CREATE TABLE IF NOT EXISTS apm_custom_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id TEXT NOT NULL, + event_type TEXT NOT NULL, + event_data TEXT, + timestamp TEXT NOT NULL, + FOREIGN KEY (request_id) REFERENCES apm_requests(request_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_apm_custom_events_request_id ON apm_custom_events(request_id); +CREATE INDEX IF NOT EXISTS idx_apm_custom_events_type ON apm_custom_events(event_type); +CREATE INDEX IF NOT EXISTS idx_apm_custom_events_timestamp ON apm_custom_events(timestamp); + +-- Raw metrics table for data not covered by the schema +CREATE TABLE IF NOT EXISTS apm_raw_metrics ( + request_id TEXT PRIMARY KEY, + metrics_json TEXT NOT NULL, + FOREIGN KEY (request_id) REFERENCES apm_requests(request_id) ON DELETE CASCADE +); \ No newline at end of file diff --git a/src/apm/migration/sqlite/0002-is_bot.sql b/src/apm/migration/sqlite/0002-is_bot.sql new file mode 100644 index 0000000..885cf54 --- /dev/null +++ b/src/apm/migration/sqlite/0002-is_bot.sql @@ -0,0 +1,2 @@ +ALTER TABLE apm_requests +ADD COLUMN is_bot INTEGER DEFAULT 0; \ No newline at end of file diff --git a/src/apm/presenter/SqlitePresenter.php b/src/apm/presenter/SqlitePresenter.php index c0c4a9d..f6bef96 100644 --- a/src/apm/presenter/SqlitePresenter.php +++ b/src/apm/presenter/SqlitePresenter.php @@ -28,7 +28,7 @@ public function __construct(string $dsn) /** * {@inheritdoc} */ - public function getDashboardData(string $threshold): array + public function getDashboardData(string $threshold, string $range = 'last_hour'): array { // Slowest Requests $stmt = $this->db->prepare('SELECT request_id, request_url, total_time FROM apm_requests WHERE timestamp >= ? ORDER BY total_time DESC LIMIT 5'); @@ -68,12 +68,26 @@ public function getDashboardData(string $threshold): array $hitCount = $hits ? array_sum(array_column($hits, 'count')) : 0; $cacheHitRate = $totalCacheOps > 0 ? $hitCount / $totalCacheOps : 0; - // Response Code Distribution Over Time + // Response Code Distribution Over Time - with interval based on time range $stmt = $this->db->prepare('SELECT timestamp, response_code FROM apm_requests WHERE timestamp >= ? ORDER BY timestamp'); $stmt->execute([$threshold]); $requestData = $stmt->fetchAll(); $responseCodeData = []; - $interval = 300; // 5 minutes + + // Set interval based on the selected time range + switch ($range) { + case 'last_day': + $interval = 1800; // 30 minutes (60 * 30) + break; + case 'last_week': + $interval = 21600; // 6 hours (60 * 60 * 6) + break; + case 'last_hour': + default: + $interval = 300; // 5 minutes (default) + break; + } + foreach ($requestData as $row) { $timestamp = strtotime($row['timestamp']); $bucket = floor($timestamp / $interval) * $interval; @@ -108,7 +122,8 @@ public function getDashboardData(string $threshold): array $stmt->execute([$threshold]); $requestData = $stmt->fetchAll(); $aggregatedData = []; - $interval = 300; // 5 minutes + + // Use the same interval for consistent visualization foreach ($requestData as $row) { $timestamp = strtotime($row['timestamp']); $bucket = floor($timestamp / $interval) * $interval; @@ -120,7 +135,7 @@ public function getDashboardData(string $threshold): array } $chartData = array_map(function($bucket, $data) { return [ - 'timestamp' => date('Y-m-d H:i:s', $bucket), + 'timestamp' => date('Y-m-d H:i:s', (int) $bucket), 'average_time' => $data['sum'] / $data['count'], ]; }, array_keys($aggregatedData), $aggregatedData); @@ -136,6 +151,7 @@ public function getDashboardData(string $threshold): array 'p95' => $p95, 'p99' => $p99, 'chartData' => $chartData, + 'allRequestsCount' => $totalRequests, ]; } @@ -200,7 +216,7 @@ public function getRequestsData(string $threshold, int $page, int $perPage, stri 'totalPages' => 0, 'perPage' => $perPage, 'totalRequests' => 0, - ], + ] ]; } @@ -219,7 +235,7 @@ public function getRequestsData(string $threshold, int $page, int $perPage, stri 'totalPages' => $totalPages, 'perPage' => $perPage, 'totalRequests' => $totalRequests, - ], + ] ]; } @@ -247,7 +263,7 @@ public function getRequestsData(string $threshold, int $page, int $perPage, stri 'totalPages' => $totalPages, 'perPage' => $perPage, 'totalRequests' => $totalRequests, - ], + ] ]; } diff --git a/src/apm/writer/SqliteWriter.php b/src/apm/writer/SqliteWriter.php index 2ae252d..204b2a5 100644 --- a/src/apm/writer/SqliteWriter.php +++ b/src/apm/writer/SqliteWriter.php @@ -121,8 +121,9 @@ protected function storeMainMetrics(array $metrics): string peak_memory, response_code, response_size, - response_build_time - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + response_build_time, + is_bot + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) '); $stmt->execute([ @@ -134,7 +135,8 @@ protected function storeMainMetrics(array $metrics): string $metrics['peak_memory'] ?? null, $metrics['response_code'] ?? null, $metrics['response_size'] ?? null, - $metrics['response_build_time'] ?? null + $metrics['response_build_time'] ?? null, + $metrics['is_bot'] ?? null ]); return $requestId; @@ -443,141 +445,7 @@ protected function ensureTablesExist(): void return; } catch (PDOException $e) { // Tables don't exist, continue to create them + throw new \RuntimeException("Database tables do not exist. Make sure you have run 'php vendor/bin/runway apm:migrate'."); } - - // Create tables with optimizations for SQLite - - // Main requests table - $this->pdo->exec("CREATE TABLE IF NOT EXISTS apm_requests ( - request_id TEXT PRIMARY KEY, - timestamp TEXT NOT NULL, - request_method TEXT, - request_url TEXT, - total_time REAL, - peak_memory INTEGER, - response_code INTEGER, - response_size INTEGER, - response_build_time REAL - )"); - - // Create indexes for the main table - $this->pdo->exec("CREATE INDEX IF NOT EXISTS idx_apm_requests_timestamp ON apm_requests(timestamp)"); - $this->pdo->exec("CREATE INDEX IF NOT EXISTS idx_apm_requests_url ON apm_requests(request_url)"); - $this->pdo->exec("CREATE INDEX IF NOT EXISTS idx_apm_requests_response_code ON apm_requests(response_code)"); - $this->pdo->exec("CREATE INDEX IF NOT EXISTS idx_apm_requests_composite ON apm_requests(timestamp, response_code, request_method)"); - - // Routes table - $this->pdo->exec("CREATE TABLE IF NOT EXISTS apm_routes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - request_id TEXT NOT NULL, - route_pattern TEXT, - execution_time REAL, - memory_used INTEGER, - FOREIGN KEY (request_id) REFERENCES apm_requests(request_id) ON DELETE CASCADE - )"); - - $this->pdo->exec("CREATE INDEX IF NOT EXISTS idx_apm_routes_request_id ON apm_routes(request_id)"); - $this->pdo->exec("CREATE INDEX IF NOT EXISTS idx_apm_routes_pattern ON apm_routes(route_pattern)"); - - // Middleware table - $this->pdo->exec("CREATE TABLE IF NOT EXISTS apm_middleware ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - request_id TEXT NOT NULL, - route_pattern TEXT, - middleware_name TEXT, - execution_time REAL, - FOREIGN KEY (request_id) REFERENCES apm_requests(request_id) ON DELETE CASCADE - )"); - - $this->pdo->exec("CREATE INDEX IF NOT EXISTS idx_apm_middleware_request_id ON apm_middleware(request_id)"); - $this->pdo->exec("CREATE INDEX IF NOT EXISTS idx_apm_middleware_name ON apm_middleware(middleware_name)"); - - // Views table - $this->pdo->exec("CREATE TABLE IF NOT EXISTS apm_views ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - request_id TEXT NOT NULL, - view_file TEXT, - render_time REAL, - FOREIGN KEY (request_id) REFERENCES apm_requests(request_id) ON DELETE CASCADE - )"); - - $this->pdo->exec("CREATE INDEX IF NOT EXISTS idx_apm_views_request_id ON apm_views(request_id)"); - $this->pdo->exec("CREATE INDEX IF NOT EXISTS idx_apm_views_file ON apm_views(view_file)"); - - // DB Connections table - $this->pdo->exec("CREATE TABLE IF NOT EXISTS apm_db_connections ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - request_id TEXT NOT NULL, - engine TEXT, - host TEXT, - database_name TEXT, - FOREIGN KEY (request_id) REFERENCES apm_requests(request_id) ON DELETE CASCADE - )"); - - $this->pdo->exec("CREATE INDEX IF NOT EXISTS idx_apm_db_connections_request_id ON apm_db_connections(request_id)"); - $this->pdo->exec("CREATE INDEX IF NOT EXISTS idx_apm_db_connections_engine ON apm_db_connections(engine)"); - - // DB Queries table - $this->pdo->exec("CREATE TABLE IF NOT EXISTS apm_db_queries ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - request_id TEXT NOT NULL, - query TEXT, - params TEXT, - execution_time REAL, - row_count INTEGER, - memory_usage INTEGER, - FOREIGN KEY (request_id) REFERENCES apm_requests(request_id) ON DELETE CASCADE - )"); - - $this->pdo->exec("CREATE INDEX IF NOT EXISTS idx_apm_db_queries_request_id ON apm_db_queries(request_id)"); - $this->pdo->exec("CREATE INDEX IF NOT EXISTS idx_apm_db_queries_execution_time ON apm_db_queries(execution_time)"); - - // Errors table - $this->pdo->exec("CREATE TABLE IF NOT EXISTS apm_errors ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - request_id TEXT NOT NULL, - error_message TEXT, - error_code INTEGER, - error_trace TEXT, - FOREIGN KEY (request_id) REFERENCES apm_requests(request_id) ON DELETE CASCADE - )"); - - $this->pdo->exec("CREATE INDEX IF NOT EXISTS idx_apm_errors_request_id ON apm_errors(request_id)"); - $this->pdo->exec("CREATE INDEX IF NOT EXISTS idx_apm_errors_code ON apm_errors(error_code)"); - - // Cache operations table - $this->pdo->exec("CREATE TABLE IF NOT EXISTS apm_cache ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - request_id TEXT NOT NULL, - cache_key TEXT, - hit INTEGER, - execution_time REAL, - FOREIGN KEY (request_id) REFERENCES apm_requests(request_id) ON DELETE CASCADE - )"); - - $this->pdo->exec("CREATE INDEX IF NOT EXISTS idx_apm_cache_request_id ON apm_cache(request_id)"); - $this->pdo->exec("CREATE INDEX IF NOT EXISTS idx_apm_cache_key ON apm_cache(cache_key)"); - $this->pdo->exec("CREATE INDEX IF NOT EXISTS idx_apm_cache_hit ON apm_cache(hit)"); - - // Custom events table - $this->pdo->exec("CREATE TABLE IF NOT EXISTS apm_custom_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - request_id TEXT NOT NULL, - event_type TEXT NOT NULL, - event_data TEXT, - timestamp TEXT NOT NULL, - FOREIGN KEY (request_id) REFERENCES apm_requests(request_id) ON DELETE CASCADE - )"); - - $this->pdo->exec("CREATE INDEX IF NOT EXISTS idx_apm_custom_events_request_id ON apm_custom_events(request_id)"); - $this->pdo->exec("CREATE INDEX IF NOT EXISTS idx_apm_custom_events_type ON apm_custom_events(event_type)"); - $this->pdo->exec("CREATE INDEX IF NOT EXISTS idx_apm_custom_events_timestamp ON apm_custom_events(timestamp)"); - - // Raw metrics table for data not covered by the schema - $this->pdo->exec("CREATE TABLE IF NOT EXISTS apm_raw_metrics ( - request_id TEXT PRIMARY KEY, - metrics_json TEXT NOT NULL, - FOREIGN KEY (request_id) REFERENCES apm_requests(request_id) ON DELETE CASCADE - )"); } } diff --git a/src/commands/InitCommand.php b/src/commands/InitCommand.php index 259f6f2..8d4852f 100644 --- a/src/commands/InitCommand.php +++ b/src/commands/InitCommand.php @@ -167,5 +167,19 @@ protected function runStorageConfigWalkthrough(string $configFile): void file_put_contents($configFile, $json); $io->boldGreen('APM configuration saved successfully! Configuration saved at '. $configFile, true); + + $answer = $io->prompt('Do you want to run the migration now? (y/n)', 'y',); + + if (strtolower($answer) !== 'y') { + $io->info('Exiting without running migration.', true); + return; + } + + + $io->info('Running migration...', true); + + $this->app()->handle([ 'vendor/bin/runway', 'apm:migrate', '--config-file', $configFile ]); + + $io->boldGreen('Migration completed successfully!', true); } } diff --git a/src/commands/MigrateCommand.php b/src/commands/MigrateCommand.php new file mode 100644 index 0000000..b954557 --- /dev/null +++ b/src/commands/MigrateCommand.php @@ -0,0 +1,164 @@ + $config JSON config from .runway-config.json + */ + public function __construct(array $config) + { + parent::__construct('apm:migrate', 'Run database migrations for APM', $config); + + // Add option for config file path + $this->option('-c --config-file path', 'Path to the runway config file', null, getcwd() . '/.runway-config.json'); + } + + public function interact(Interactor $io): void + { + // No interaction needed before execute + } + + public function execute() + { + $configFile = $this->configFile; + $io = $this->app()->io(); + + // Check if config file exists + if (file_exists($configFile) === false) { + $io->error("Config file not found at {$configFile}", true); + return; + } + + // Load config + $config = json_decode(file_get_contents($configFile), true) ?? []; + if (empty($config['apm'])) { + $io->error('APM configuration not found. Please run apm:init first.', true); + return; + } + + $apmConfig = $config['apm']; + $storageType = $apmConfig['storage_type'] ?? null; + + if (empty($storageType)) { + $io->error('Storage type not configured. Please run apm:init first.', true); + return; + } + + // Set up migrations directory + $migrationsDir = __DIR__ . '/../apm/migration/' . $storageType; + if (!is_dir($migrationsDir)) { + $io->error("Migrations directory not found for {$storageType}: {$migrationsDir}", true); + return; + } + + // Get executed migrations + $executedMigrations = $apmConfig['executed_migrations'] ?? []; + + // Get all migration files + $migrationFiles = $this->getMigrationFiles($migrationsDir); + + if (empty($migrationFiles)) { + $io->info("No migration files found in {$migrationsDir}", true); + return; + } + + // Get database connection + try { + $db = $this->getDatabaseConnection($apmConfig); + } catch (PDOException $e) { + $io->error("Failed to connect to database: " . $e->getMessage(), true); + return; + } + + $io->boldCyan("Running migrations for {$storageType} storage", true); + + $newExecutedMigrations = []; + + // Run each migration that hasn't been executed yet + foreach ($migrationFiles as $migrationFile) { + $filename = basename($migrationFile); + + if (in_array($filename, $executedMigrations)) { + $io->comment("Skipping {$filename} (already executed)", true); + $newExecutedMigrations[] = $filename; + continue; + } + + $io->info("Running migration: {$filename}", true); + + try { + // Get SQL from migration file + $sql = file_get_contents($migrationFile); + if (empty($sql)) { + $io->warn("Empty migration file: {$filename}", true); + continue; + } + + // Execute the SQL + $db->exec($sql); + + // Add to executed migrations + $newExecutedMigrations[] = $filename; + $io->boldGreen("Successfully executed {$filename}", true); + } catch (PDOException $e) { + $io->error("Failed to execute {$filename}: " . $e->getMessage(), true); + // Continue with other migrations + } + } + + // Update config with executed migrations + $config['apm']['executed_migrations'] = $newExecutedMigrations; + $json = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + file_put_contents($configFile, $json); + + $io->boldGreen("Migration completed successfully!", true); + } + + /** + * Get all migration files in the migrations directory + * + * @param string $migrationsDir + * @return array + */ + protected function getMigrationFiles(string $migrationsDir): array + { + $files = glob($migrationsDir . '/*.sql'); + return $files !== false ? $files : []; + } + + /** + * Get database connection based on storage type + * + * @param array $config + * @return PDO + */ + protected function getDatabaseConnection(array $config): PDO + { + $storageType = $config['storage_type']; + + switch ($storageType) { + case 'sqlite': + $dsn = $config['dest_db_dsn']; + var_dump($dsn); + return new PDO($dsn, null, null, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + + default: + throw new \InvalidArgumentException("Unsupported storage type: {$storageType}"); + } + } +}