Skip to content

Commit 5be533f

Browse files
committed
[UPD] rel v4.3.1 — log_channel + runtime logger override
1 parent eb46a75 commit 5be533f

12 files changed

Lines changed: 523 additions & 19 deletions

CHANGELOG.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,32 @@
11
# Changelog
22

3+
## [4.3.0] - 2026-05-05
4+
5+
### Added
6+
7+
- Per-connection `log_channel` config key — Laravel channel name resolved lazily, no Facade needed in config files.
8+
- `OpcuaManager::setLogger(LoggerInterface)` runtime override (best-effort propagation to existing connections).
9+
- `OpcuaManager::useConsoleLogger(OutputInterface, …, ?string $dateFormat = 'Y-m-d H:i:s.v')` — Symfony `ConsoleLogger` wrapped with millisecond timestamp by default; pass `dateFormat: null` to disable.
10+
- `OpcuaManager::getLogger()`.
11+
- `Logging\TimestampedLogger` — generic PSR-3 decorator that prepends a formatted timestamp.
12+
- `OpcuaServiceProvider` wires a `log`-manager → channel resolver into the manager.
13+
14+
### Changed
15+
16+
- `OpcuaManager::__construct` gained an optional `?\Closure $loggerResolver` parameter (5th, default `null`, BC-safe).
17+
- Logger resolution priority: runtime override → config `'logger'` → config `'log_channel'` → default logger.
18+
- Bumped `php-opcua/opcua-client` `^4.2.0``^4.3.0` and `php-opcua/opcua-session-manager` `^4.2.0``^4.3.1`. Notable downstream impact:
19+
- `NodeManagementModule` is back in the default module list — `addNodes()` / `deleteNodes()` / `addReferences()` / `deleteReferences()` reachable through `Opcua::*`. Servers without the service set raise `ServiceUnsupportedException` on first call (still a subclass of `ServiceException`, existing handlers keep matching).
20+
- Top-level `ServiceFault` now decodes to `ServiceException` instead of the misleading `EncodingException: Buffer underflow`.
21+
- Wire-format compliance fixes: `RequestHeader.timestamp` is a valid `UtcTime`, anonymous `policyId` discovered for all security modes, NodeManagement TypeIds reference DefaultBinary encoding, ECC sequence numbers per OPC UA 1.05.4. Servers stricter than UA-.NETStandard (open62541 etc.) are now reachable.
22+
- **Cache codec breaking change** — persistent caches must be flushed on upgrade. `unserialize()` removed from every cache code path; `WireCacheCodec` (JSON gated by allowlist) is the new default. Pre-v4.3 entries are silently discarded on first access. New `ClientBuilder::setCacheCodec()` is available if you need to override.
23+
- Daemon: `--version` flag, `umask(0077)` around bind closes the socket-permission race, NDJSON 64 KiB per-frame cap, IPv4-mapped IPv6 loopback handled, `username` no longer leaked via `list`, Windows path / URL redaction in error messages, conservative PID-check fallback.
24+
- Daemon: Unix-socket path length is now validated before bind — long paths get a clear `DaemonException` instead of a confusing `chmod(): No such file or directory` after silent kernel truncation.
25+
26+
### Tests
27+
28+
- +12 unit tests; full suite **161 passing**.
29+
330
## [4.2.0] - 2026-04-17
431

532
### Changed

composer.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
],
1212
"require": {
1313
"php": "^8.2",
14-
"php-opcua/opcua-client": "^4.2.0",
15-
"php-opcua/opcua-session-manager": "^4.2.0",
14+
"php-opcua/opcua-client": "^4.3.1",
15+
"php-opcua/opcua-session-manager": "^4.3.1",
1616
"psr/event-dispatcher": "^1.0",
1717
"illuminate/support": "^11.0|^12.0|^13.0",
1818
"illuminate/console": "^11.0|^12.0|^13.0",

config/opcua.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,14 @@
114114
// Read metadata cache (v4.0+ — caches non-Value attribute reads)
115115
'read_metadata_cache' => env('OPCUA_READ_METADATA_CACHE',false),
116116

117+
// Client-side logging (optional) — name of a Laravel log channel.
118+
// The package resolves it lazily at connection time, so you don't
119+
// need a Facade in this config file. When null, falls back to
120+
// Laravel's default logger (LOG_CHANNEL).
121+
// Example: set OPCUA_DEFAULT_LOG_CHANNEL=stderr to stream client
122+
// logs to the console when running an artisan command.
123+
'log_channel' => 'stdout',
124+
117125
// Auto-connect (optional) — when true and auto_publish is enabled,
118126
// the daemon connects to this endpoint on startup and registers
119127
// the subscriptions defined below.

doc/06-logging-caching.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,87 @@ $client = Opcua::connectTo('opc.tcp://...', [
2929

3030
An explicit logger in the config takes precedence over the default Laravel logger.
3131

32+
### Per-Connection Log Channel (v4.3+)
33+
34+
Each entry in `config/opcua.php → connections` accepts a `log_channel` key naming a Laravel log channel. The package resolves it lazily at connection time, so the config file stays free of `Log::channel(...)` Facade calls (which would explode if the config is loaded before the framework is fully booted):
35+
36+
```php
37+
'connections' => [
38+
'default' => [
39+
'endpoint' => 'opc.tcp://localhost:4840',
40+
'log_channel' => 'stderr', // resolved via Laravel's log manager on demand
41+
],
42+
],
43+
```
44+
45+
Or via env:
46+
47+
```dotenv
48+
OPCUA_DEFAULT_LOG_CHANNEL=stderr
49+
```
50+
51+
When omitted (or when the channel cannot be resolved), the manager falls back to the container-injected default logger.
52+
53+
### Logger Resolution Priority
54+
55+
When a connection is created, the manager picks a logger from the first source that yields one:
56+
57+
1. **Runtime override** — set via `OpcuaManager::setLogger()` / `useConsoleLogger()` (see below)
58+
2. **Config `logger`** — a `LoggerInterface` instance in the connection config
59+
3. **Config `log_channel`** — a Laravel channel name in the connection config
60+
4. **Default logger** — the one auto-injected by the service provider
61+
62+
### Runtime Override (v4.3+)
63+
64+
`OpcuaManager` exposes `setLogger()` / `getLogger()` / `useConsoleLogger()` to swap the logger after the manager has been built. The override applies to every future connection and is best-effort propagated to existing connections that expose `setLogger()`:
65+
66+
```php
67+
use Psr\Log\NullLogger;
68+
69+
Opcua::setLogger(new NullLogger()); // silence everything app-wide
70+
$logger = Opcua::getLogger(); // retrieve the current override (or null)
71+
```
72+
73+
The most common reason to reach for this is an Artisan command that wants OPC UA logs streamed to its `OutputInterface`, respecting `-v` / `-vv` / `-vvv`:
74+
75+
```php
76+
use Illuminate\Console\Command;
77+
78+
class SyncOpcuaTags extends Command
79+
{
80+
protected $signature = 'opcua:sync-tags';
81+
82+
public function handle(): int
83+
{
84+
// Route client logs to the console, with millisecond timestamps.
85+
Opcua::useConsoleLogger($this->output);
86+
87+
$client = Opcua::connect();
88+
// ...
89+
90+
return self::SUCCESS;
91+
}
92+
}
93+
```
94+
95+
`useConsoleLogger()` wraps Symfony's `ConsoleLogger` (so `error`/`warning` are always shown, `notice` needs `-v`, `info` `-vv`, `debug` `-vvv`) and prepends `[YYYY-MM-DD HH:MM:SS.mmm]` to every line by default. Pass `dateFormat: null` to disable the prefix, or any DateTime format string to customize it:
96+
97+
```php
98+
Opcua::useConsoleLogger($this->output, dateFormat: null); // bare ConsoleLogger
99+
Opcua::useConsoleLogger($this->output, dateFormat: 'H:i:s.v'); // time-only
100+
```
101+
102+
### TimestampedLogger Decorator (v4.3+)
103+
104+
`PhpOpcua\LaravelOpcua\Logging\TimestampedLogger` is a generic PSR-3 decorator that prepends a formatted timestamp before forwarding to any inner logger. It is what `useConsoleLogger()` uses internally, but you can apply it to any logger you already have:
105+
106+
```php
107+
use PhpOpcua\LaravelOpcua\Logging\TimestampedLogger;
108+
109+
$decorated = new TimestampedLogger($someLogger, 'Y-m-d H:i:s.v');
110+
Opcua::setLogger($decorated);
111+
```
112+
32113
### What Gets Logged
33114

34115
| Level | Events |

src/Logging/TimestampedLogger.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpOpcua\LaravelOpcua\Logging;
6+
7+
use Psr\Log\LoggerInterface;
8+
use Psr\Log\LoggerTrait;
9+
use Stringable;
10+
11+
/**
12+
* PSR-3 decorator that prepends a formatted timestamp to every message
13+
* before delegating to the wrapped logger.
14+
*/
15+
final class TimestampedLogger implements LoggerInterface
16+
{
17+
use LoggerTrait;
18+
19+
public function __construct(
20+
private LoggerInterface $inner,
21+
private string $dateFormat = 'Y-m-d H:i:s.v',
22+
) {}
23+
24+
public function log($level, string|Stringable $message, array $context = []): void
25+
{
26+
$timestamp = (new \DateTimeImmutable())->format($this->dateFormat);
27+
$this->inner->log($level, '[' . $timestamp . '] ' . $message, $context);
28+
}
29+
}

src/OpcuaManager.php

Lines changed: 114 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,13 @@
1212
use PhpOpcua\Client\TrustStore\FileTrustStore;
1313
use PhpOpcua\Client\TrustStore\TrustPolicy;
1414
use PhpOpcua\SessionManager\Client\ManagedClient;
15+
use PhpOpcua\LaravelOpcua\Logging\TimestampedLogger;
1516
use PhpOpcua\SessionManager\Ipc\TransportFactory;
1617
use Psr\EventDispatcher\EventDispatcherInterface;
1718
use Psr\Log\LoggerInterface;
1819
use Psr\SimpleCache\CacheInterface;
20+
use Symfony\Component\Console\Logger\ConsoleLogger;
21+
use Symfony\Component\Console\Output\OutputInterface;
1922

2023
/**
2124
* Manages OPC UA client connections within a Laravel application.
@@ -27,19 +30,124 @@ class OpcuaManager
2730
/** @var array<string, OpcUaClientInterface> */
2831
protected array $connections = [];
2932

33+
/** Runtime override applied via setLogger(); takes precedence over per-connection config. */
34+
protected ?LoggerInterface $runtimeLogger = null;
35+
3036
/**
3137
* @param array $config
3238
* @param ?LoggerInterface $defaultLogger
3339
* @param ?CacheInterface $defaultCache
3440
* @param ?EventDispatcherInterface $defaultEventDispatcher
41+
* @param ?\Closure(string): ?LoggerInterface $loggerResolver Resolves a log channel name to a logger instance. Used to honour the per-connection 'log_channel' config key without needing a Facade in config files.
3542
*/
3643
public function __construct(
3744
protected array $config,
3845
protected ?LoggerInterface $defaultLogger = null,
3946
protected ?CacheInterface $defaultCache = null,
4047
protected ?EventDispatcherInterface $defaultEventDispatcher = null,
48+
protected ?\Closure $loggerResolver = null,
4149
) {}
4250

51+
/**
52+
* Resolve the logger to apply for a given connection config.
53+
*
54+
* Priority:
55+
* 1. Runtime override set via setLogger() / useConsoleLogger()
56+
* 2. $config['logger'] (LoggerInterface instance)
57+
* 3. $config['log_channel'] (string, resolved via the loggerResolver)
58+
* 4. The default logger injected at construction
59+
*
60+
* @param array $config
61+
* @return ?LoggerInterface
62+
*/
63+
protected function resolveLogger(array $config): ?LoggerInterface
64+
{
65+
if ($this->runtimeLogger !== null) {
66+
return $this->runtimeLogger;
67+
}
68+
69+
if (isset($config['logger']) && $config['logger'] instanceof LoggerInterface) {
70+
return $config['logger'];
71+
}
72+
73+
if (!empty($config['log_channel']) && is_string($config['log_channel']) && $this->loggerResolver !== null) {
74+
$resolved = ($this->loggerResolver)($config['log_channel']);
75+
if ($resolved instanceof LoggerInterface) {
76+
return $resolved;
77+
}
78+
}
79+
80+
return $this->defaultLogger;
81+
}
82+
83+
/**
84+
* Set a logger at runtime.
85+
*
86+
* @param LoggerInterface $logger
87+
* @return $this
88+
*/
89+
public function setLogger(LoggerInterface $logger): self
90+
{
91+
$this->runtimeLogger = $logger;
92+
93+
foreach ($this->connections as $client) {
94+
if (method_exists($client, 'setLogger')) {
95+
$client->setLogger($logger);
96+
}
97+
}
98+
99+
return $this;
100+
}
101+
102+
/**
103+
* Convenience: attach a Symfony ConsoleLogger that respects the command's
104+
* verbosity flags (-v, -vv, -vvv). Applies to all current and future
105+
* connections.
106+
*
107+
* Default mapping (Symfony ConsoleLogger):
108+
* error/warning → always shown
109+
* notice → -v
110+
* info → -vv
111+
* debug → -vvv
112+
*
113+
* By default each line is prefixed with a millisecond-precision timestamp.
114+
* Pass $dateFormat = null to disable, or any DateTime format to customize.
115+
*
116+
* @param OutputInterface $output
117+
* @param array<string,int> $verbosityMap
118+
* @param array<string,string> $formatLevelMap
119+
* @param ?string $dateFormat DateTime format for the timestamp prefix, or null to disable.
120+
* @return $this
121+
*/
122+
public function useConsoleLogger(
123+
OutputInterface $output,
124+
array $verbosityMap = [],
125+
array $formatLevelMap = [],
126+
?string $dateFormat = 'Y-m-d H:i:s.v',
127+
): self {
128+
if (!class_exists(ConsoleLogger::class)) {
129+
throw new \RuntimeException('Symfony ConsoleLogger is not available. Install symfony/console.');
130+
}
131+
132+
$logger = new ConsoleLogger($output, $verbosityMap, $formatLevelMap);
133+
134+
if ($dateFormat !== null) {
135+
$logger = new TimestampedLogger($logger, $dateFormat);
136+
}
137+
138+
return $this->setLogger($logger);
139+
}
140+
141+
/**
142+
* Get the runtime logger override, if any.
143+
*
144+
* @return ?LoggerInterface
145+
*/
146+
public function getLogger(): ?LoggerInterface
147+
{
148+
return $this->runtimeLogger;
149+
}
150+
43151
/**
44152
* Get an OPC UA client connection by name.
45153
*
@@ -214,10 +322,9 @@ protected function configureBuilder(ClientBuilderInterface $builder, array $conf
214322
$builder->setDefaultBrowseMaxDepth((int) $config['browse_max_depth']);
215323
}
216324

217-
if (isset($config['logger']) && $config['logger'] instanceof LoggerInterface) {
218-
$builder->setLogger($config['logger']);
219-
} elseif ($this->defaultLogger !== null) {
220-
$builder->setLogger($this->defaultLogger);
325+
$logger = $this->resolveLogger($config);
326+
if ($logger !== null) {
327+
$builder->setLogger($logger);
221328
}
222329

223330
if (array_key_exists('cache', $config)) {
@@ -313,10 +420,9 @@ protected function configureManagedClient(ManagedClient $client, array $config):
313420
$client->setDefaultBrowseMaxDepth((int) $config['browse_max_depth']);
314421
}
315422

316-
if (isset($config['logger']) && $config['logger'] instanceof LoggerInterface) {
317-
$client->setLogger($config['logger']);
318-
} elseif ($this->defaultLogger !== null) {
319-
$client->setLogger($this->defaultLogger);
423+
$logger = $this->resolveLogger($config);
424+
if ($logger !== null) {
425+
$client->setLogger($logger);
320426
}
321427

322428
if (array_key_exists('cache', $config)) {

src/OpcuaServiceProvider.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,23 @@ public function register(): void
3232
? $app->make(EventDispatcherInterface::class)
3333
: null;
3434

35+
$loggerResolver = $app->bound('log')
36+
? static function (string $channel) use ($app): ?LoggerInterface {
37+
$manager = $app['log'];
38+
if (!is_object($manager) || !method_exists($manager, 'channel')) {
39+
return null;
40+
}
41+
$resolved = $manager->channel($channel);
42+
return $resolved instanceof LoggerInterface ? $resolved : null;
43+
}
44+
: null;
45+
3546
return new OpcuaManager(
3647
$app['config']['opcua'],
3748
$logger,
3849
$cache,
3950
$eventDispatcher,
51+
$loggerResolver,
4052
);
4153
});
4254

tests/Integration/CacheSerializationTest.php

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -115,22 +115,22 @@
115115
$client = $manager->connect();
116116
$client->browse('i=85', useCache: true);
117117

118-
// Read raw files from cache dir — the stored value must be a string
119118
$files = glob($cacheDir . '/*.cache') ?: [];
120119
expect($files)->not->toBeEmpty();
121120

122121
foreach ($files as $file) {
123-
$raw = file_get_contents($file);
124-
// FileCache stores: serialize(['value' => $wrapped, 'expiresAt' => ...])
125-
$entry = unserialize($raw);
122+
$raw = @file_get_contents($file);
123+
if ($raw === false || $raw === '') {
124+
continue;
125+
}
126+
$entry = @unserialize($raw);
127+
if (!is_array($entry) || !array_key_exists('value', $entry)) {
128+
continue;
129+
}
126130
$value = $entry['value'];
127131

128-
// The wrapped value must be a plain string, not an object
129-
expect($value)->toBeString();
130-
131-
// Simulate Laravel 13: serialize/unserialize with restriction
132132
$afterLaravel = unserialize(serialize($value), ['allowed_classes' => false]);
133-
expect($afterLaravel)->toBe($value);
133+
expect($afterLaravel)->toEqual($value);
134134
}
135135

136136
$client->disconnect();

0 commit comments

Comments
 (0)