Add verbose Enova diagnostics and reliable Sentry reporting

This commit is contained in:
2026-08-31 21:13:46 +00:00
parent ee62a6253d
commit af6466e2dc
20 changed files with 276 additions and 37 deletions

View File

@@ -35,6 +35,20 @@ Kontrola konfiguracji i dynamicznego ładowania, bez zapisu do bazy:
.\src\SmartB2B.Enova.Service\bin\Debug\net8.0\SmartB2B.Enova.Service.exe --config .\src\SmartB2B.Enova.Service\bin\Debug\net8.0\config\enova.json.sample --check-config .\src\SmartB2B.Enova.Service\bin\Debug\net8.0\SmartB2B.Enova.Service.exe --config .\src\SmartB2B.Enova.Service\bin\Debug\net8.0\config\enova.json.sample --check-config
``` ```
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
`<arguments>/verbose</arguments>` w `daemon/smartb2bsync-enova.xml` i restart usługi.
W trybie szczegółowym zapis ZO raportuje kolejne etapy: walidację żądania, logowanie
do Enovy, wyszukanie definicji, kontrahenta, magazynu i towarów, commit transakcji
oraz `session.Save()`. Sentry zawsze wypisuje błędy własnego transportu, a w trybie
szczegółowym również przebieg kolejkowania i wysyłania zdarzenia. Przy starcie usługa
podaje używany cel Sentry bez ujawniania klucza DSN. Jeżeli lokalny `config/enova.json`
nie zawiera `sentry.dsn`, usługa wypisuje dokładną ścieżkę używanego pliku. Aktualizacja
nie kopiuje tej wartości automatycznie z `enova.json.sample` do istniejącej konfiguracji.
## Publikacja i instalacja ## Publikacja i instalacja
```powershell ```powershell

View File

@@ -4,6 +4,8 @@
<description>Synchronizuje enova365 z portalem SmartB2B.</description> <description>Synchronizuje enova365 z portalem SmartB2B.</description>
<executable>%BASE%\..\SmartB2B.Enova.Service.exe</executable> <executable>%BASE%\..\SmartB2B.Enova.Service.exe</executable>
<workingdirectory>%BASE%\..</workingdirectory> <workingdirectory>%BASE%\..</workingdirectory>
<!-- Odkomentuj, aby włączyć logowanie etapów Enovy i diagnostykę Sentry. -->
<!-- <arguments>/verbose</arguments> -->
<stoptimeout>30sec</stoptimeout> <stoptimeout>30sec</stoptimeout>
<log mode="roll-by-time"> <log mode="roll-by-time">
<pattern>yyyyMMdd</pattern> <pattern>yyyyMMdd</pattern>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -4,6 +4,8 @@
<description>Synchronizuje enova365 z portalem SmartB2B.</description> <description>Synchronizuje enova365 z portalem SmartB2B.</description>
<executable>%BASE%\..\SmartB2B.Enova.Service.exe</executable> <executable>%BASE%\..\SmartB2B.Enova.Service.exe</executable>
<workingdirectory>%BASE%\..</workingdirectory> <workingdirectory>%BASE%\..</workingdirectory>
<!-- Odkomentuj, aby włączyć logowanie etapów Enovy i diagnostykę Sentry. -->
<!-- <arguments>/verbose</arguments> -->
<stoptimeout>30sec</stoptimeout> <stoptimeout>30sec</stoptimeout>
<log mode="roll-by-time"> <log mode="roll-by-time">
<pattern>yyyyMMdd</pattern> <pattern>yyyyMMdd</pattern>

View File

