diff --git a/common.props b/common.props index e7a9f994b3..d4d950fa63 100644 --- a/common.props +++ b/common.props @@ -42,8 +42,8 @@ - - + + diff --git a/deploy/docker/docker-compose.yaml b/deploy/docker/docker-compose.yaml index df131936c7..ecfc4024be 100644 --- a/deploy/docker/docker-compose.yaml +++ b/deploy/docker/docker-compose.yaml @@ -5,20 +5,20 @@ services: ############################ opcplc: container_name: opcplc - image: mcr.microsoft.com/iotedge/opc-plc:${OPC_PLC_TAG:-2.9.10} + image: mcr.microsoft.com/iotedge/opc-plc:${OPC_PLC_TAG:-latest} ports: - "50000:50000" command: [ - "--sph", + "--sph=True", "--spf=/shared/pn.json", "--pn=50000", - "--alm", - "--ses", + "--alm=True", + "--ses=True", "--ei=${EVENT_NODES:-100}", "--gn=${GUID_NODES:-100}", "--fn=${FAST_NODES:-99900}", "--sn=${SLOW_NODES:-99900}", - "--aa" + "--aa=True" ] volumes: - shared:/shared:rw @@ -41,6 +41,7 @@ services: "--cl=5", "--rs", "--dm=True", + "--lfm=syslog", "--pki=/shared/pki", "--pf=/shared/pn.json", "--npd=${NODES_PER_DATASET:-10000}" diff --git a/deploy/docker/with-monitor.yaml b/deploy/docker/with-monitor.yaml index d7d7c71854..51490bbc54 100644 --- a/deploy/docker/with-monitor.yaml +++ b/deploy/docker/with-monitor.yaml @@ -13,7 +13,7 @@ services: environment: DOTNET_DiagnosticPorts: /shared/diag/dotnet-monitor.sock ############################ - # Optional dotnet-monitor + # dotnet-monitor ############################ monitor: container_name: monitor diff --git a/deploy/docker/with-pcap-capture.yaml b/deploy/docker/with-pcap-capture.yaml new file mode 100644 index 0000000000..3f4f975905 --- /dev/null +++ b/deploy/docker/with-pcap-capture.yaml @@ -0,0 +1,34 @@ +version: "3.9" +services: + ############################ + # OPC PLC Simulation + ############################ + opcplc: + command: [ + "--sph=True", + "--spf=/shared/pn.json", + "--pn=50000", + "--fn=8000", + "--aa=True", + "--ut=True" + ] + ############################ + # Network capture + ############################ + pcap: + container_name: pcap + image: travelping/pcap + cap_add: + - NET_ADMIN + network_mode: host + volumes: + - shared:/data:rw + environment: + IFACE: any + FORMAT: pcapng + MAXFILENUM: 10 + MAXFILESIZE: 200 + FILENAME: dump.pcap + FILTER: "src or dst host 192.168.80.2" +volumes: + shared: diff --git a/docs/opc-publisher/commandline.md b/docs/opc-publisher/commandline.md index cc1f6ceb0d..b09adfbad3 100644 --- a/docs/opc-publisher/commandline.md +++ b/docs/opc-publisher/commandline.md @@ -18,8 +18,8 @@ When both environment variable and CLI argument are provided, the command line o ██║ ██║██╔═══╝ ██║ ██╔═══╝ ██║ ██║██╔══██╗██║ ██║╚════██║██╔══██║██╔══╝ ██╔══██╗ ╚██████╔╝██║ ╚██████╗ ██║ ╚██████╔╝██████╔╝███████╗██║███████║██║ ██║███████╗██║ ██║ ╚═════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝╚══════╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ + 2.9.4 (.NET 8.0.1/win-x64/OPC Stack 1.4.372.116) - 2.9.4 (.NET 8.0.0/win-x64/OPC Stack 1.4.372.106) General ------- @@ -806,6 +806,14 @@ Diagnostic options `Critical` `None` Default: `Information`. + --lfm, --logformat, --LogFormat=VALUE + The logging format to use when writing to the + console. + Allowed values: + `simple` + `syslog` + `systemd` + Default: `simple`. --di, --diagnosticsinterval, --DiagnosticsInterval=VALUE Produce publisher diagnostic information at this specified interval in seconds. @@ -856,15 +864,6 @@ Diagnostic options metrics directly on the standard path. Default: `disabled` if Otlp collector is configured, otherwise `enabled`. - --cap, --capturedevice, --CaptureDevice=VALUE - The capture device to use to capture network - traffic. - Network capture is not supported on this system. - --cpf, --capturefile, --CaptureFileName=VALUE - The file name to capture traffic to. - A device must be selected using `--cd` if - capture capability is supported on this system. - Default: `opcua.pcap`. ``` Currently supported combinations of `--mm` snd `--me` can be found [here](./messageformats.md). diff --git a/docs/release-announcement.md b/docs/release-announcement.md index d6c2fb7635..7452b90088 100644 --- a/docs/release-announcement.md +++ b/docs/release-announcement.md @@ -65,6 +65,7 @@ We are pleased to announce the release of version 2.9.4 of OPC Publisher and the - Recreate session if it expires on server (#2138) - Log subscription keep alive error only when session is connected (#2137) - Update OPC UA .net stack to latest version (1.4.372.116-preview) to enable fully async reconnect and fix several issues in previous versions. +- Added the ability to switch publisher to emit logs in syslog or systemd format using --lfm command line option. - Fix issue where certain publish errors cause reconnect state machine to fail (#2104, #2136) ## Azure Industrial IoT OPC Publisher 2.9.3 diff --git a/src/Azure.IIoT.OpcUa.Publisher.Models/tests/Azure.IIoT.OpcUa.Publisher.Models.Tests.csproj b/src/Azure.IIoT.OpcUa.Publisher.Models/tests/Azure.IIoT.OpcUa.Publisher.Models.Tests.csproj index 5d2bceba34..1400924e0e 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Models/tests/Azure.IIoT.OpcUa.Publisher.Models.Tests.csproj +++ b/src/Azure.IIoT.OpcUa.Publisher.Models/tests/Azure.IIoT.OpcUa.Publisher.Models.Tests.csproj @@ -10,7 +10,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/cli/Program.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/cli/Program.cs index 60f13872d3..22d33ce1b7 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Module/cli/Program.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Module/cli/Program.cs @@ -381,7 +381,7 @@ private static async Task LoadPnJson(string publishProfile, } if (publishedNodesFile != null && File.Exists(publishedNodesFile)) { - var publishedNodesFilePath = Path.GetTempFileName(); + const string publishedNodesFilePath = "profile.json"; await File.WriteAllTextAsync(publishedNodesFilePath, (await File.ReadAllTextAsync(publishedNodesFile, ct).ConfigureAwait(false)) diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/src/Runtime/CommandLine.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/src/Runtime/CommandLine.cs index 40bbdb9f8d..015ff002b9 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Module/src/Runtime/CommandLine.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Module/src/Runtime/CommandLine.cs @@ -448,9 +448,12 @@ public CommandLine(string[] args, CommandLineLogger? logger = null) "------------------", "", - { $"ll|loglevel=|{Configuration.Logging.LogLevelKey}=", + { $"ll|loglevel=|{Configuration.LoggingLevel.LogLevelKey}=", $"The loglevel to use.\nAllowed values:\n `{string.Join("`\n `", Enum.GetNames(typeof(LogLevel)))}`\nDefault: `{LogLevel.Information}`.\n", - (LogLevel l) => this[Configuration.Logging.LogLevelKey] = l.ToString() }, + (LogLevel l) => this[Configuration.LoggingLevel.LogLevelKey] = l.ToString() }, + { $"lfm|logformat=|{Configuration.LoggingFormat.LogFormatKey}=", + $"The log format to use when writing to the console.\nAllowed values:\n `{string.Join("`\n `", Configuration.LoggingFormat.LogFormatsSupported)}`\nDefault: `{Configuration.LoggingFormat.LogFormatDefault}`.\n", + (string s) => this[Configuration.LoggingFormat.LogFormatKey] = s }, { $"di|diagnosticsinterval=|{PublisherConfig.DiagnosticsIntervalKey}=", "Produce publisher diagnostic information at this specified interval in seconds.\nBy default diagnostics are written to the OPC Publisher logger (which requires at least --loglevel `information`) unless configured differently using `--pd`.\n`0` disables diagnostic output.\nDefault:60000 (60 seconds).\nAlso can be set using `DiagnosticsInterval` environment variable in the form of a duration string in the form `[d.]hh:mm:ss[.fffffff]`\".\n", (int i) => this[PublisherConfig.DiagnosticsIntervalKey] = TimeSpan.FromSeconds(i).ToString() }, @@ -475,12 +478,6 @@ public CommandLine(string[] args, CommandLineLogger? logger = null) { $"em|enableprometheusendpoint=|{Configuration.Otlp.EnableMetricsKey}=", "Explicitly enable or disable exporting prometheus metrics directly on the standard path.\nDefault: `disabled` if Otlp collector is configured, otherwise `enabled`.\n", (bool? b) => this[Configuration.Otlp.EnableMetricsKey] = b?.ToString() ?? "True" }, - { $"cap|capturedevice=|{OpcUaClientConfig.CaptureDeviceKey}=", - $"The capture device to use to capture network traffic.\n{SupportsCapture(OpcUaClientCapture.AvailableDevices)}\n", - (string s) => this[OpcUaClientConfig.CaptureDeviceKey] = s }, - { $"cpf|capturefile=|{OpcUaClientConfig.CaptureFileNameKey}=", - $"The file name to capture traffic to.\nA device must be selected using `--cd` if capture capability is supported on this system.\nDefault: `{OpcUaClientConfig.CaptureFileNameDefault}`.\n", - (string s) => this[OpcUaClientConfig.CaptureFileNameKey] = s }, // testing purposes @@ -618,15 +615,6 @@ void SetStoreType(string s, string storeTypeKey, string optionName) } } - private static string SupportsCapture(IReadOnlyList devices) - { - if (devices.Count == 0) - { - return "Network capture is not supported on this system."; - } - return $"Available devices on your system:\n `{string.Join("`\n `", devices)}`\nDefault: `null` (disabled)."; - } - private readonly CommandLineLogger _logger; } diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/src/Runtime/Configuration.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/src/Runtime/Configuration.cs index 689976f0ec..4c5e2c3f72 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Module/src/Runtime/Configuration.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Module/src/Runtime/Configuration.cs @@ -33,6 +33,8 @@ namespace Azure.IIoT.OpcUa.Publisher.Module.Runtime using System.Linq; using System.Net; using System.Text.RegularExpressions; + using Microsoft.Extensions.Logging.Console; + using static Azure.IIoT.OpcUa.Publisher.Module.Runtime.Configuration; /// /// Configuration extensions @@ -54,8 +56,16 @@ public static void AddPublisherServices(this ContainerBuilder builder) .AsImplementedInterfaces().SingleInstance(); builder.RegisterType() .AsImplementedInterfaces().AsSelf().SingleInstance(); - builder.RegisterType() + builder.RegisterType() .AsImplementedInterfaces(); + builder.RegisterType>() + .AsImplementedInterfaces(); + builder.RegisterType>() + .AsImplementedInterfaces(); + builder.RegisterType>() + .AsImplementedInterfaces(); + builder.RegisterType() + .AsImplementedInterfaces().AsSelf().SingleInstance(); builder.RegisterType() .AsImplementedInterfaces(); @@ -445,9 +455,9 @@ public override void Configure(string? name, KestrelServerOptions options) } /// - /// Configure logger factory + /// Configure logger filter /// - internal sealed class Logging : ConfigureOptionBase + internal sealed class LoggingLevel : ConfigureOptionBase { /// /// Configuration @@ -457,9 +467,80 @@ internal sealed class Logging : ConfigureOptionBase /// public override void Configure(string? name, LoggerFilterOptions options) { - if (Enum.TryParse(GetStringOrDefault(LogLevelKey), out var logLevel)) + var levelString = GetStringOrDefault(LogLevelKey); + if (!string.IsNullOrEmpty(levelString)) + { + if (Enum.TryParse(levelString, out var logLevel)) + { + options.MinLevel = logLevel; + } + else + { + // Compatibilty with serilog + switch (levelString) + { + case "Verbose": + options.MinLevel = LogLevel.Trace; + break; + case "Fatal": + options.MinLevel = LogLevel.Critical; + break; + } + } + } + } + + /// + /// Create logging configurator + /// + /// + public LoggingLevel(IConfiguration configuration) : base(configuration) + { + } + } + + /// + /// Logging format + /// + internal class LoggingFormat : PostConfigureOptionBase + { + /// + /// Supported formats + /// + public static readonly string[] LogFormatsSupported = new[] + { + ConsoleFormatterNames.Simple, + Syslog.FormatterName, + ConsoleFormatterNames.Systemd + }; + + /// + /// Configuration + /// + public const string LogFormatKey = "LogFormat"; + + /// + /// Default format + /// + public const string LogFormatDefault = ConsoleFormatterNames.Simple; + + /// + public override void PostConfigure(string? name, ConsoleLoggerOptions options) + { + switch (GetStringOrDefault(LogFormatKey)) { - options.MinLevel = logLevel; + case Syslog.FormatterName: + options.FormatterName = Syslog.FormatterName; + break; + case ConsoleFormatterNames.Systemd: + options.FormatterName = ConsoleFormatterNames.Systemd; + break; + case ConsoleFormatterNames.Simple: + options.FormatterName = ConsoleFormatterNames.Simple; + break; + default: + options.FormatterName = LogFormatDefault; + break; } } @@ -467,7 +548,37 @@ public override void Configure(string? name, LoggerFilterOptions options) /// Create logging configurator /// /// - public Logging(IConfiguration configuration) : base(configuration) + public LoggingFormat(IConfiguration configuration) : base(configuration) + { + } + } + + /// + /// Logging format + /// + /// + internal sealed class ConsoleLogging : LoggingFormat, + IConfigureOptions, IConfigureNamedOptions where T : ConsoleFormatterOptions + { + /// + public void Configure(string? name, T options) + { + options.TimestampFormat = "[yy-MM-dd HH:mm:ss.ffff] "; + options.IncludeScopes = true; + options.UseUtcTimestamp = true; + } + + /// + public void Configure(T options) + { + Configure(null, options); + } + + /// + /// Create logging configurator + /// + /// + public ConsoleLogging(IConfiguration configuration) : base(configuration) { } } diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/src/Runtime/Syslog.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/src/Runtime/Syslog.cs new file mode 100644 index 0000000000..225444c3a3 --- /dev/null +++ b/src/Azure.IIoT.OpcUa.Publisher.Module/src/Runtime/Syslog.cs @@ -0,0 +1,108 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See License.txt in the repo root for license information. +// ------------------------------------------------------------ + +namespace Azure.IIoT.OpcUa.Publisher.Module.Runtime +{ + using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Options; + using System; + using System.Globalization; + using Microsoft.Extensions.Logging.Abstractions; + using Microsoft.Extensions.Logging.Console; + using System.IO; + using System.Text; + + /// + /// Logging formatter compatible with syslogs format. + /// + public sealed class Syslog : ConsoleFormatter, IDisposable + { + /// + /// The default timestamp format for all IoT compatible logging events.. + /// + public const string DefaultTimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ "; + + /// + /// Name of the formatter + /// + public const string FormatterName = "syslog"; + + /// + /// Initializes a new instance of the class. + /// + /// + public Syslog(IOptionsMonitor options) + : base(FormatterName) + { + _optionsReloadToken = options.OnChange(opt => + { + _options = opt; + _includeScopes = opt.IncludeScopes; + }); + _options = options.CurrentValue; + _serviceId = "opcpublisher@311"; + _timestampFormat = DefaultTimestampFormat; + _includeScopes = _options.IncludeScopes; + } + + /// + public override void Write(in LogEntry logEntry, + IExternalScopeProvider? scopeProvider, TextWriter textWriter) + { + string? message = logEntry.Formatter?.Invoke(logEntry.State, logEntry.Exception); + if (message is null) + { + return; + } + var messageBuilder = new StringBuilder(_initialLength) + .Append(_syslogMap[(int)logEntry.LogLevel]) + .Append(DateTime.UtcNow.ToString(_timestampFormat, CultureInfo.InvariantCulture)); + if (_includeScopes && scopeProvider != null && !string.IsNullOrEmpty(_serviceId)) + { + messageBuilder.Append('[').Append(_serviceId); + scopeProvider.ForEachScope((scope, state) => + { + StringBuilder builder = state; + builder.Append(' ').Append(scope); + }, messageBuilder); + messageBuilder.Append("] "); + } + messageBuilder.Append("- ").AppendLine(message); + if (logEntry.Exception != null) + { + // TODO: syslog format does not support stack traces + messageBuilder.AppendLine(logEntry.Exception.ToString()); + } + textWriter.Write(messageBuilder.ToString()); + } + + /// + public void Dispose() + { + _optionsReloadToken?.Dispose(); + } + + private const int _initialLength = 256; + + /// + /// Map of to syslog severity. + /// + private static readonly string[] _syslogMap = new[] + { + /* Trace */ "<7>", + /* Debug */ "<7>", + /* Info */ "<6>", + /* Warn */ "<4>", + /* Error */ "<3>", + /* Crit */ "<3>" + }; + + private readonly IDisposable? _optionsReloadToken; + private readonly string _timestampFormat; + private readonly string _serviceId; + private bool _includeScopes; + private ConsoleFormatterOptions _options; + } +} diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/src/Startup.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/src/Startup.cs index 6ce46d3a30..11da21969a 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Module/src/Startup.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Module/src/Startup.cs @@ -20,6 +20,7 @@ namespace Azure.IIoT.OpcUa.Publisher.Module using OpenTelemetry.Resources; using OpenTelemetry.Trace; using System; + using Microsoft.Extensions.Logging.Console; /// /// Webservice startup @@ -60,13 +61,8 @@ public void ConfigureServices(IServiceCollection services) { services.AddLogging(options => options .AddFilter(typeof(IAwaitable).Namespace, LogLevel.Warning) - .AddSimpleConsole(options => - { - // options.SingleLine = true; - options.IncludeScopes = true; - options.UseUtcTimestamp = true; - options.TimestampFormat = "[yy-MM-dd HH:mm:ss.ffff] "; - }) + .AddConsole() + .AddConsoleFormatter() .AddOpenTelemetry(Configuration, options => { options.IncludeScopes = true; diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Azure.IIoT.OpcUa.Publisher.Module.Tests.csproj b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Azure.IIoT.OpcUa.Publisher.Module.Tests.csproj index 9e1295d8ca..2ada11f497 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Azure.IIoT.OpcUa.Publisher.Module.Tests.csproj +++ b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Azure.IIoT.OpcUa.Publisher.Module.Tests.csproj @@ -9,7 +9,7 @@ - + all runtime; build; native; contentfiles; analyzers diff --git a/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi/tests/Azure.IIoT.OpcUa.Publisher.Service.WebApi.Tests.csproj b/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi/tests/Azure.IIoT.OpcUa.Publisher.Service.WebApi.Tests.csproj index 8fbdf06243..694090077c 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi/tests/Azure.IIoT.OpcUa.Publisher.Service.WebApi.Tests.csproj +++ b/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi/tests/Azure.IIoT.OpcUa.Publisher.Service.WebApi.Tests.csproj @@ -10,7 +10,7 @@ - + all runtime; build; native; contentfiles; analyzers diff --git a/src/Azure.IIoT.OpcUa.Publisher.Service/tests/Azure.IIoT.OpcUa.Publisher.Service.Tests.csproj b/src/Azure.IIoT.OpcUa.Publisher.Service/tests/Azure.IIoT.OpcUa.Publisher.Service.Tests.csproj index c4f7373e7a..915f662eaa 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Service/tests/Azure.IIoT.OpcUa.Publisher.Service.Tests.csproj +++ b/src/Azure.IIoT.OpcUa.Publisher.Service/tests/Azure.IIoT.OpcUa.Publisher.Service.Tests.csproj @@ -12,7 +12,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers diff --git a/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Azure.IIoT.OpcUa.Publisher.Testing.csproj b/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Azure.IIoT.OpcUa.Publisher.Testing.csproj index 90b61398ff..7443fce93c 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Azure.IIoT.OpcUa.Publisher.Testing.csproj +++ b/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Azure.IIoT.OpcUa.Publisher.Testing.csproj @@ -12,7 +12,7 @@ - + diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Azure.IIoT.OpcUa.Publisher.csproj b/src/Azure.IIoT.OpcUa.Publisher/src/Azure.IIoT.OpcUa.Publisher.csproj index ac081a3a42..fb5dbf4155 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Azure.IIoT.OpcUa.Publisher.csproj +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Azure.IIoT.OpcUa.Publisher.csproj @@ -4,12 +4,6 @@ true enable - - SHARPPCAP - - - - diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Services/PublisherDiagnosticCollector.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Services/PublisherDiagnosticCollector.cs index 9676cbcd4c..6073ca5f22 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Services/PublisherDiagnosticCollector.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Services/PublisherDiagnosticCollector.cs @@ -195,14 +195,7 @@ internal WriterGroupDiagnosticModel AggregateModel writers.Sum(w => w.MonitoredOpcNodesFailedCount), MonitoredOpcNodesSucceededCount = MonitoredOpcNodesSucceededCount + writers.Sum(w => w.MonitoredOpcNodesSucceededCount), - ConnectionRetries = ConnectionRetries + - writers.Sum(w => w.ConnectionRetries), - NumberOfDisconnectedEndpoints = NumberOfDisconnectedEndpoints + - writers.Sum(w => w.NumberOfDisconnectedEndpoints), - NumberOfConnectedEndpoints = NumberOfConnectedEndpoints + - writers.Sum(w => w.NumberOfConnectedEndpoints), - OpcEndpointConnected = NumberOfConnectedEndpoints != 0 || - writers.Any(w => w.NumberOfConnectedEndpoints != 0), + OpcEndpointConnected = NumberOfConnectedEndpoints != 0, PublishRequestsRatio = PublishRequestsRatio + writers.Sum(w => w.PublishRequestsRatio), BadPublishRequestsRatio = BadPublishRequestsRatio + diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Services/WriterGroupDataSource.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Services/WriterGroupDataSource.cs index de0692f04a..c8e0cff5c8 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Services/WriterGroupDataSource.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Services/WriterGroupDataSource.cs @@ -886,6 +886,20 @@ private long DataChangesCount } } + private IEnumerable UsedClients => _subscriptions.Values + .Select(s => s.Subscription?.State!) + .Where(s => s != null) + .Distinct(); + + private int ReconnectCount => UsedClients + .Sum(s => s.ReconnectCount); + + private int ConnectedClients => UsedClients + .Count(s => s.State == EndpointConnectivityState.Ready); + + private int DisconnectedClients => UsedClients + .Count(s => s.State != EndpointConnectivityState.Ready); + /// /// Create observable metrics /// @@ -927,6 +941,15 @@ private void InitializeMetrics() _meter.CreateObservableCounter("iiot_edge_publisher_keep_alive_notifications", () => new Measurement(_keepAliveCount, _metrics.TagList), "Notifications", "Total Opc keep alive notifications delivered for processing."); + _meter.CreateObservableUpDownCounter("iiot_edge_publisher_connection_retries", + () => new Measurement(ReconnectCount, + _metrics.TagList), "Attempts", "OPC UA connect retries."); + _meter.CreateObservableGauge("iiot_edge_publisher_is_connection_ok", + () => new Measurement(ConnectedClients, + _metrics.TagList), "Endpoints", "OPC UA endpoints that are successfully connected."); + _meter.CreateObservableGauge("iiot_edge_publisher_is_disconnected", + () => new Measurement(DisconnectedClients, + _metrics.TagList), "Endpoints", "OPC UA endpoints that are disconnected."); } private const long kNumberOfInvokedMessagesResetThreshold = long.MaxValue - 10000; diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Extensions/ContainerBuilderEx.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Extensions/ContainerBuilderEx.cs index 06e4f28953..d9d83718e2 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Extensions/ContainerBuilderEx.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Extensions/ContainerBuilderEx.cs @@ -31,8 +31,6 @@ public static void AddOpcUaStack(this ContainerBuilder builder) .AsImplementedInterfaces().SingleInstance(); builder.RegisterType() .AsImplementedInterfaces().SingleInstance(); - builder.RegisterType() - .AsImplementedInterfaces().SingleInstance(); builder.RegisterType() .AsImplementedInterfaces(); diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/IOpcUaClient.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/IOpcUaClient.cs index 21f162f239..9a3e483598 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/IOpcUaClient.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/IOpcUaClient.cs @@ -39,6 +39,8 @@ ValueTask GetSessionHandleAsync( /// is not connected. /// /// - void ManageSubscription(IOpcUaSubscription subscription); + /// + void ManageSubscription(IOpcUaSubscription subscription, + bool closeSubscription = false); } } diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/IOpcUaClientDiagnostics.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/IOpcUaClientDiagnostics.cs index 379cc59292..fa6f6acf61 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/IOpcUaClientDiagnostics.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/IOpcUaClientDiagnostics.cs @@ -10,7 +10,7 @@ namespace Azure.IIoT.OpcUa.Publisher.Stack /// /// Safely access client state for diagnostics /// - internal interface IOpcUaClientDiagnostics + public interface IOpcUaClientDiagnostics { /// /// Bad publish requests tracked by this client diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/IOpcUaSubscription.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/IOpcUaSubscription.cs index 88ee0dec6c..ad967a4081 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/IOpcUaSubscription.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/IOpcUaSubscription.cs @@ -17,8 +17,8 @@ namespace Azure.IIoT.OpcUa.Publisher.Stack internal interface IOpcUaSubscription { /// - /// Apply the current subscription configuration to - /// the session. + /// Create or update the subscription now using the + /// currently configured subscription configuration. /// /// /// @@ -34,5 +34,17 @@ ValueTask SyncWithSessionAsync(ISession session, /// bool TryGetCurrentPosition(out uint subscriptionId, out uint sequenceNumber); + + /// + /// Notifies the subscription that should remove + /// itself from the session. If the session is null + /// then there is no session and the subscription + /// should clean up. + /// + /// + /// + /// + ValueTask CloseInSessionAsync(ISession? session, + CancellationToken ct = default); } } diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/ISubscriptionHandle.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/ISubscriptionHandle.cs index e46a8ad582..53515cb125 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/ISubscriptionHandle.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/ISubscriptionHandle.cs @@ -6,8 +6,6 @@ namespace Azure.IIoT.OpcUa.Publisher.Stack { using Azure.IIoT.OpcUa.Publisher.Stack.Models; - using System.Threading; - using System.Threading.Tasks; /// /// Subscription handle is a safe abstraction that allows the owner of the @@ -26,6 +24,11 @@ public interface ISubscriptionHandle /// ushort LocalIndex { get; } + /// + /// State of the underlying client + /// + IOpcUaClientDiagnostics State { get; } + /// /// Create a keep alive notification /// diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaClient.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaClient.cs index 0f16c33641..9c1c946d5a 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaClient.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaClient.cs @@ -217,9 +217,10 @@ public bool TryGetSession([NotNullWhen(true)] out ISession? session) } /// - public void ManageSubscription(IOpcUaSubscription subscription) + public void ManageSubscription(IOpcUaSubscription subscription, bool closeSubscription) { - TriggerConnectionEvent(ConnectionEvent.SubscriptionManage, subscription); + TriggerConnectionEvent(closeSubscription ? + ConnectionEvent.SubscriptionClose : ConnectionEvent.SubscriptionManage, subscription); } /// @@ -709,6 +710,13 @@ await ApplySubscriptionAsync(new[] { item }, queuedSubscriptions, } break; + case ConnectionEvent.SubscriptionClose: + var sub = context as IOpcUaSubscription; + Debug.Assert(sub != null); + queuedSubscriptions.Remove(sub); + await sub.CloseInSessionAsync(_session, ct).ConfigureAwait(false); + break; + case ConnectionEvent.StartReconnect: // sent by the keep alive timeout path switch (currentSessionState) { @@ -1270,6 +1278,7 @@ private async ValueTask UpdateSessionAsync(ISession session) if (ReferenceEquals(_session, session)) { // Not a new session + NotifyConnectivityStateChange(EndpointConnectivityState.Ready); return false; } @@ -1567,7 +1576,8 @@ private enum ConnectionEvent Disconnect, StartReconnect, ReconnectComplete, - SubscriptionManage + SubscriptionManage, + SubscriptionClose } private enum SessionState diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaClientCapture.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaClientCapture.cs deleted file mode 100644 index 73604430c6..0000000000 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaClientCapture.cs +++ /dev/null @@ -1,189 +0,0 @@ -// ------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See License.txt in the repo root for license information. -// ------------------------------------------------------------ - -namespace Azure.IIoT.OpcUa.Publisher.Stack.Services -{ - using Microsoft.Extensions.Logging; - using Microsoft.Extensions.Options; - using System; -#if SHARPPCAP - using SharpPcap; - using SharpPcap.LibPcap; -#endif - using System.Collections.Generic; - using Autofac; - - /// - /// Opc Ua traffic capture capabilities - /// - public sealed class OpcUaClientCapture : IStartable, IDisposable - { - /// - /// Get the devices that can be used to capture - /// - public static IReadOnlyList AvailableDevices - { - get - { -#if SHARPPCAP - try - { - return LibPcapLiveDeviceList.Instance - .Select(d => d.Interface.FriendlyName ?? d.Name) - .ToArray(); - } - catch -#endif - { - return Array.Empty(); - } - } - } - - /// - /// Create capture service - /// - /// - /// - public OpcUaClientCapture(IOptions options, - ILogger logger) - { - _options = options; - _logger = logger; - } - - /// - public void Start() - { - // Find device - var deviceName = _options.Value.CaptureDevice; - if (string.IsNullOrEmpty(deviceName)) - { - return; - } - -#if SHARPPCAP - _logger.LogInformation("Using SharpPcap {Version}", Pcap.SharpPcapVersion); - var device = FindDeviceByName(deviceName); - if (device == null) - { - _logger.LogError("Could not find a capture device with name {Name}! " + - "Not capturing traffic...", deviceName); - return; - } - - _device?.Dispose(); - _device = new CaptureDevice(this, device, _options.Value.CaptureFileName); -#else - _logger.LogWarning("SharpPcap is not included in this build."); -#endif - } - - /// - public void Dispose() - { -#if SHARPPCAP - _device?.Dispose(); - _device = null; -#endif - } - -#if SHARPPCAP - /// - /// Find device by name - /// - /// - /// - private static LibPcapLiveDevice? FindDeviceByName(string deviceName) - { - var device = LibPcapLiveDeviceList.Instance - .FirstOrDefault(d => d.Interface.FriendlyName == deviceName); - if (device == null) - { - device = LibPcapLiveDeviceList.Instance - .FirstOrDefault(d => d.Name == deviceName); - if (device == null && deviceName == "loopback") - { - device = LibPcapLiveDeviceList.Instance - .FirstOrDefault(d => d.Loopback); - } - } - return device; - } - - /// - /// Capture device - /// - private sealed class CaptureDevice : IDisposable - { - /// - /// Create capture device - /// - /// - /// - /// - public CaptureDevice(OpcUaClientCapture outer, - LibPcapLiveDevice device, string? fileName = null) - { - _outer = outer; - _device = device; - - if (string.IsNullOrEmpty(fileName)) - { - fileName = "opcua.pcap"; - } - - if (File.Exists(fileName)) - { - File.Delete(fileName); - } - - _outer._logger.LogInformation( - "Start capturing {Device} ({Description}) to {FileName}.", - device.Name, device.Description, fileName); - - // Open the device for capturing - _device.Open(mode: DeviceModes.NoCaptureLocal, 1000); - // _device.Filter = "ip and tcp and not port 80 and not port 25"; - - _writer = new CaptureFileWriterDevice(fileName); - _writer.Open(_device); - _device.OnPacketArrival += (_, e) => _writer.Write(e.GetPacket()); - - // Start the capturing process - _device.StartCapture(); - } - - /// - public void Dispose() - { - try - { - _device.StopCapture(); - - _outer._logger.LogInformation( - "Stopped capturing {Device} ({Description}) to file ({Statistics}).", - _device.Name, _device.Description, _device.Statistics.ToString()); - - _writer.Close(); - } - finally - { - _writer.Dispose(); - _device.Dispose(); - } - } - - private readonly LibPcapLiveDevice _device; - private readonly CaptureFileWriterDevice _writer; - private readonly OpcUaClientCapture _outer; - } - - private CaptureDevice? _device; -#endif - private readonly IOptions _options; - private readonly ILogger _logger; - } -} diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaSubscription.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaSubscription.cs index 47b0c4d8c9..0d906b0528 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaSubscription.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaSubscription.cs @@ -46,6 +46,10 @@ internal sealed class OpcUaSubscription : Subscription, ISubscriptionHandle, /// public ushort LocalIndex { get; } + /// + public IOpcUaClientDiagnostics State + => (_client as IOpcUaClientDiagnostics) ?? OpcUaClient.Disconnected; + /// /// Current metadata /// @@ -58,12 +62,6 @@ internal sealed class OpcUaSubscription : Subscription, ISubscriptionHandle, internal bool IsOnline => Handle != null && Session?.Connected == true && !_closed; - /// - /// Client state - /// - internal IOpcUaClientDiagnostics State - => (_client as IOpcUaClientDiagnostics) ?? OpcUaClient.Disconnected; - /// /// Subscription /// @@ -96,6 +94,7 @@ internal OpcUaSubscription(IClientAccessor clients, InitializeMetrics(); TriggerManageSubscription(true); + Debug.Assert(_client != null); } /// @@ -240,10 +239,8 @@ public void Update(SubscriptionModel subscription) _forceRecreate = true; // ... release client handle to cause closing of session if last reference. - var client = _client; + _client?.Dispose(); _client = null; - - client?.Dispose(); } TriggerManageSubscription(true); @@ -263,31 +260,40 @@ public void Close() Debug.Assert(!_closed); _closed = true; + TriggerManageSubscription(true); } } + /// + public async ValueTask CloseInSessionAsync(ISession? session, CancellationToken ct) + { + Debug.Assert(_closed); + + // Finalize closing the subscription + ResetKeepAliveTimer(); + + _callbacks.OnSubscriptionUpdated(null); + + // Does not throw + await CloseCurrentSubscriptionAsync().ConfigureAwait(false); + + lock (_lock) + { + _client?.Dispose(); + _client = null; + } + } + /// public async ValueTask SyncWithSessionAsync(ISession session, CancellationToken ct) { - if (_disposed) + if (_disposed || _closed) { return; } try { - if (_closed) // Finalize closing the subscription - { - _callbacks.OnSubscriptionUpdated(null); - - // Does not throw - await CloseCurrentSubscriptionAsync().ConfigureAwait(false); - - _client?.Dispose(); - _client = null; - return; - } - await SyncWithSessionInternalAsync(session, ct).ConfigureAwait(false); } catch (Exception e) @@ -314,6 +320,11 @@ protected override void Dispose(bool disposing) if (!_disposed) { _disposed = true; + if (_closed) + { + _client?.Dispose(); + _client = null; + } FastDataChangeCallback = null; FastEventCallback = null; @@ -1233,7 +1244,10 @@ private void TriggerSubscriptionManagementCallbackIn(TimeSpan? delay, /// private void OnSubscriptionManagementTriggered(object? state) { - TriggerManageSubscription(false); + lock (_lock) + { + TriggerManageSubscription(false); + } } /// @@ -1263,7 +1277,7 @@ private void TriggerManageSubscription(bool ensureClientExists) _logger.LogInformation("Trigger management of subscription {Subscription}...", this); - _client.ManageSubscription(this); + _client.ManageSubscription(this, _closed); } /// @@ -2136,15 +2150,6 @@ public void InitializeMetrics() _meter.CreateObservableUpDownCounter("iiot_edge_publisher_monitored_items", () => new Measurement(_currentlyMonitored.Count, _metrics.TagList), "Monitored items", "Monitored item count."); - _meter.CreateObservableUpDownCounter("iiot_edge_publisher_connection_retries", - () => new Measurement(State.ReconnectCount, - _metrics.TagList), "Attempts", "OPC UA connect retries."); - _meter.CreateObservableGauge("iiot_edge_publisher_is_connection_ok", - () => new Measurement(State.State == EndpointConnectivityState.Ready ? 1 : 0, - _metrics.TagList), "Online", "OPC UA connection success flag."); - _meter.CreateObservableGauge("iiot_edge_publisher_is_disconnected", - () => new Measurement(State.State != EndpointConnectivityState.Ready ? 1 : 0, - _metrics.TagList), "Online", "OPC UA connection success flag."); _meter.CreateObservableUpDownCounter("iiot_edge_publisher_publish_requests_per_subscription", () => new Measurement(Ratio(State.OutstandingRequestCount, State.SubscriptionCount), _metrics.TagList), "Requests per Subscription", "Good publish requests per subsciption."); @@ -2164,9 +2169,7 @@ public void InitializeMetrics() private static readonly TimeSpan kDefaultErrorRetryDelay = TimeSpan.FromSeconds(2); private FrozenDictionary _currentlyMonitored; private SubscriptionModel _template; -#pragma warning disable CA2213 // Disposable fields should be disposed private IOpcUaClient? _client; -#pragma warning restore CA2213 // Disposable fields should be disposed private uint _previousSequenceNumber; private bool _useDeferredAcknoledge; private uint _sequenceNumber; diff --git a/src/Azure.IIoT.OpcUa.Publisher/tests/Azure.IIoT.OpcUa.Publisher.Tests.csproj b/src/Azure.IIoT.OpcUa.Publisher/tests/Azure.IIoT.OpcUa.Publisher.Tests.csproj index 5368644c1c..2b616c8815 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/tests/Azure.IIoT.OpcUa.Publisher.Tests.csproj +++ b/src/Azure.IIoT.OpcUa.Publisher/tests/Azure.IIoT.OpcUa.Publisher.Tests.csproj @@ -12,7 +12,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers diff --git a/src/Azure.IIoT.OpcUa/tests/Azure.IIoT.OpcUa.Tests.csproj b/src/Azure.IIoT.OpcUa/tests/Azure.IIoT.OpcUa.Tests.csproj index 807b8fac37..f4d29e8929 100644 --- a/src/Azure.IIoT.OpcUa/tests/Azure.IIoT.OpcUa.Tests.csproj +++ b/src/Azure.IIoT.OpcUa/tests/Azure.IIoT.OpcUa.Tests.csproj @@ -9,7 +9,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers