diff --git a/README.md b/README.md
index 39ef667..56ebd28 100644
--- a/README.md
+++ b/README.md
@@ -62,7 +62,9 @@ operatora:
Szczegółowe logowanie włącza parametr `/debug` albo `/verbose` (obsługiwane są też
warianty `--debug` i `--verbose`). Wyłącza je brak parametru lub jawny parametr
`/nodebug` albo `/noverbose`. Błędy są zapisywane do `stderr` niezależnie od tego
-ustawienia. W usłudze Windows parametr można włączyć przez odkomentowanie elementu
+ustawienia. Każdy wiersz `stdout` i `stderr` zaczyna się znacznikiem czasu ISO 8601
+w UTC, np. `2026-09-18T12:34:56.7890000+00:00`. W usłudze Windows parametr można
+włączyć przez odkomentowanie elementu
`/verbose` w `daemon-enova/smartb2bsync-enova.xml` i restart usługi.
W trybie szczegółowym zapis ZO raportuje kolejne etapy: walidację żądania, logowanie
diff --git a/release-optima/SmartB2B.Optima.Adapter.dll b/release-optima/SmartB2B.Optima.Adapter.dll
index f3d7e3e..3d9897e 100644
Binary files a/release-optima/SmartB2B.Optima.Adapter.dll and b/release-optima/SmartB2B.Optima.Adapter.dll differ
diff --git a/release-optima/SmartB2B.Optima.Adapter.pdb b/release-optima/SmartB2B.Optima.Adapter.pdb
index 75b0992..881437c 100644
Binary files a/release-optima/SmartB2B.Optima.Adapter.pdb and b/release-optima/SmartB2B.Optima.Adapter.pdb differ
diff --git a/release-optima/SmartB2B.Optima.Service.dll b/release-optima/SmartB2B.Optima.Service.dll
index 8e5b599..2be45da 100644
Binary files a/release-optima/SmartB2B.Optima.Service.dll and b/release-optima/SmartB2B.Optima.Service.dll differ
diff --git a/release-optima/SmartB2B.Optima.Service.exe b/release-optima/SmartB2B.Optima.Service.exe
index d69d8ca..3aa1081 100644
Binary files a/release-optima/SmartB2B.Optima.Service.exe and b/release-optima/SmartB2B.Optima.Service.exe differ
diff --git a/release-optima/SmartB2B.Optima.Service.pdb b/release-optima/SmartB2B.Optima.Service.pdb
index 7d4bc1a..e85dfe4 100644
Binary files a/release-optima/SmartB2B.Optima.Service.pdb and b/release-optima/SmartB2B.Optima.Service.pdb differ
diff --git a/release-optima/SmartB2B.Sync.Contracts.dll b/release-optima/SmartB2B.Sync.Contracts.dll
index 4746a50..4788d29 100644
Binary files a/release-optima/SmartB2B.Sync.Contracts.dll and b/release-optima/SmartB2B.Sync.Contracts.dll differ
diff --git a/release-optima/SmartB2B.Sync.Contracts.pdb b/release-optima/SmartB2B.Sync.Contracts.pdb
index 26f9100..d82a385 100644
Binary files a/release-optima/SmartB2B.Sync.Contracts.pdb and b/release-optima/SmartB2B.Sync.Contracts.pdb differ
diff --git a/release-optima/SmartB2B.Sync.Service.dll b/release-optima/SmartB2B.Sync.Service.dll
index 99652e8..e40ba0f 100644
Binary files a/release-optima/SmartB2B.Sync.Service.dll and b/release-optima/SmartB2B.Sync.Service.dll differ
diff --git a/release-optima/SmartB2B.Sync.Service.pdb b/release-optima/SmartB2B.Sync.Service.pdb
index 31edbbd..6a56e96 100644
Binary files a/release-optima/SmartB2B.Sync.Service.pdb and b/release-optima/SmartB2B.Sync.Service.pdb differ
diff --git a/release-optima/daemon/smartb2bsync-optima.xml b/release-optima/daemon/smartb2bsync-optima.xml
index 8402eca..9bde0f9 100644
--- a/release-optima/daemon/smartb2bsync-optima.xml
+++ b/release-optima/daemon/smartb2bsync-optima.xml
@@ -52,8 +52,8 @@
-
+
diff --git a/src/SmartB2B.Enova.Service/Program.cs b/src/SmartB2B.Enova.Service/Program.cs
index 231300d..0a236e3 100644
--- a/src/SmartB2B.Enova.Service/Program.cs
+++ b/src/SmartB2B.Enova.Service/Program.cs
@@ -11,6 +11,7 @@ internal static class Program
{
public static async Task Main(string[] args)
{
+ ConsoleLogger.InstallTimestampedOutput();
IDisposable? sentry = null;
IErpOrderAdapter? adapter = null;
var cancellation = new CancellationTokenSource();
diff --git a/src/SmartB2B.Optima.Service/Program.cs b/src/SmartB2B.Optima.Service/Program.cs
index f04b670..92da474 100644
--- a/src/SmartB2B.Optima.Service/Program.cs
+++ b/src/SmartB2B.Optima.Service/Program.cs
@@ -11,6 +11,7 @@ internal static class Program
{
public static async Task Main(string[] args)
{
+ ConsoleLogger.InstallTimestampedOutput();
IDisposable? sentry = null;
IErpOrderAdapter? adapter = null;
var cancellation = new CancellationTokenSource();
diff --git a/src/SmartB2B.Sync.Service/Diagnostics/ConsoleLogger.cs b/src/SmartB2B.Sync.Service/Diagnostics/ConsoleLogger.cs
index ef5a16f..dd6ad4f 100644
--- a/src/SmartB2B.Sync.Service/Diagnostics/ConsoleLogger.cs
+++ b/src/SmartB2B.Sync.Service/Diagnostics/ConsoleLogger.cs
@@ -4,6 +4,12 @@ public static class ConsoleLogger
{
public static bool VerboseEnabled { get; private set; }
+ public static void InstallTimestampedOutput()
+ {
+ Console.SetOut(new TimestampedTextWriter(Console.Out));
+ Console.SetError(new TimestampedTextWriter(Console.Error));
+ }
+
public static void Configure(bool verboseEnabled) => VerboseEnabled = verboseEnabled;
public static void Verbose(string message)
diff --git a/src/SmartB2B.Sync.Service/Diagnostics/DiagnosticsHandler.cs b/src/SmartB2B.Sync.Service/Diagnostics/DiagnosticsHandler.cs
index 6395920..053735a 100644
--- a/src/SmartB2B.Sync.Service/Diagnostics/DiagnosticsHandler.cs
+++ b/src/SmartB2B.Sync.Service/Diagnostics/DiagnosticsHandler.cs
@@ -88,9 +88,16 @@ public sealed partial class DiagnosticsHandler
var compactDate = date.Replace("-", string.Empty, StringComparison.Ordinal);
var path = Path.Combine(_logDirectory, $"{_logPrefix}_{compactDate}.{stream}.log");
- return File.Exists(path)
- ? await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false)
- : null;
+ if (!File.Exists(path))
+ {
+ return null;
+ }
+
+ await using var log = new FileStream(
+ path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete,
+ bufferSize: 4096, useAsync: true);
+ using var reader = new StreamReader(log);
+ return await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
}
[GeneratedRegex("^\\d{4}-\\d{2}-\\d{2}$", RegexOptions.CultureInvariant)]
diff --git a/src/SmartB2B.Sync.Service/Diagnostics/TimestampedTextWriter.cs b/src/SmartB2B.Sync.Service/Diagnostics/TimestampedTextWriter.cs
new file mode 100644
index 0000000..7b84407
--- /dev/null
+++ b/src/SmartB2B.Sync.Service/Diagnostics/TimestampedTextWriter.cs
@@ -0,0 +1,93 @@
+using System.Globalization;
+using System.Text;
+
+namespace SmartB2B.Sync.Service.Diagnostics;
+
+internal sealed class TimestampedTextWriter : TextWriter
+{
+ private readonly TextWriter _inner;
+ private readonly object _sync = new();
+ private bool _atLineStart = true;
+
+ public TimestampedTextWriter(TextWriter inner) => _inner = inner;
+
+ public override Encoding Encoding => _inner.Encoding;
+
+ public override void Write(char value)
+ {
+ lock (_sync)
+ {
+ WriteCore(value.ToString().AsSpan());
+ }
+ }
+
+ public override void Write(string? value)
+ {
+ if (value is null) return;
+ lock (_sync)
+ {
+ WriteCore(value.AsSpan());
+ }
+ }
+
+ public override void Write(char[] buffer, int index, int count)
+ {
+ ArgumentNullException.ThrowIfNull(buffer);
+ lock (_sync)
+ {
+ WriteCore(buffer.AsSpan(index, count));
+ }
+ }
+
+ public override void Write(ReadOnlySpan buffer)
+ {
+ lock (_sync)
+ {
+ WriteCore(buffer);
+ }
+ }
+
+ public override void WriteLine(string? value)
+ {
+ lock (_sync)
+ {
+ if (value is not null) WriteCore(value.AsSpan());
+ WriteCore(NewLine.AsSpan());
+ }
+ }
+
+ public override void WriteLine()
+ {
+ lock (_sync)
+ {
+ WriteCore(NewLine.AsSpan());
+ }
+ }
+
+ public override void Flush()
+ {
+ lock (_sync)
+ {
+ _inner.Flush();
+ }
+ }
+
+ private void WriteCore(ReadOnlySpan text)
+ {
+ while (!text.IsEmpty)
+ {
+ if (_atLineStart)
+ {
+ _inner.Write(DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture));
+ _inner.Write(' ');
+ _atLineStart = false;
+ }
+
+ var newline = text.IndexOf('\n');
+ var length = newline >= 0 ? newline + 1 : text.Length;
+ _inner.Write(text[..length]);
+ _atLineStart = newline >= 0;
+ text = text[length..];
+ }
+ }
+}
diff --git a/tests/SmartB2B.Optima.Tests/Program.cs b/tests/SmartB2B.Optima.Tests/Program.cs
index f7024a5..6734e5e 100644
--- a/tests/SmartB2B.Optima.Tests/Program.cs
+++ b/tests/SmartB2B.Optima.Tests/Program.cs
@@ -1,7 +1,9 @@
using System.Collections.Concurrent;
+using System.Globalization;
using Newtonsoft.Json.Linq;
using SmartB2B.Optima.Adapter;
using SmartB2B.Sync.Contracts;
+using SmartB2B.Sync.Service.Diagnostics;
using SmartB2B.Sync.Service.Orders;
using SmartB2B.Sync.Service.Rpc;
using WampSharp.V2.Core.Contracts;
@@ -26,7 +28,9 @@ var tests = new (string Name, Func Run)[]
("Wynik Optimy zachowuje kwargs WAMP", HandlerReturnsKeywordResult),
("Operacje zamówień są serializowane", ConcurrentOrdersAreSerialized),
("Fasada COM działa na jednym wątku STA", FacadeRunsOnSingleStaThread),
- ("Adapter serializuje bezpośrednie wywołania COM", AdapterSerializesComCalls)
+ ("Adapter serializuje bezpośrednie wywołania COM", AdapterSerializesComCalls),
+ ("Diagnostyka czyta log podczas zapisu", DiagnosticsReadsOpenLogs),
+ ("Każdy wiersz logu ma czas ISO", ConsoleOutputHasIsoTimestamps)
};
var failures = 0;
@@ -111,6 +115,84 @@ static async Task HandlerMapsOptimaContract()
AssertEqual(123m, request.Items[0].UnitPriceGross, "brutto");
}
+static async Task DiagnosticsReadsOpenLogs()
+{
+ var directory = Path.Combine(Path.GetTempPath(), $"smartb2b-log-test-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(directory);
+ try
+ {
+ var diagnostics = new DiagnosticsHandler("test", "1", ".NET", "test", null, directory, "service");
+ var invocation = new RpcInvocation([], new Dictionary { ["date"] = "2026-09-18" });
+
+ foreach (var stream in new[] { "out", "err" })
+ {
+ var path = Path.Combine(directory, $"service_20260918.{stream}.log");
+ await using var writer = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.ReadWrite);
+ var content = $"Log {stream} zażółć";
+ var bytes = System.Text.Encoding.UTF8.GetBytes(content);
+ await writer.WriteAsync(bytes);
+ await writer.FlushAsync();
+
+ var result = stream == "out"
+ ? await diagnostics.GetLogAsync(invocation, CancellationToken.None)
+ : await diagnostics.GetErrorLogAsync(invocation, CancellationToken.None);
+ AssertEqual(content, result, $"odczyt otwartego pliku {stream}");
+ }
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+}
+
+static Task ConsoleOutputHasIsoTimestamps()
+{
+ var originalOut = Console.Out;
+ var originalError = Console.Error;
+ using var output = new StringWriter();
+ using var error = new StringWriter();
+ try
+ {
+ Console.SetOut(output);
+ Console.SetError(error);
+ ConsoleLogger.InstallTimestampedOutput();
+
+ Console.Write("pierw");
+ Console.WriteLine("szy\ndrugi");
+ ConsoleLogger.Error("błąd\nszczegóły");
+
+ AssertTimestampedLines(output.ToString(), ["pierwszy", "drugi"]);
+ AssertTimestampedLines(error.ToString(), ["błąd", "szczegóły"]);
+ }
+ finally
+ {
+ Console.SetOut(originalOut);
+ Console.SetError(originalError);
+ }
+
+ return Task.CompletedTask;
+}
+
+static void AssertTimestampedLines(string output, string[] expectedMessages)
+{
+ var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries);
+ AssertEqual(expectedMessages.Length, lines.Length, "liczba wierszy logu");
+ for (var index = 0; index < lines.Length; index++)
+ {
+ var line = lines[index].TrimEnd('\r');
+ var separator = line.IndexOf(' ');
+ if (separator < 0 ||
+ !DateTimeOffset.TryParseExact(line[..separator], "O", CultureInfo.InvariantCulture,
+ DateTimeStyles.None, out var timestamp) ||
+ timestamp.Offset != TimeSpan.Zero)
+ {
+ throw new InvalidOperationException($"Brak czasu ISO UTC w wierszu: {line}");
+ }
+
+ AssertEqual(expectedMessages[index], line[(separator + 1)..], "treść wiersza logu");
+ }
+}
+
static async Task HandlerMapsOptionalExtraFields()
{
using var adapter = new CapturingAdapter();