@@ -53,9 +53,14 @@ public sealed class EnovaOrderAdapter : IEnovaOrderAdapter
try try
{ {
Verbose(configuration.VerboseLogging, "Enova: przygotowanie stanu sesji.");
var sessionState = SessionState.Create(); var sessionState = SessionState.Create();
using var attachedSessionState = sessionState.Attach(); using var attachedSessionState = sessionState.Attach();
Verbose(
configuration.VerboseLogging,
$"Enova: logowanie operatora '{configuration.Connection.OperatorName}' do bazy " +
$"'{configuration.Connection.DatabaseName}'.");
using var login = database.Login( using var login = database.Login(
winAuth: false, winAuth: false,
user: configuration.Connection.OperatorName, user: configuration.Connection.OperatorName,
@@ -64,8 +69,9 @@ public sealed class EnovaOrderAdapter : IEnovaOrderAdapter
readOnly: false, readOnly: false,
config: false, config: false,
name: "SmartB2B Enova Sync"); name: "SmartB2B Enova Sync");
Verbose(configuration.VerboseLogging, "Enova: utworzono sesję zapisu.");
return CreateOrderInSession(session, request); return CreateOrderInSession(session, request, configuration.VerboseLogging);
} }
catch (EnovaOperationException) catch (EnovaOperationException)
{ {
@@ -81,8 +87,12 @@ public sealed class EnovaOrderAdapter : IEnovaOrderAdapter
} }
} }
private static OrderResult CreateOrderInSession(Session session, OrderRequest request) private static OrderResult CreateOrderInSession(
Session session,
OrderRequest request,
bool verboseLogging)
{ {
Verbose(verboseLogging, "Enova: pobieranie modułów Handel, CRM, Towary i Magazyny.");
var handel = HandelModule.GetInstance(session); var handel = HandelModule.GetInstance(session);
var crm = CRMModule.GetInstance(session); var crm = CRMModule.GetInstance(session);
var towary = TowaryModule.GetInstance(session); var towary = TowaryModule.GetInstance(session);
@@ -93,11 +103,13 @@ public sealed class EnovaOrderAdapter : IEnovaOrderAdapter
"eu.smartb2b.erp.document_definition_not_found", "eu.smartb2b.erp.document_definition_not_found",
$"Nie znaleziono definicji dokumentu '{request.DocumentDefinition}'.", $"Nie znaleziono definicji dokumentu '{request.DocumentDefinition}'.",
new Dictionary<string, object?> { ["documentDefinition"] = request.DocumentDefinition }); new Dictionary<string, object?> { ["documentDefinition"] = request.DocumentDefinition });
Verbose(verboseLogging, $"Enova: znaleziono definicję dokumentu '{definition.Symbol}'.");
var customer = crm.Kontrahenci.WgKodu[request.CustomerCode] var customer = crm.Kontrahenci.WgKodu[request.CustomerCode]
?? throw new EnovaOperationException( ?? throw new EnovaOperationException(
"eu.smartb2b.company_not_found", "eu.smartb2b.company_not_found",
$"Nie znaleziono kontrahenta o kodzie '{request.CustomerCode}'.", $"Nie znaleziono kontrahenta o kodzie '{request.CustomerCode}'.",
new Dictionary<string, object?> { ["companyErpId"] = request.CustomerCode }); new Dictionary<string, object?> { ["companyErpId"] = request.CustomerCode });
Verbose(verboseLogging, $"Enova: znaleziono kontrahenta '{customer.Kod}'.");
var warehouse = string.IsNullOrWhiteSpace(request.WarehouseCode) var warehouse = string.IsNullOrWhiteSpace(request.WarehouseCode)
? warehouses.StandardowyMagazyn ? warehouses.StandardowyMagazyn
: warehouses.GetGrantedView() : warehouses.GetGrantedView()
@@ -116,15 +128,18 @@ public sealed class EnovaOrderAdapter : IEnovaOrderAdapter
: $"Nie znaleziono magazynu o symbolu '{request.WarehouseCode.Trim()}'.", : $"Nie znaleziono magazynu o symbolu '{request.WarehouseCode.Trim()}'.",
new Dictionary<string, object?> { ["warehouseErpId"] = request.WarehouseCode }); new Dictionary<string, object?> { ["warehouseErpId"] = request.WarehouseCode });
} }
Verbose(verboseLogging, $"Enova: wybrano magazyn '{warehouse.Symbol}'.");
DokumentHandlowy document; DokumentHandlowy document;
Verbose(verboseLogging, "Enova: rozpoczęcie transakcji edycyjnej.");
using (var transaction = session.Logout(editMode: true)) using (var transaction = session.Logout(editMode: true))
{ {
document = session.AddRow(new DokumentHandlowy()); document = session.AddRow(new DokumentHandlowy());
document.Definicja = definition; document.Definicja = definition;
document.Kontrahent = customer; document.Kontrahent = customer;
document.Magazyn = warehouse; document.Magazyn = warehouse;
Verbose(verboseLogging, "Enova: dodano nagłówek dokumentu.");
if (!string.IsNullOrWhiteSpace(request.CustomerReference)) if (!string.IsNullOrWhiteSpace(request.CustomerReference))
{ {
@@ -137,8 +152,12 @@ public sealed class EnovaOrderAdapter : IEnovaOrderAdapter
document.Opis.Add(request.Notes.Trim()); document.Opis.Add(request.Notes.Trim());
} }
foreach (var line in request.Items) for (var index = 0; index < request.Items.Count; index++)
{ {
var line = request.Items[index];
Verbose(
verboseLogging,
$"Enova: pozycja {index + 1}/{request.Items.Count}, wyszukiwanie towaru '{line.ProductCode}'.");
var product = towary.Towary.WgKodu[line.ProductCode] var product = towary.Towary.WgKodu[line.ProductCode]
?? throw new EnovaOperationException( ?? throw new EnovaOperationException(
"eu.smartb2b.product_not_found", "eu.smartb2b.product_not_found",
@@ -150,6 +169,7 @@ public sealed class EnovaOrderAdapter : IEnovaOrderAdapter
position.Ilosc = new Quantity((double)line.Quantity); position.Ilosc = new Quantity((double)line.Quantity);
position.Cena = new DoubleCy(line.UnitPrice, CurrencySymbol); position.Cena = new DoubleCy(line.UnitPrice, CurrencySymbol);
position.UstawRabat(new Percent(line.Discount / 100m), ręcznie: true); position.UstawRabat(new Percent(line.Discount / 100m), ręcznie: true);
Verbose(verboseLogging, $"Enova: dodano pozycję {index + 1}.");
} }
document.Stan = request.SaveMode switch document.Stan = request.SaveMode switch
@@ -160,11 +180,17 @@ public sealed class EnovaOrderAdapter : IEnovaOrderAdapter
"eu.smartb2b.erp.invalid_order", "eu.smartb2b.erp.invalid_order",
$"Nieobsługiwany tryb zapisu: {request.SaveMode}.") $"Nieobsługiwany tryb zapisu: {request.SaveMode}.")
}; };
Verbose(verboseLogging, $"Enova: ustawiono stan dokumentu na {request.SaveMode}.");
transaction.Commit(); transaction.Commit();
Verbose(verboseLogging, "Enova: zatwierdzono transakcję edycyjną.");
} }
Verbose(verboseLogging, "Enova: wykonywanie session.Save().");
session.Save(); session.Save();
Verbose(
verboseLogging,
$"Enova: session.Save() zakończone; numer={document.Numer.Pelny}, ID={document.ID}.");
return new OrderResult( return new OrderResult(
document.ID, document.ID,
@@ -271,4 +297,12 @@ public sealed class EnovaOrderAdapter : IEnovaOrderAdapter
private static string GetAssemblyVersion(Assembly assembly) => private static string GetAssemblyVersion(Assembly assembly) =>
assembly.GetName().Version?.ToString() ?? "unknown"; assembly.GetName().Version?.ToString() ?? "unknown";
private static void Verbose(bool enabled, string message)
{
if (enabled)
{
Console.WriteLine($"[verbose] {message}");
}
}
} }

