Skip to content

Commit a412c2d

Browse files
committed
Add logging support.
Log user input, command invocations, and executed code to a PSR-3 logger or a simple callback. Configuration: // Simple callback $config->setLogging(function ($kind, $data) { file_put_contents('/tmp/psysh.log', "[$kind] $data\n", FILE_APPEND); }); // PSR-3 logger with defaults // (input=info, command=info, execute=debug) $config->setLogging($psrLogger); // Granular control per event type $config->setLogging([ 'logger' => $psrLogger, 'level' => [ 'input' => 'info', 'command' => false, // disable 'execute' => 'debug', ], ]); Fixes #821, #651, #565
1 parent 194ae68 commit a412c2d

File tree

13 files changed

+1051
-0
lines changed

13 files changed

+1051
-0
lines changed

phpstan.neon.dist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ parameters:
1616
# See: https://github.com/bobthecow/psysh/pull/738#issuecomment-1202622730
1717
- src/Readline/Hoa/FileLink.php
1818
- src/Util/Str.php
19+
# Fake PSR-3 logger implements interface that may not exist when psr/log isn't installed
20+
- test/Util/FakePsrLogger.php
1921
bootstrapFiles:
2022
- vendor-bin/phpstan/bootstrap.php
2123
reportUnmatchedIgnoredErrors: true

src/Configuration.php

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@
1313

1414
use Psy\Exception\DeprecatedException;
1515
use Psy\Exception\RuntimeException;
16+
use Psy\ExecutionLoop\ExecutionLoggingListener;
17+
use Psy\ExecutionLoop\InputLoggingListener;
1618
use Psy\ExecutionLoop\ProcessForker;
1719
use Psy\Formatter\SignatureFormatter;
20+
use Psy\Logger\CallbackLogger;
1821
use Psy\Manual\ManualInterface;
1922
use Psy\Manual\V2Manual;
2023
use Psy\Manual\V3Manual;
@@ -65,6 +68,7 @@ class Configuration
6568
'historySize',
6669
'implicitUse',
6770
'interactiveMode',
71+
'logging',
6872
'manualDbFile',
6973
'pager',
7074
'prompt',
@@ -113,6 +117,7 @@ class Configuration
113117
private array $newMatchers = [];
114118
private ?array $autoloadWarmers = null;
115119
private $implicitUse = false;
120+
private ?ShellLogger $logger = null;
116121
private int $errorLoggingLevel = \E_ALL;
117122
private bool $warnOnMultipleConfigs = false;
118123
private string $colorMode = self::COLOR_MODE_AUTO;
@@ -1527,6 +1532,164 @@ public function getImplicitUse()
15271532
return $this->implicitUse;
15281533
}
15291534

1535+
/**
1536+
* Configure logging.
1537+
*
1538+
* Logs PsySH input, commands, and executed code to the provided logger.
1539+
* Accepts a PSR-3 logger, a simple callback, or an array for more control
1540+
* over log levels.
1541+
*
1542+
* Examples:
1543+
*
1544+
* // Simple callback logging
1545+
* $config->setLogging(function ($kind, $data) {
1546+
* $line = sprintf("[%s] %s\n", $kind, $data);
1547+
* file_put_contents('/tmp/psysh.log', $line, FILE_APPEND);
1548+
* });
1549+
*
1550+
* // PSR-3 logger with defaults (input=info, command=info, execute=debug)
1551+
* $config->setLogging($psrLogger);
1552+
*
1553+
* // Set single level for all event types
1554+
* $config->setLogging([
1555+
* 'logger' => $psrLogger,
1556+
* 'level' => 'debug',
1557+
* ]);
1558+
*
1559+
* // Granular control over each event type
1560+
* $config->setLogging([
1561+
* 'logger' => $psrLogger,
1562+
* 'level' => [
1563+
* 'input' => 'info',
1564+
* 'command' => false, // disable logging
1565+
* 'execute' => 'debug',
1566+
* ],
1567+
* ]);
1568+
*
1569+
* @param \Psr\Log\LoggerInterface|callable|array $logging
1570+
*/
1571+
public function setLogging($logging): void
1572+
{
1573+
$this->logger = $this->parseLoggingConfig($logging);
1574+
}
1575+
1576+
/**
1577+
* Get a ShellLogger instance if logging is configured.
1578+
*
1579+
* @return ShellLogger|null
1580+
*/
1581+
public function getLogger(): ?ShellLogger
1582+
{
1583+
return $this->logger;
1584+
}
1585+
1586+
/**
1587+
* Get an InputLoggingListener if input logging is enabled.
1588+
*
1589+
* @return InputLoggingListener|null
1590+
*/
1591+
public function getInputLogger(): ?InputLoggingListener
1592+
{
1593+
$logger = $this->getLogger();
1594+
if ($logger === null || $logger->isInputDisabled()) {
1595+
return null;
1596+
}
1597+
1598+
return new InputLoggingListener($logger);
1599+
}
1600+
1601+
/**
1602+
* Get an ExecutionLoggingListener if execution logging is enabled.
1603+
*
1604+
* @return ExecutionLoggingListener|null
1605+
*/
1606+
public function getExecutionLogger(): ?ExecutionLoggingListener
1607+
{
1608+
$logger = $this->getLogger();
1609+
if ($logger === null || $logger->isExecuteDisabled()) {
1610+
return null;
1611+
}
1612+
1613+
return new ExecutionLoggingListener($logger);
1614+
}
1615+
1616+
/**
1617+
* Parse logging configuration.
1618+
*
1619+
* @param \Psr\Log\LoggerInterface|Logger\CallbackLogger|callable|array $config
1620+
*
1621+
* @return ShellLogger
1622+
*/
1623+
private function parseLoggingConfig($config): ShellLogger
1624+
{
1625+
if (!\is_array($config)) {
1626+
$config = ['logger' => $config];
1627+
}
1628+
1629+
if (!isset($config['logger'])) {
1630+
throw new \InvalidArgumentException('Logging config array must include a "logger" key');
1631+
}
1632+
1633+
$logger = $config['logger'];
1634+
1635+
if (\is_callable($logger)) {
1636+
$logger = new CallbackLogger($logger);
1637+
}
1638+
1639+
if (!$this->isLogger($logger)) {
1640+
throw new \InvalidArgumentException('Logging "logger" must be a logger instance or callable');
1641+
}
1642+
1643+
$defaults = [
1644+
'input' => 'info',
1645+
'command' => 'info',
1646+
'execute' => 'debug',
1647+
];
1648+
1649+
if (isset($config['level'])) {
1650+
$level = $config['level'];
1651+
1652+
// String: apply same level to all types
1653+
if (\is_string($level)) {
1654+
$levels = [
1655+
'input' => $level,
1656+
'command' => $level,
1657+
'execute' => $level,
1658+
];
1659+
} elseif (\is_array($level)) {
1660+
// Array: granular per-type levels
1661+
$levels = [
1662+
'input' => $level['input'] ?? $defaults['input'],
1663+
'command' => $level['command'] ?? $defaults['command'],
1664+
'execute' => $level['execute'] ?? $defaults['execute'],
1665+
];
1666+
} else {
1667+
throw new \InvalidArgumentException('Logging "level" must be a string or array');
1668+
}
1669+
} else {
1670+
$levels = $defaults;
1671+
}
1672+
1673+
return new ShellLogger($logger, $levels);
1674+
}
1675+
1676+
/**
1677+
* Check if a value is a valid logger instance.
1678+
*
1679+
* @param mixed $logger
1680+
*
1681+
* @return bool
1682+
*/
1683+
private function isLogger($logger): bool
1684+
{
1685+
if ($logger instanceof CallbackLogger) {
1686+
return true;
1687+
}
1688+
1689+
// Safe check for LoggerInterface without requiring psr/log as a dependency
1690+
return \interface_exists('Psr\Log\LoggerInterface') && $logger instanceof \Psr\Log\LoggerInterface;
1691+
}
1692+
15301693
/**
15311694
* @deprecated Use `addMatchers` instead
15321695
*
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
/*
4+
* This file is part of Psy Shell.
5+
*
6+
* (c) 2012-2025 Justin Hileman
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Psy\ExecutionLoop;
13+
14+
use Psy\Shell;
15+
use Psy\ShellLogger;
16+
17+
/**
18+
* Execution logging listener.
19+
*
20+
* Logs code about to be executed to a ShellLogger.
21+
*/
22+
class ExecutionLoggingListener extends AbstractListener
23+
{
24+
private ShellLogger $logger;
25+
26+
/**
27+
* @param ShellLogger $logger
28+
*/
29+
public function __construct(ShellLogger $logger)
30+
{
31+
$this->logger = $logger;
32+
}
33+
34+
/**
35+
* {@inheritdoc}
36+
*/
37+
public static function isSupported(): bool
38+
{
39+
return true;
40+
}
41+
42+
/**
43+
* {@inheritdoc}
44+
*/
45+
public function onExecute(Shell $shell, string $code)
46+
{
47+
$this->logger->logExecute($code);
48+
49+
return null;
50+
}
51+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
/*
4+
* This file is part of Psy Shell.
5+
*
6+
* (c) 2012-2025 Justin Hileman
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Psy\ExecutionLoop;
13+
14+
use Psy\Shell;
15+
use Psy\ShellLogger;
16+
17+
/**
18+
* Input logging listener.
19+
*
20+
* Logs user code input to a ShellLogger.
21+
*/
22+
class InputLoggingListener extends AbstractListener
23+
{
24+
private ShellLogger $logger;
25+
26+
/**
27+
* @param ShellLogger $logger
28+
*/
29+
public function __construct(ShellLogger $logger)
30+
{
31+
$this->logger = $logger;
32+
}
33+
34+
/**
35+
* {@inheritdoc}
36+
*/
37+
public static function isSupported(): bool
38+
{
39+
return true;
40+
}
41+
42+
/**
43+
* {@inheritdoc}
44+
*/
45+
public function onInput(Shell $shell, string $input)
46+
{
47+
$this->logger->logInput($input);
48+
49+
return null;
50+
}
51+
}

src/Logger/CallbackLogger.php

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
/*
4+
* This file is part of Psy Shell.
5+
*
6+
* (c) 2012-2025 Justin Hileman
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Psy\Logger;
13+
14+
/**
15+
* Callback logger.
16+
*
17+
* A simple logger that calls a callback with the log kind and data.
18+
*/
19+
class CallbackLogger
20+
{
21+
private $callback;
22+
23+
/**
24+
* @param callable $callback Callback to invoke with (string $kind, string $data)
25+
*/
26+
public function __construct(callable $callback)
27+
{
28+
$this->callback = $callback;
29+
}
30+
31+
/**
32+
* Log a message.
33+
*
34+
* @param string $level Log level
35+
* @param string $message Log message
36+
* @param array $context Context data
37+
*/
38+
public function log(string $level, string $message, array $context = []): void
39+
{
40+
// Determine the kind from the message
41+
switch ($message) {
42+
case 'PsySH input':
43+
$kind = 'input';
44+
break;
45+
case 'PsySH command':
46+
$kind = 'command';
47+
break;
48+
case 'PsySH execute':
49+
$kind = 'execute';
50+
break;
51+
default:
52+
$kind = 'unknown';
53+
break;
54+
}
55+
56+
// Extract the data from context
57+
$data = $context[$kind] ?? $context['code'] ?? '';
58+
59+
\call_user_func($this->callback, $kind, $data);
60+
}
61+
}

0 commit comments

Comments
 (0)