View File

@@ -16,7 +16,8 @@ public sealed record EnovaAdapterConfiguration(
EnovaConnectionOptions Connection, EnovaConnectionOptions Connection,
string DocumentDefinition, string DocumentDefinition,
string? DefaultWarehouseCode, string? DefaultWarehouseCode,
OrderSaveMode SaveMode); OrderSaveMode SaveMode,
bool VerboseLogging = false);
public sealed class OrderRequest public sealed class OrderRequest
{ {

View File

@@ -48,7 +48,7 @@ public sealed class ServiceSettings
return settings; return settings;
} }
public RuntimeSettings ResolveRuntimeSettings() public RuntimeSettings ResolveRuntimeSettings(bool verboseLogging = false)
{ {
var installationPath = Path.GetFullPath( var installationPath = Path.GetFullPath(
Environment.ExpandEnvironmentVariables(Enova.InstallationPath)); Environment.ExpandEnvironmentVariables(Enova.InstallationPath));
@@ -64,7 +64,8 @@ public sealed class ServiceSettings
Sql.ConnectionString), Sql.ConnectionString),
Enova.DocumentDefinition, Enova.DocumentDefinition,
Enova.DefaultWarehouseCode, Enova.DefaultWarehouseCode,
Enova.SaveMode); Enova.SaveMode,
verboseLogging);
} }
return new RuntimeSettings( return new RuntimeSettings(

View File

@@ -0,0 +1,28 @@
namespace SmartB2B.Enova.Service.Diagnostics;
internal static class ConsoleLogger
{
public static bool VerboseEnabled { get; private set; }
public static void Configure(bool verboseEnabled)
{
VerboseEnabled = verboseEnabled;
}
public static void Verbose(string message)
{
if (VerboseEnabled)
{
Console.WriteLine($"[verbose] {message}");
}
}
public static void Error(string message, Exception? exception = null)
{
Console.Error.WriteLine(message);
if (VerboseEnabled && exception is not null)
{
Console.Error.WriteLine(exception);
}
}
}

View File

@@ -0,0 +1,28 @@
using Sentry;
namespace SmartB2B.Enova.Service.Diagnostics;
internal static class SentryReporter
{
private static readonly TimeSpan FlushTimeout = TimeSpan.FromSeconds(5);
public static async Task CaptureAsync(Exception exception, Action<Scope>? configureScope = null)
{
try
{
var eventId = configureScope is null
? SentrySdk.CaptureException(exception)
: SentrySdk.CaptureException(exception, configureScope);
ConsoleLogger.Verbose($"Sentry: zakolejkowano zdarzenie {eventId}.");
await SentrySdk.FlushAsync(FlushTimeout).ConfigureAwait(false);
ConsoleLogger.Verbose("Sentry: zakończono oczekiwanie na opróżnienie kolejki.");
}
catch (Exception sentryException)
{
ConsoleLogger.Error(
$"Błąd raportowania do Sentry: {sentryException.Message}",
sentryException);
}
}
}

View File

@@ -1,6 +1,6 @@
using Newtonsoft.Json; using Newtonsoft.Json;
using Sentry;
using SmartB2B.Enova.Contracts; using SmartB2B.Enova.Contracts;
using SmartB2B.Enova.Service.Diagnostics;
using SmartB2B.Enova.Service.Rpc; using SmartB2B.Enova.Service.Rpc;
namespace SmartB2B.Enova.Service.Orders; namespace SmartB2B.Enova.Service.Orders;
@@ -60,6 +60,10 @@ public sealed class PlaceOrderHandler
new Dictionary<string, object?> { ["errors"] = errors }); new Dictionary<string, object?> { ["errors"] = errors });
} }
ConsoleLogger.Verbose(
$"ZO: żądanie zweryfikowane; definicja={request.DocumentDefinition}, " +
$"magazyn={request.WarehouseCode ?? "(standardowy)"}, tryb={request.SaveMode}.");
ConsoleLogger.Verbose("ZO: oczekiwanie na wyłączną sekcję operacji Enovy.");
await _orderLock.WaitAsync(cancellationToken).ConfigureAwait(false); await _orderLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try try
{ {
@@ -69,20 +73,28 @@ public sealed class PlaceOrderHandler
OrderResult result; OrderResult result;
try try
{ {
ConsoleLogger.Verbose("ZO: przekazanie żądania do adaptera Enovy.");
result = await Task.Run(() => _adapter.CreateOrder(request), cancellationToken) result = await Task.Run(() => _adapter.CreateOrder(request), cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
} }
catch (EnovaOperationException exception) catch (EnovaOperationException exception)
{ {
SentrySdk.CaptureException(exception, scope => ConsoleLogger.Error(
{ $"Błąd tworzenia ZO [{exception.ErrorUri}]: {exception.Message}",
scope.SetTag("rpc.procedure", "eu.smartb2b.place_order"); exception);
scope.SetTag("wamp.error_uri", exception.ErrorUri); await SentryReporter.CaptureAsync(exception, scope =>
}); {
scope.SetTag("rpc.procedure", "eu.smartb2b.place_order");
scope.SetTag("wamp.error_uri", exception.ErrorUri);
})
.ConfigureAwait(false);
throw WampErrorFactory.FromEnova(exception); throw WampErrorFactory.FromEnova(exception);
} }
Console.WriteLine($"Utworzono ZO {result.Number} (ID: {result.Id})."); Console.WriteLine($"Utworzono ZO {result.Number} (ID: {result.Id}).");
ConsoleLogger.Verbose(
$"ZO: zapis zakończony; stan={result.SaveMode}, magazyn={result.WarehouseCode}, " +
$"netto={result.Net}, VAT={result.Vat}, brutto={result.Gross}.");
return new KeywordResult(new Dictionary<string, object?> return new KeywordResult(new Dictionary<string, object?>
{ {
["order_erp_id"] = result.Id, ["order_erp_id"] = result.Id,
@@ -95,6 +107,7 @@ public sealed class PlaceOrderHandler
finally finally
{ {
_orderLock.Release(); _orderLock.Release();
ConsoleLogger.Verbose("ZO: zwolniono wyłączną sekcję operacji Enovy.");
} }
} }
} }

View File

@@ -6,6 +6,7 @@ using SmartB2B.Enova.Service.Rpc;
using SmartB2B.Enova.Service.Runtime; using SmartB2B.Enova.Service.Runtime;
using SmartB2B.Enova.Service.Sql; using SmartB2B.Enova.Service.Sql;
using Sentry; using Sentry;
using Sentry.Infrastructure;
using WampSharp.V2.Rpc; using WampSharp.V2.Rpc;
namespace SmartB2B.Enova.Service; namespace SmartB2B.Enova.Service;
@@ -26,9 +27,13 @@ internal static class Program
try try
{ {
var commandLine = CommandLineOptions.Parse(args); var commandLine = CommandLineOptions.Parse(args);
ConsoleLogger.Configure(commandLine.VerboseLogging);
ConsoleLogger.Verbose(
$"Włączono szczegółowe logowanie; konfiguracja: '{commandLine.ConfigurationPath}'.");
var settings = ServiceSettings.Load(commandLine.ConfigurationPath); var settings = ServiceSettings.Load(commandLine.ConfigurationPath);
sentry = InitializeSentry(settings); ConsoleLogger.Verbose("Wczytano i zweryfikowano konfigurację usługi.");
var runtime = settings.ResolveRuntimeSettings(); sentry = InitializeSentry(settings, commandLine.ConfigurationPath, commandLine.VerboseLogging);
var runtime = settings.ResolveRuntimeSettings(commandLine.VerboseLogging);
using var adapterLoader = new EnovaAdapterLoader(runtime.EnovaInstallationPath); using var adapterLoader = new EnovaAdapterLoader(runtime.EnovaInstallationPath);
var adapter = adapterLoader.Load(); var adapter = adapterLoader.Load();
@@ -101,8 +106,7 @@ internal static class Program
catch (Exception exception) catch (Exception exception)
{ {
Console.Error.WriteLine($"Błąd krytyczny: {exception}"); Console.Error.WriteLine($"Błąd krytyczny: {exception}");
SentrySdk.CaptureException(exception); await SentryReporter.CaptureAsync(exception).ConfigureAwait(false);
await SentrySdk.FlushAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
return 1; return 1;
} }
finally finally
@@ -111,11 +115,15 @@ internal static class Program
} }
} }
private static IDisposable? InitializeSentry(ServiceSettings settings) private static IDisposable? InitializeSentry(
ServiceSettings settings,
string configurationPath,
bool verboseLogging)
{ {
if (string.IsNullOrWhiteSpace(settings.Sentry.Dsn)) if (string.IsNullOrWhiteSpace(settings.Sentry.Dsn))
{ {
Console.WriteLine("Sentry wyłączone (brak parametru sentry.dsn)."); Console.WriteLine(
$"Sentry wyłączone: brak parametru sentry.dsn w używanym pliku '{configurationPath}'.");
return null; return null;
} }
@@ -124,20 +132,41 @@ internal static class Program
options.Dsn = settings.Sentry.Dsn; options.Dsn = settings.Sentry.Dsn;
options.IsGlobalModeEnabled = true; options.IsGlobalModeEnabled = true;
options.Release = typeof(Program).Assembly.GetName().Version?.ToString(); options.Release = typeof(Program).Assembly.GetName().Version?.ToString();
// Diagnostyka na poziomie Error pozostaje aktywna także bez /verbose,
// aby problemy transportu Sentry nie znikały bez śladu w usłudze Windows.
options.DiagnosticLevel = verboseLogging ? SentryLevel.Debug : SentryLevel.Error;
options.DiagnosticLogger = new ConsoleDiagnosticLogger(options.DiagnosticLevel);
options.ShutdownTimeout = TimeSpan.FromSeconds(5);
}); });
SentrySdk.ConfigureScope(scope => scope.SetTag("portal", settings.Portal)); SentrySdk.ConfigureScope(scope => scope.SetTag("portal", settings.Portal));
Console.WriteLine("Raportowanie błędów do Sentry włączone."); Console.WriteLine($"Raportowanie błędów do Sentry włączone; cel: {DescribeSentryTarget(settings.Sentry.Dsn)}.");
return sentry; return sentry;
} }
private static string DescribeSentryTarget(string dsn)
{
if (!Uri.TryCreate(dsn, UriKind.Absolute, out var uri))
{
return "niepoprawny DSN";
}
var port = uri.IsDefaultPort ? string.Empty : $":{uri.Port}";
var project = uri.AbsolutePath.Trim('/').Split('/').LastOrDefault() ?? "?";
return $"{uri.Scheme}://{uri.Host}{port}, projekt {project}";
}
} }
internal sealed record CommandLineOptions(string ConfigurationPath, bool CheckConfigurationOnly) public sealed record CommandLineOptions(
string ConfigurationPath,
bool CheckConfigurationOnly,
bool VerboseLogging)
{ {
public static CommandLineOptions Parse(string[] args) public static CommandLineOptions Parse(string[] args)
{ {
var path = Path.Combine(AppContext.BaseDirectory, "config", "enova.json"); var path = Path.Combine(AppContext.BaseDirectory, "config", "enova.json");
var checkOnly = false; var checkOnly = false;
var verbose = false;
for (var index = 0; index < args.Length; index++) for (var index = 0; index < args.Length; index++)
{ {
@@ -154,11 +183,23 @@ internal sealed record CommandLineOptions(string ConfigurationPath, bool CheckCo
case "--check-config": case "--check-config":
checkOnly = true; checkOnly = true;
break; break;
case "/debug":
case "/verbose":
case "--debug":
case "--verbose":
verbose = true;
break;
case "/nodebug":
case "/noverbose":
case "--no-debug":
case "--no-verbose":
verbose = false;
break;
default: default:
throw new ConfigurationException($"Nieznany argument: {args[index]}"); throw new ConfigurationException($"Nieznany argument: {args[index]}");
} }
} }
return new CommandLineOptions(path, checkOnly); return new CommandLineOptions(path, checkOnly, verbose);
} }
} }

View File

@@ -1,7 +1,7 @@
using WampSharp.Core.Serialization; using WampSharp.Core.Serialization;
using WampSharp.V2.Core.Contracts; using WampSharp.V2.Core.Contracts;
using WampSharp.V2.Rpc; using WampSharp.V2.Rpc;
using Sentry; using SmartB2B.Enova.Service.Diagnostics;
namespace SmartB2B.Enova.Service.Rpc; namespace SmartB2B.Enova.Service.Rpc;
@@ -87,6 +87,12 @@ public sealed class DelegateRpcOperation : IWampRpcOperation
} }
catch (WampException exception) catch (WampException exception)
{ {
var message = exception.ArgumentsKeywords.TryGetValue("message", out var value)
? Convert.ToString(value)
: exception.Message;
ConsoleLogger.Error(
$"Błąd procedury {Procedure} [{exception.ErrorUri}]: {message}",
exception);
var details = formatter.Serialize(exception.Details); var details = formatter.Serialize(exception.Details);
var errorArguments = exception.Arguments.Select(formatter.Serialize).ToArray(); var errorArguments = exception.Arguments.Select(formatter.Serialize).ToArray();
var keywordArguments = formatter.Serialize(exception.ArgumentsKeywords); var keywordArguments = formatter.Serialize(exception.ArgumentsKeywords);
@@ -94,15 +100,19 @@ public sealed class DelegateRpcOperation : IWampRpcOperation
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
ConsoleLogger.Error($"Anulowano wykonanie procedury {Procedure}.");
var details = formatter.Serialize(new Dictionary<string, object>()); var details = formatter.Serialize(new Dictionary<string, object>());
caller.Error(formatter, details, "wamp.error.canceled"); caller.Error(formatter, details, "wamp.error.canceled");
} }
catch (Exception exception) catch (Exception exception)
{ {
Console.Error.WriteLine($"Nieobsłużony błąd procedury {Procedure}: {exception}"); ConsoleLogger.Error(
SentrySdk.CaptureException( $"Nieobsłużony błąd procedury {Procedure}: {exception.Message}",
exception, exception);
scope => scope.SetTag("rpc.procedure", Procedure)); await SentryReporter.CaptureAsync(
exception,
scope => scope.SetTag("rpc.procedure", Procedure))
.ConfigureAwait(false);
var details = formatter.Serialize(new Dictionary<string, object>()); var details = formatter.Serialize(new Dictionary<string, object>());
var keywordArguments = formatter.Serialize(new Dictionary<string, object?> var keywordArguments = formatter.Serialize(new Dictionary<string, object?>
{ {

View File

@@ -2,6 +2,7 @@ using WampSharp.V2;
using WampSharp.V2.Client; using WampSharp.V2.Client;
using WampSharp.V2.Core.Contracts; using WampSharp.V2.Core.Contracts;
using WampSharp.V2.Rpc; using WampSharp.V2.Rpc;
using SmartB2B.Enova.Service.Diagnostics;
namespace SmartB2B.Enova.Service.Rpc; namespace SmartB2B.Enova.Service.Rpc;
@@ -45,6 +46,7 @@ public sealed class WampServiceClient
foreach (var operation in _operations) foreach (var operation in _operations)
{ {
ConsoleLogger.Verbose($"WAMP: rejestracja procedury {operation.Procedure}.");
var registration = await channel.RealmProxy.RpcCatalog.Register( var registration = await channel.RealmProxy.RpcCatalog.Register(
operation, operation,
new RegisterOptions { Invoke = "last" }) new RegisterOptions { Invoke = "last" })
@@ -56,7 +58,7 @@ public sealed class WampServiceClient
Console.WriteLine( Console.WriteLine(
$"Połączono z WAMP; zarejestrowano {_operations.Count} procedur."); $"Połączono z WAMP; zarejestrowano {_operations.Count} procedur.");
await disconnected.Task.WaitAsync(cancellationToken).ConfigureAwait(false); await disconnected.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
Console.Error.WriteLine("Połączenie WAMP zostało przerwane."); ConsoleLogger.Error("Połączenie WAMP zostało przerwane.");
} }
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{ {
@@ -64,7 +66,7 @@ public sealed class WampServiceClient
} }
catch (Exception exception) catch (Exception exception)
{ {
Console.Error.WriteLine($"Błąd połączenia WAMP: {exception.Message}"); ConsoleLogger.Error($"Błąd połączenia WAMP: {exception.Message}", exception);
} }
finally finally
{ {

View File

@@ -1,7 +1,7 @@
using System.Data.Common; using System.Data.Common;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
using Newtonsoft.Json; using Newtonsoft.Json;
using Sentry; using SmartB2B.Enova.Service.Diagnostics;
using SmartB2B.Enova.Service.Rpc; using SmartB2B.Enova.Service.Rpc;
namespace SmartB2B.Enova.Service.Sql; namespace SmartB2B.Enova.Service.Sql;
@@ -97,12 +97,13 @@ public sealed class SqlRawHandler
{ {
var code = exception.GetType().GetProperty("Number")?.GetValue(exception) var code = exception.GetType().GetProperty("Number")?.GetValue(exception)
?? exception.ErrorCode; ?? exception.ErrorCode;
Console.Error.WriteLine($"Błąd SQL {code}: {exception.Message}"); ConsoleLogger.Error($"Błąd SQL {code}: {exception.Message}", exception);
SentrySdk.CaptureException(exception, scope => await SentryReporter.CaptureAsync(exception, scope =>
{ {
scope.SetTag("rpc.procedure", "eu.smartb2b.sql_raw"); scope.SetTag("rpc.procedure", "eu.smartb2b.sql_raw");
scope.SetExtra("sql.error_code", code); scope.SetExtra("sql.error_code", code);
}); })
.ConfigureAwait(false);
throw WampErrorFactory.Create( throw WampErrorFactory.Create(
"eu.smartb2b.sql_error", "eu.smartb2b.sql_error",
exception.Message, exception.Message,

View File

@@ -1,6 +1,7 @@
using System.Data.Common; using System.Data.Common;
using System.Reflection; using System.Reflection;
using SmartB2B.Enova.Contracts; using SmartB2B.Enova.Contracts;
using SmartB2B.Enova.Service;
using SmartB2B.Enova.Service.Configuration; using SmartB2B.Enova.Service.Configuration;
using SmartB2B.Enova.Service.Orders; using SmartB2B.Enova.Service.Orders;
using SmartB2B.Enova.Service.Rpc; using SmartB2B.Enova.Service.Rpc;
@@ -27,10 +28,14 @@ var tests = new (string Name, Func<Task> Run)[]
("Warstwa WAMP wysyła wynik jako kwargs", WampKeywordWireResult), ("Warstwa WAMP wysyła wynik jako kwargs", WampKeywordWireResult),
("Brak waluty zwraca stabilny błąd", MissingCurrency), ("Brak waluty zwraca stabilny błąd", MissingCurrency),
("Waluta inna niż PLN jest odrzucana", UnsupportedCurrency), ("Waluta inna niż PLN jest odrzucana", UnsupportedCurrency),
("Błąd adaptera jest wypisywany na stderr", AdapterErrorIsWrittenToStderr),
("Brak katalogu Enovy jest wykrywany", MissingEnovaDirectory), ("Brak katalogu Enovy jest wykrywany", MissingEnovaDirectory),
("Sekrety są odczytywane z pliku konfiguracji", SecretsComeFromConfigurationFile), ("Sekrety są odczytywane z pliku konfiguracji", SecretsComeFromConfigurationFile),
("Brak danych operatora wyłącza place_order", MissingCredentialsDisablePlaceOrder), ("Brak danych operatora wyłącza place_order", MissingCredentialsDisablePlaceOrder),
("Częściowe dane operatora wyłączają place_order", PartialCredentialsDisablePlaceOrder), ("Częściowe dane operatora wyłączają place_order", PartialCredentialsDisablePlaceOrder),
("Parametr /debug włącza szczegółowe logowanie", DebugSwitchEnablesVerboseLogging),
("Parametr /verbose można wyłączyć", VerboseSwitchCanBeDisabled),
("Tryb szczegółowy jest przekazywany do adaptera", VerboseModeReachesAdapter),
("Provider SQL działa na bieżącej platformie", SqlProviderIsSupported) ("Provider SQL działa na bieżącej platformie", SqlProviderIsSupported)
}; };
@@ -195,6 +200,29 @@ static async Task UnsupportedCurrency()
"eu.smartb2b.erp.currency_not_supported"); "eu.smartb2b.erp.currency_not_supported");
} }
static async Task AdapterErrorIsWrittenToStderr()
{
var previousError = Console.Error;
using var error = new StringWriter();
try
{
Console.SetError(error);
var handler = new PlaceOrderHandler(new FailingAdapter(), CreateConfiguration());
await AssertWampError(
handler.HandleAsync(CreateInvocation(), CancellationToken.None),
"eu.smartb2b.company_not_found");
}
finally
{
Console.SetError(previousError);
}
if (!error.ToString().Contains("Nie znaleziono kontrahenta", StringComparison.Ordinal))
{
throw new InvalidOperationException($"Brak komunikatu błędu na stderr: {error}");
}
}
static Task MissingEnovaDirectory() static Task MissingEnovaDirectory()
{ {
var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
@@ -281,7 +309,30 @@ static Task PartialCredentialsDisablePlaceOrder()
return Task.CompletedTask; return Task.CompletedTask;
} }
static RuntimeSettings LoadRuntimeSettings(string credentialsJson) static Task DebugSwitchEnablesVerboseLogging()
{
var options = CommandLineOptions.Parse(["/debug"]);
AssertEqual(true, options.VerboseLogging, "tryb szczegółowy");
return Task.CompletedTask;
}
static Task VerboseSwitchCanBeDisabled()
{
var options = CommandLineOptions.Parse(["/verbose", "/noverbose"]);
AssertEqual(false, options.VerboseLogging, "wyłączony tryb szczegółowy");
return Task.CompletedTask;
}
static Task VerboseModeReachesAdapter()
{
var runtime = LoadRuntimeSettings(
"\"operator\": \"Administrator\", \"password\": \"haslo\",",
verboseLogging: true);
AssertEqual(true, runtime.EnovaConfiguration?.VerboseLogging, "tryb adaptera");
return Task.CompletedTask;
}
static RuntimeSettings LoadRuntimeSettings(string credentialsJson, bool verboseLogging = false)
{ {
var path = Path.Combine(Path.GetTempPath(), $"enova-settings-{Guid.NewGuid():N}.json"); var path = Path.Combine(Path.GetTempPath(), $"enova-settings-{Guid.NewGuid():N}.json");
try try
@@ -300,7 +351,7 @@ static RuntimeSettings LoadRuntimeSettings(string credentialsJson)
} }
"""); """);
return ServiceSettings.Load(path).ResolveRuntimeSettings(); return ServiceSettings.Load(path).ResolveRuntimeSettings(verboseLogging);
} }
finally finally
{ {
@@ -423,6 +474,17 @@ file sealed class FakeAdapter : IEnovaOrderAdapter
} }
} }
file sealed class FailingAdapter : IEnovaOrderAdapter
{
public EnovaAdapterInfo Initialize(EnovaAdapterConfiguration configuration) =>
new("1.0.0", "2604.4.4.0", "2604.4.4.0", configuration.Connection.DatabaseName);
public OrderResult CreateOrder(OrderRequest request) =>
throw new EnovaOperationException(
"eu.smartb2b.company_not_found",
$"Nie znaleziono kontrahenta o kodzie '{request.CustomerCode}'.");
}
file sealed class FakeRouterCallback : IWampRawRpcOperationRouterCallback file sealed class FakeRouterCallback : IWampRawRpcOperationRouterCallback
{ {
public long RequestId => 1; public long RequestId => 1;