mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-29 07:07:24 +08:00
# Description Add **UaLens**, an Avalonia desktop engineering workspace for OPC UA exploration, monitoring, typed editing, administration and guided companion-specification tasks. This is a **new draft PR for `pisrapp` against `master`**, replacing closed, unmerged #4462 without reopening it. The shared prerequisites (#4801, #4802, #4803, #4804 and #4806) have all merged; their implementation is no longer part of this PR's delta. ### Scope - Multi-document workspace with connection, security and identity controls; browsing, subscriptions, events, alarms, history, writes and method calls. - Offline NodeSet2 exploration, typed scalar/array/matrix editing, certificate/GDS and account/role tools. - UaLens companion workflows, repository sample launching and the Aspire AppHost. - PubSub commissioning and capture/replay integration, plus the UaLens-facing OpenUSD viewport and rendered Vision workflows. - UaLens projects, test assemblies, UI assets, guides, solution/dependency entries, desktop test wiring and delivery workflows. Head **`6f46c51fcfef275059bbe77ef39c64c19ebc1053`** is one UaLens-only commit directly above master **`44a8cb277df450162c29313e2f4c862e7c683752`**. The rebase required no conflicts. All **967 UaLens file blobs**, the full-index binary patch, commit-message text and author/co-author attribution are unchanged from the previously published layer. There is **no implementation delta under `src/`**. The original history is retained in recovery refs, including `refs/pr-stack/backups/master20261017/ualens`. No feature was disabled, and no export/path safeguards, native acceptance criteria, test assertions or CI gates were weakened. The four previously recorded blank-line-at-EOF findings in the UaLens dialog files remain unchanged; they are not reported as newly fixed. ### Validation Fresh validation ran on the **exact PR head `6f46c51f`**, using the existing pinned SDK **10.0.401**, normal repository analyzers and physical/non-linked build outputs: ```powershell $d = 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe' $b = 'D:\git\UA-.NETStandard_master\TestResults\ua-stack-feedback20261016' $r = 'C:\Users\mschier\.copilot\session-state\abd33a2e-7139-44e1-9912-6e4451102b4e\files\ualens-new-draft20261017-net10' $env:DOTNET_ROOT = Split-Path -Parent $d $env:DOTNET_ROOT_X64 = $env:DOTNET_ROOT $env:DOTNET_CLI_TELEMETRY_OPTOUT = '1' $env:PATH = $env:DOTNET_ROOT + [IO.Path]::PathSeparator + $env:PATH & $d test 'tests\Opc.Ua.Lens.Tests\Opc.Ua.Lens.Tests.csproj' --no-restore --artifacts-path $b -c Release -f net10.0 -p:CustomTestTarget=net10.0 --filter 'TestCategory!=LongRunning&TestCategory!=Stress' --logger 'trx;LogFileName=ualens-new-draft.trx' --results-directory $r --blame-hang --blame-hang-timeout 3m --blame-hang-dump-type none --verbosity minimal # PASS, exit0: Passed: 4524, Failed: 0, Skipped: 0. ``` The saved TRX contains **4524 Passed and 44 explicit/prerequisite NotExecuted records** (4568 total). The repository verdict helper confirms zero failed tests, fixture setup/teardown failures or run-level errors, without toleration. No compiler/analyzer diagnostics were emitted. Logs, TRX and `ualens-new-draft20261017-validation.json` retain this exact-head evidence. **Not run for this new draft:** the full `UA.slnx` net48/net10 matrix, fresh coverage, other desktop TFMs, application-desktop/native workflow suites, NativeAOT, GPU/per-RID/installed-runtime or package-release qualification. UaLens targets .NET8+; no net48 UaLens execution is claimed. Remote CI and CodeQL are not yet claimed green. Historical native diagnostics and successful managed tests are not substitutes for rendered-frame or release acceptance. The previous PR and retained session artifacts preserve the full extraction, review, failed-attempt and diagnostic history. Historical intermittent CI failures were not silently reclassified as root-cause fixes; no old diagnostic grants or producer/release operations were reused for this PR. ## Related Issues _Reference all GitHub issues this PR addresses. If there is no issue yet, open one and link it here._ _If this is a relatively large or complex change, a design must have been discussed in the related tracking issue and signed off (which becomes the Architectural Decision Record (ADR))._ - References #4461, the existing closed UaLens design/qualification tracking issue; no new design approval is implied. - Replaces closed, unmerged #4462 for the remaining UaLens work. - Builds on merged prerequisites #4801, #4802, #4803, #4804 and #4806; this PR does not close them. ## Checklist _Put an `x` in the boxes that apply. You can complete these step by step after opening the PR._ - [ ] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [ ] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received. The test/coverage item remains unchecked because fresh coverage was not collected. This draft does not claim CLA confirmation, full-matrix clearance, native qualification or completed review. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Alexander Köpke <alexander.koepke@microsoft.com> Co-authored-by: Copilot <Copilot@users.noreply.github.com>
1710 lines
76 KiB
Markdown
1710 lines
76 KiB
Markdown
# Diagnostics
|
||
|
||
This document covers the diagnostics surface of the OPC UA .NET Standard
|
||
stack: how to plumb logs, traces, and metrics through `ITelemetryContext`;
|
||
the spec-defined OPC UA server audit events; the built-in OPC UA server
|
||
diagnostics information model; and the packet capture, dissection, and
|
||
replay engine shipped by `OPCFoundation.NetStandard.Opc.Ua.Core.Diagnostics`.
|
||
|
||
It replaces the previous `Observability.md` and `PacketCapture.md` docs.
|
||
|
||
## Contents
|
||
|
||
- [1. Telemetry context (`ITelemetryContext`)](#1-telemetry-context-itelemetrycontext)
|
||
- [Overview](#overview)
|
||
- [Extension methods](#extension-methods)
|
||
- [Obtaining a telemetry context](#obtaining-a-telemetry-context)
|
||
- [Using the telemetry context](#using-the-telemetry-context)
|
||
- [Refactoring guidance](#refactoring-guidance)
|
||
- [High-speed logging and source generators](#high-speed-logging-and-source-generators)
|
||
- [Extensibility patterns](#extensibility-patterns)
|
||
- [Metrics emitted by the stack](#metrics-emitted-by-the-stack)
|
||
- [Wiring up a metrics exporter](#wiring-up-a-metrics-exporter)
|
||
- [Opt-out / no-op contexts](#opt-out--no-op-contexts)
|
||
- [Temporary compromises](#temporary-compromises)
|
||
- [2. OPC UA server audit events](#2-opc-ua-server-audit-events)
|
||
- [Enabling auditing](#enabling-auditing)
|
||
- [Raising an audit event](#raising-an-audit-event)
|
||
- [Redacted private-key material](#redacted-private-key-material)
|
||
- [Subscribing on the server](#subscribing-on-the-server)
|
||
- [3. OPC UA server built-in diagnostics nodes](#3-opc-ua-server-built-in-diagnostics-nodes)
|
||
- [Enabling diagnostics](#enabling-diagnostics)
|
||
- [Information-model surface](#information-model-surface)
|
||
- [Performance considerations](#performance-considerations)
|
||
- [4. Packet capture, dissection, and replay](#4-packet-capture-dissection-and-replay)
|
||
- [Security model](#security-model)
|
||
- [When to use it](#when-to-use-it)
|
||
- [Architecture](#architecture)
|
||
- [Integration with the central channel manager](#integration-with-the-central-channel-manager)
|
||
- [Capture sources](#capture-sources)
|
||
- [Enabling pcap capture without dependency injection](#enabling-pcap-capture-without-dependency-injection)
|
||
- [Quick start: in-process client capture](#quick-start-in-process-client-capture)
|
||
- [Quick start: replay and decode an existing pcap](#quick-start-replay-and-decode-an-existing-pcap)
|
||
- [Quick start: mock-server replay](#quick-start-mock-server-replay)
|
||
- [Security profile coverage](#security-profile-coverage)
|
||
- [Transport coverage](#transport-coverage)
|
||
- [File format reference](#file-format-reference)
|
||
- [Dependencies and governance](#dependencies-and-governance)
|
||
- [Security considerations](#security-considerations)
|
||
- [Limitations](#limitations)
|
||
- [Troubleshooting](#troubleshooting)
|
||
- [5. PubSub packet capture and dissection](#5-pubsub-packet-capture-and-dissection)
|
||
- [Architecture — capturing transport decorator](#architecture--capturing-transport-decorator)
|
||
- [Capturing and dissecting](#capturing-and-dissecting)
|
||
- [Decrypting encrypted UADP](#decrypting-encrypted-uadp)
|
||
- [Environment-variable auto-capture](#environment-variable-auto-capture)
|
||
- [Security considerations](#security-considerations-1)
|
||
- [6. Related references](#6-related-references)
|
||
|
||
## 1. Telemetry context (`ITelemetryContext`)
|
||
|
||
`ITelemetryContext` is the single abstraction every stack component uses
|
||
to obtain `ILogger`, `ActivitySource`, and `Meter` instances. It removes
|
||
the prior process-wide singleton logger, supports dependency injection,
|
||
and aligns the stack with current .NET and OpenTelemetry practices. The
|
||
design follows Microsoft's [guidance for library authors](https://learn.microsoft.com/dotnet/core/extensions/logging-library-authors).
|
||
|
||
### Overview
|
||
|
||
The legacy `TraceLoggerProvider` enables source-generated log calls when
|
||
a configured trace-file or Debug sink can receive them, even without a
|
||
`Tracing.TraceEventHandler` subscriber. `TraceMask` still filters individual
|
||
messages. Trace events and file output can be enabled independently; file
|
||
write failures report the actual path and error in Debug output.
|
||
|
||
Ordinary `ILogger` event IDs, including source-generated class offsets, are
|
||
identifiers rather than trace masks; their trace category comes from the log
|
||
level. The obsolete `Utils.Trace(int traceMask, ...)` and
|
||
`Utils.Log(int traceMask, ...)` APIs retain their explicit category, including
|
||
combined masks, in `TraceEventArgs.TraceMask`. For direct `ILogger` compatibility
|
||
calls, an `EventId` whose number **and name** match a `Utils.TraceMasks` constant
|
||
(for example, `new EventId(Utils.TraceMasks.ServiceDetail, "ServiceDetail")`)
|
||
also selects that legacy category. A matching number alone does not.
|
||
|
||
```csharp
|
||
public interface ITelemetryContext
|
||
{
|
||
// Creates a new Meter for recording metrics (caller disposes).
|
||
Meter CreateMeter();
|
||
|
||
// Creates a Meter for a specific assembly (caller disposes).
|
||
Meter CreateMeter(Assembly assembly);
|
||
|
||
// Factory used to create typed ILogger instances.
|
||
ILoggerFactory LoggerFactory { get; }
|
||
|
||
// Shared ActivitySource representing the current assembly/component.
|
||
ActivitySource ActivitySource { get; }
|
||
|
||
// Gets the shared ActivitySource for a specific assembly.
|
||
ActivitySource GetActivitySource(Assembly assembly);
|
||
}
|
||
```
|
||
|
||
One abstraction covers all three telemetry pillars (logs, traces,
|
||
metrics). Lifetime semantics are explicit: callers dispose meters they
|
||
create; the context owns the long-lived `ActivitySource` and
|
||
`LoggerFactory`. Multiple telemetry contexts may coexist in the same
|
||
process — e.g. a server and a client, or several servers with
|
||
different logging configurations inside a container.
|
||
|
||
### Extension methods
|
||
|
||
```csharp
|
||
public static class TelemetryExtensions
|
||
{
|
||
// Creates a logger for the specified category.
|
||
ILogger CreateLogger(this ITelemetryContext context, string categoryName);
|
||
ILogger<T> CreateLogger<T>(this ITelemetryContext context);
|
||
|
||
// Captures the calling assembly and creates its Meter.
|
||
Meter CreateMeter(this ITelemetryContext context);
|
||
|
||
// Captures the calling assembly and gets its shared ActivitySource.
|
||
ActivitySource GetActivitySource(this ITelemetryContext context);
|
||
|
||
// Starts a new Activity with the shared ActivitySource.
|
||
Activity StartActivity(this ITelemetryContext context, string activityName, ActivityKind kind = ActivityKind.Internal);
|
||
}
|
||
```
|
||
|
||
The metric and tracing members on `ITelemetryContext` identify the
|
||
component assembly at the call site. The extension methods provide the
|
||
same behavior when the supplied `ITelemetryContext` may be `null`, using
|
||
the default context when necessary. In debug builds, the fallback also
|
||
reports a debug check so missing telemetry is caught early.
|
||
|
||
### Obtaining a telemetry context
|
||
|
||
Telemetry context should be passed **via constructors only**. This
|
||
enables dependency injection, testability, and `readonly` fields. It
|
||
also aligns with the lifecycle of the owning class — for example
|
||
`Dispose` on the class can dispose the meter it created.
|
||
|
||
Existing code can obtain an `ITelemetryContext` from
|
||
`IServiceMessageContext.Telemetry`, `ISystemContext.Telemetry`, or
|
||
`IServerInternal.Telemetry`. Encoders, session users, and filtering
|
||
helpers all have one of these available.
|
||
|
||
When code may run on the server side or in client/server-shared code,
|
||
use this priority order:
|
||
|
||
0. From a private `ITelemetryContext` member (e.g. `m_telemetry`) of
|
||
the current class. If the constructor receives any of the types
|
||
below, pick the first and assign it to a `private readonly`
|
||
`m_telemetry` — then use it everywhere in the class. Create
|
||
a `m_logger` field at the same time when one is needed.
|
||
1. `ISystemContext.Telemetry`
|
||
2. `IServerInternal.Telemetry`
|
||
3. `IServiceMessageContext.Telemetry`
|
||
|
||
In pure client code:
|
||
|
||
0. From a private `ITelemetryContext` member, initialized in the
|
||
constructor from any context in this list.
|
||
1. `ISession` → `session.MessageContext.Telemetry`
|
||
2. `ISystemContext.Telemetry`
|
||
3. `IServiceMessageContext.Telemetry`
|
||
|
||
When deriving from `NodeState`, override `void Initialize(ITelemetryContext)`
|
||
and store the context in a private field (call the base implementation
|
||
so it also receives the context). `Initialize` is called after the
|
||
`NodeState` is created and before any other method runs.
|
||
|
||
If you need to create a context yourself, use the
|
||
`DefaultTelemetry.Create(...)` static factory. Do **not** create new
|
||
contexts in code that already has access to one — plumb the
|
||
existing one through. The application root (typically where you
|
||
construct `ApplicationInstance` or `HostApplicationBuilder`) is the
|
||
right place to materialize the context.
|
||
|
||
### Using the telemetry context
|
||
|
||
Use the context directly when it is non-null. Use the extension methods
|
||
when a context may be null; the returned instance is guaranteed to be
|
||
non-null. Because `ITelemetryContext.CreateMeter()` shadows its extension,
|
||
invoke the nullable meter fallback explicitly as
|
||
`TelemetryExtensions.CreateMeter(telemetry)`.
|
||
|
||
```csharp
|
||
// Obtain telemetry context
|
||
ITelemetryContext telemetry = systemContext.Telemetry;
|
||
|
||
// Logging
|
||
ILogger logger = telemetry.CreateLogger("Sample");
|
||
// or
|
||
ILogger<MyClass> logger = telemetry.CreateLogger<MyClass>();
|
||
|
||
logger.LogInformation("Connecting to {Endpoint}", endpointUrl);
|
||
|
||
// Tracing
|
||
using Activity? activity = telemetry.StartActivity("ConnectSession");
|
||
// Perform OPC UA operation ...
|
||
|
||
// Metrics
|
||
using Meter meter = telemetry.CreateMeter();
|
||
Counter<long> connectCounter = meter.CreateCounter<long>("opc.ua.client.connects");
|
||
connectCounter.Add(1);
|
||
```
|
||
|
||
Store the obtained logger / meter / activity-source references in
|
||
`private readonly` fields populated in the constructor. Loggers are
|
||
cached by the `ILoggerProvider`, so obtaining one is cheap. Still,
|
||
be mindful of the per-object reference cost when creating loggers for
|
||
very large object populations (`NodeState`, `NodeId`).
|
||
|
||
Resolve source names before registering an `ActivityListener`, and keep its
|
||
`ShouldListenTo` filter free of source lookups. Creating an `ActivitySource`
|
||
synchronously invokes existing listeners; resolving a missing source inside
|
||
that filter can recursively re-enter source creation.
|
||
|
||
`ConsoleReferenceClient` and `ConsoleReferenceServer` show the full
|
||
pattern end-to-end.
|
||
|
||
### Refactoring guidance
|
||
|
||
- Only pass an `ILogger` via constructor to objects that actually need
|
||
it. `NodeState`-derived classes may use the telemetry context to
|
||
create a logger on demand instead of taking one as a constructor
|
||
parameter.
|
||
- Prefer **throwing** from "model" classes and **logging** in the
|
||
caller. Example: parsing a `NodeId` should throw on failure; the
|
||
caller decides whether to log a warning and return null, or
|
||
rethrow. A `TryParse` flavour is often the better API shape.
|
||
- Use a "service" class to manage large populations of model objects
|
||
and pass the telemetry context once into its constructor. The
|
||
service class then creates loggers/meters once and reuses them.
|
||
This is the pattern we follow for certificate handling, storage,
|
||
and configuration management.
|
||
- A logger/meter created in the outer class can be passed to inner
|
||
classes created inside the outer class — for example
|
||
PubSub message objects.
|
||
- If a class cannot obtain a logger yet (gradual migration),
|
||
initialize the field with `Telemetry.NullLogger.Instance`. In
|
||
release builds this is a no-op; in debug builds it is a debug-check
|
||
logger that asserts on use, surfacing the missing wiring. This is
|
||
different from `NullLogger.Instance` in
|
||
`Microsoft.Extensions.Logging.Abstractions`.
|
||
|
||
### High-speed logging and source generators
|
||
|
||
The stack uses [`LoggerMessageAttribute`](https://learn.microsoft.com/dotnet/core/extensions/logger-message-generator)
|
||
source-generated logging **everywhere**. It avoids boxing of value-type
|
||
arguments, caches the message formatter, and emits an efficient
|
||
`IsEnabled` check so a disabled log level costs nothing. All new logging
|
||
must follow this pattern; direct `ILogger.LogInformation/LogError/...`
|
||
calls are not allowed.
|
||
|
||
> **Authoring a log message** — the event-id conventions, log-class layout,
|
||
> parameter rules, and the `IsEnabled` guard for expensive arguments
|
||
> (`CA1873`, enabled solution-wide) live in the
|
||
> [Developer Guide](DeveloperGuide.md#add-a-log-message-source-generated).
|
||
|
||
### Sensitive data in logs (data classification)
|
||
|
||
The stack's log messages contain values that can be confidential in a
|
||
deployment: network addresses describe the plant network, certificate
|
||
subjects and application URIs identify installations, user names and file
|
||
paths identify people and hosts, and exception texts can contain any of
|
||
them. The stack does not decide what to hide. It classifies these values,
|
||
and the application decides per classification what reaches its logs. It
|
||
uses the standard .NET
|
||
[data classification and redaction](https://learn.microsoft.com/dotnet/core/extensions/data-redaction)
|
||
packages (`Microsoft.Extensions.Compliance.*`, `Microsoft.Extensions.Telemetry`).
|
||
|
||
Every `[LoggerMessage]` parameter that holds such a value carries one of the
|
||
attributes of `Opc.Ua.Redaction.OpcUaDataClassifications` (taxonomy `OpcUa`):
|
||
|
||
| Classification | Attribute | Examples |
|
||
|---|---|---|
|
||
| `NetworkAddress` | `[NetworkAddress]` | endpoint, discovery, proxy, reverse connect and broker URLs; host and domain names; IP and MAC addresses; socket end points; ports |
|
||
| `ApplicationIdentity` | `[ApplicationIdentity]` | certificates; certificate subject, issuer, thumbprint and serial number; application URIs |
|
||
| `UserIdentity` | `[UserIdentity]` | user names, identity token subjects and claims, identity mapping criteria |
|
||
| `FilePath` | `[FilePath]` | file, directory and certificate store paths |
|
||
| `SensitiveText` | `[SensitiveText]` | exception messages and diagnostic text passed as a parameter |
|
||
|
||
Identifiers that correlate entries, such as `ChannelId`, `SessionId`,
|
||
`SubscriptionId` and status codes, are not classified.
|
||
|
||
#### The logging pipeline must be redaction-aware
|
||
|
||
Classified values are only written by the redaction-aware ("extended")
|
||
logger of `Microsoft.Extensions.Telemetry`. **A plain `ILoggerFactory`
|
||
(`LoggerFactory.Create(...)` or `services.AddLogging(...)` without
|
||
`EnableRedaction()`) logs `(null)` in place of every classified value.**
|
||
|
||
The stack enables the extended logger itself in
|
||
`services.AddOpcUa().AddLogging(...)` and `DefaultTelemetry.Create(...)`.
|
||
Without further configuration the values are logged unchanged, as before.
|
||
`services.AddOpcUa()` alone does not change the host's logging. An
|
||
application that configures logging itself (a generic host, or its own
|
||
`ILoggerFactory` passed to the stack) enables it there:
|
||
|
||
```csharp
|
||
// Generic host
|
||
builder.Logging.EnableOpcUaRedaction();
|
||
|
||
// Own factory
|
||
using ILoggerFactory loggerFactory = LoggerFactory.Create(logging => logging
|
||
.EnableOpcUaRedaction() // EnableRedaction(), discriminator off
|
||
.AddConsole());
|
||
```
|
||
|
||
`EnableOpcUaRedaction()` is `EnableRedaction()` with
|
||
`LoggerRedactionOptions.ApplyDiscriminator = false`. With the discriminator
|
||
on, values that are not redacted get the parameter name appended
|
||
(`opc.tcp://host:4840:Url`).
|
||
|
||
#### Redacting
|
||
|
||
Register redactors per classification with `AddRedaction` (package
|
||
`Microsoft.Extensions.Compliance.Redaction`). Once any redactor is
|
||
registered, every classification without its own redactor is erased by the
|
||
fallback redactor:
|
||
|
||
```csharp
|
||
services.AddOpcUa().AddLogging(logging => logging.AddConsole());
|
||
services.AddRedaction(redaction => redaction
|
||
.SetRedactor<ErasingRedactor>(OpcUaDataClassifications.NetworkAddress)
|
||
.SetRedactor<ErasingRedactor>(OpcUaDataClassifications.UserIdentity)
|
||
.SetHmacRedactor(
|
||
options => { options.Key = hmacKey; options.KeyId = 1; },
|
||
OpcUaDataClassifications.ApplicationIdentity)
|
||
.SetFallbackRedactor<NullRedactor>()); // keep FilePath and SensitiveText
|
||
```
|
||
|
||
With an HMAC redactor the same value always produces the same token, so
|
||
entries stay correlatable without exposing the value. Redaction applies to
|
||
the formatted message and to the structured state, so OpenTelemetry and
|
||
other structured exporters only receive redacted values. A downstream
|
||
collector that redacts by itself gets the raw values when the
|
||
classifications are mapped to `NullRedactor`, and filters on the structured
|
||
keys.
|
||
|
||
#### What is not classified
|
||
|
||
- The `Exception` passed to a log call. Its message and stack trace are
|
||
written by the logger provider. With the extended logger,
|
||
`LoggerEnrichmentOptions.IncludeExceptionMessage = false` removes
|
||
exception messages from the log state.
|
||
- Exception and `ServiceResult` messages created by the stack. They are
|
||
returned to callers and peers and can contain addresses.
|
||
- The few log calls whose level is decided at run time (`logger.Log(level,
|
||
...)`), which cannot carry attributes.
|
||
|
||
#### The removed redaction API
|
||
|
||
`Redact.Create(...)`, `RedactionWrapper<T>`, `IRedactionStrategy`,
|
||
`RedactionStrategies` and `SimpleRedactionStrategy` were removed.
|
||
Configure redactors with `AddRedaction` instead of
|
||
`RedactionStrategies.SetStrategy`.
|
||
|
||
### Extensibility patterns
|
||
|
||
#### Custom `ITelemetryContext` implementations
|
||
|
||
Derive from `TelemetryContextBase`, supplying an `ILoggerFactory`
|
||
(and optionally an `ActivitySource`) to the base constructor. Each
|
||
instance gets its own meters / loggers without affecting any other
|
||
context.
|
||
|
||
```csharp
|
||
public sealed class TenantTelemetry : TelemetryContextBase
|
||
{
|
||
public TenantTelemetry(string tenantId, ILoggerFactory loggerFactory)
|
||
: base(loggerFactory, new ActivitySource($"Opc.Ua.{tenantId}"))
|
||
{
|
||
TenantId = tenantId;
|
||
}
|
||
|
||
public string TenantId { get; }
|
||
}
|
||
```
|
||
|
||
Multi-tenant hosting can then attach a `TenantTelemetry` to every
|
||
`ApplicationInstance` or session it creates, keeping each tenant's
|
||
telemetry stream isolated.
|
||
|
||
#### Wiring into Microsoft.Extensions.DependencyInjection
|
||
|
||
The OPC UA stack ships a fluent `IOpcUaBuilder` surface that
|
||
registers a default `ITelemetryContext` for you. The recommended
|
||
shape is:
|
||
|
||
```csharp
|
||
HostApplicationBuilder builder = Host.CreateApplicationBuilder();
|
||
|
||
builder.Services
|
||
.AddOpcUa() // registers ITelemetryContext
|
||
.AddLogging(b => b.AddConsole()) // wires ILoggerFactory through the same builder
|
||
.AddMetrics(); // wires IMeterFactory through the same builder
|
||
|
||
// Feature libraries hang off the same IOpcUaBuilder:
|
||
builder.Services
|
||
.AddOpcUa()
|
||
.AddClient(opts => { /* ... */ })
|
||
.AddServer(opts => { /* ... */ });
|
||
```
|
||
|
||
`services.AddOpcUa()` registers
|
||
[`ServiceProviderTelemetryContext`](../src/Opc.Ua.Core/Stack/Diagnostics/ServiceProviderTelemetryContext.cs)
|
||
as a singleton `ITelemetryContext` via `TryAddSingleton`. That
|
||
adapter resolves the host's `ILoggerFactory` from DI on first use
|
||
(falling back to `NullLoggerFactory` when none is registered) and
|
||
materializes a fresh `Meter` / `ActivitySource` per calling
|
||
assembly — no further wiring needed for the common case.
|
||
|
||
The fluent `.AddLogging(...)` / `.AddMetrics(...)` overloads on
|
||
`IOpcUaBuilder` are thin pass-throughs to the standard
|
||
`IServiceCollection` extensions, kept on the builder so the OPC UA
|
||
registration stays a single fluent chain. Parameterless overloads
|
||
exist for the "just turn it on" case.
|
||
|
||
To **replace** the default telemetry context with a custom
|
||
implementation (per-tenant context, prebuilt logger factory, opt-out
|
||
context, etc.), register it **before** calling `AddOpcUa()` so
|
||
`TryAddSingleton` keeps your registration:
|
||
|
||
```csharp
|
||
builder.Services.AddSingleton<ITelemetryContext>(sp
|
||
=> new TenantTelemetry("tenant-A", sp.GetRequiredService<ILoggerFactory>()));
|
||
builder.Services.AddOpcUa(); // sees the existing registration; does not overwrite
|
||
```
|
||
|
||
Or, for fine-grained per-component contexts, use keyed singletons
|
||
alongside the default one:
|
||
|
||
```csharp
|
||
builder.Services.AddOpcUa();
|
||
builder.Services.AddKeyedSingleton<ITelemetryContext>(
|
||
"Client",
|
||
(sp, _) => DefaultTelemetry.Create(sp.GetRequiredService<ILoggerFactory>()));
|
||
```
|
||
|
||
#### OpenTelemetry exporter wiring
|
||
|
||
`ITelemetryContext` exposes the standard .NET diagnostic primitives,
|
||
so any OpenTelemetry exporter that subscribes to an `ActivitySource`
|
||
or `Meter` works without bespoke integration. A typical OTLP export
|
||
looks like:
|
||
|
||
```csharp
|
||
builder.Services.AddOpenTelemetry()
|
||
.ConfigureResource(r => r.AddService("opc-ua-client"))
|
||
.WithTracing(t => t
|
||
.AddSource("Opc.Ua.Core*") // assembly-qualified source names
|
||
.AddSource("Opc.Ua.Client*")
|
||
.AddOtlpExporter())
|
||
.WithMetrics(m => m
|
||
.AddMeter("Opc.Ua.Client*") // assembly-qualified meter names
|
||
.AddMeter("Opc.Ua.Server*")
|
||
.AddOtlpExporter());
|
||
```
|
||
|
||
For Console / Jaeger / Application Insights, swap the
|
||
`AddOtlpExporter()` call for the corresponding exporter from
|
||
`OpenTelemetry.Exporter.*`.
|
||
|
||
#### Naming conventions
|
||
|
||
| Pillar | Convention | Example |
|
||
|---|---|---|
|
||
| `ActivitySource` name | Assembly or component-qualified | `Opc.Ua.Core`, `Opc.Ua.Client.Session`, `Opc.Ua.Core.Diagnostics` |
|
||
| `Activity` (span) name | Action-oriented, no parameters | `ConnectSession`, `CreateSubscription`, `StartCapture` |
|
||
| `Meter` name | Mirrors `ActivitySource` namespace | `Opc.Ua.Client.Session`, `Opc.Ua.Server.Subscriptions` |
|
||
| Counter / instrument name | `opc.ua.<area>.<noun>` lower-snake | `opc.ua.client.connects`, `opc.ua.server.subscriptions.active` |
|
||
| Tags / dimensions | Stable subset only | `endpoint.url`, `security.mode`, `security.policy.uri` |
|
||
|
||
Keep tag cardinality bounded — do not tag on session id or any
|
||
per-request identifier.
|
||
|
||
#### Testing patterns
|
||
|
||
For unit tests, use a `ListLoggerProvider` or `ITestOutputHelper`-backed
|
||
factory, then derive a `TelemetryContext` from it:
|
||
|
||
```csharp
|
||
var loggerFactory = LoggerFactory.Create(b => b.AddProvider(new ListLoggerProvider(out var logs)));
|
||
ITelemetryContext telemetry = DefaultTelemetry.Create(loggerFactory);
|
||
|
||
// Exercise the code-under-test, then assert on `logs`.
|
||
```
|
||
|
||
For activities, attach an `ActivityListener`:
|
||
|
||
```csharp
|
||
using var listener = new ActivityListener
|
||
{
|
||
ShouldListenTo = src => src.Name == "Opc.Ua.Core",
|
||
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllDataAndRecorded,
|
||
ActivityStopped = activity => observed.Add(activity)
|
||
};
|
||
ActivitySource.AddActivityListener(listener);
|
||
```
|
||
|
||
For metrics, use `MeterListener` to capture instrument writes without
|
||
needing a full OpenTelemetry pipeline.
|
||
|
||
### Metrics emitted by the stack
|
||
|
||
The stack creates one `Meter` per assembly that records measurements.
|
||
The meter's **name is the assembly name** of the component that
|
||
created it (via `ITelemetryContext.CreateMeter()` →
|
||
`Assembly.GetCallingAssembly().FullName`). Most tooling matches with
|
||
wildcards, so subscribe with `AddMeter("Opc.Ua.Core*", "Opc.Ua.Client*")`
|
||
to pick up everything the stack emits today.
|
||
|
||
The instruments below are the complete current inventory. Tag values
|
||
are documented next to the tag key.
|
||
|
||
#### Meter `Opc.Ua.Core`
|
||
|
||
Client transport channel manager — defined in
|
||
`ClientChannelManagerMetrics.cs`:
|
||
|
||
| Instrument | Kind | Unit | Tags | Description |
|
||
|---|---|---|---|---|
|
||
| `opc.ua.channel.open` | `Counter<long>` | — | `endpoint`, `reverse` (bool) | OPC UA client transport channels opened. |
|
||
| `opc.ua.channel.close` | `Counter<long>` | — | `endpoint`, `reverse`, `reason` (`lease-released` \| `manager-disposed` \| `faulted`) | OPC UA client transport channels closed. |
|
||
| `opc.ua.channel.active` | `UpDownCounter<long>` | — | `endpoint` | Current number of active OPC UA client channel entries. |
|
||
| `opc.ua.channel.reconnect.attempts` | `Counter<long>` | — | `endpoint`, `outcome` | OPC UA client channel reconnect attempts. |
|
||
| `opc.ua.channel.reconnect.duration` | `Histogram<double>` | `ms` | `endpoint`, `outcome` | Duration of reconnect cycles. |
|
||
| `opc.ua.channel.gate.wait` | `Histogram<double>` | `ms` | `endpoint` | Time spent waiting for the per-channel ready gate. |
|
||
| `opc.ua.channel.participant.timeout.count` | `Counter<long>` | — | `endpoint`, `participant` (kind prefix, e.g. `Session`, `Discovery`) | Reconnect participant callbacks that timed out. |
|
||
| `opc.ua.channel.participant.recreate.count` | `Counter<long>` | — | `endpoint`, `participant`, `success` (bool) | Reconnect participant recreate callbacks. |
|
||
| `opc.ua.channel.refcount` | `ObservableGauge<long>` | — | `endpoint` | Reference count per channel entry. |
|
||
| `opc.ua.channel.participants` | `ObservableGauge<long>` | — | `endpoint` | Participant count per channel entry. |
|
||
|
||
Note: the `participant` tag carries the **kind prefix only** (`Session`, `Discovery`, etc.). The per-instance participant id is deliberately omitted to keep metric cardinality bounded; the full id is available on the related Activity tags and on the structured logs emitted under the `Opc.Ua.ChannelManager` logger category for correlation (see [Sessions.md](Sessions.md#diagnostics-surface-contract--what-tags-and-structured-log-fields-carry)).
|
||
|
||
Client request duration — defined in `ClientBase.cs`:
|
||
|
||
| Instrument | Kind | Unit | Tags | Description |
|
||
|---|---|---|---|---|
|
||
| `opc.ua.client.request.duration` | `Histogram<double>` | `s` | `opc.ua.request.service` (service name), `opc.ua.response.status.code` (uint), `server.address` (endpoint URL), `opc.ua.request.timeout` (ms) | Wall-clock duration of each client service request. Default bucket boundaries: 5 ms - 60 s. Only emitted when the client's `ActivityTraceFlags` include `ClientTraceFlags.Metrics`. |
|
||
|
||
Certificate cache — defined in `CertificateCache.cs`:
|
||
|
||
| Instrument | Kind | Unit | Tags | Description |
|
||
|---|---|---|---|---|
|
||
| `opc.ua.certcache.hit` | `ObservableCounter<long>` | — | — | Total certificate cache hits (public + private key caches combined). |
|
||
| `opc.ua.certcache.miss` | `ObservableCounter<long>` | — | — | Total certificate cache misses. |
|
||
| `opc.ua.certcache.size` | `ObservableGauge<long>` | — | — | Current number of cached certificate entries (both caches). |
|
||
| `opc.ua.certcache.private_key_entries` | `ObservableGauge<long>` | — | — | Current number of cached entries that hold private keys. |
|
||
| `opc.ua.certcache.eviction` | `ObservableCounter<long>` | — | — | Total certificate cache evictions. |
|
||
|
||
#### Meter `Opc.Ua.Client`
|
||
|
||
Client node cache — defined in `NodeCache.cs`:
|
||
|
||
| Instrument | Kind | Unit | Tags | Description |
|
||
|---|---|---|---|---|
|
||
| `opc.ua.client.nodecache.hits` | `ObservableCounter<long>` | — | `cache` (`nodes` \| `values` \| `references`) | Hits on the named sub-cache. |
|
||
| `opc.ua.client.nodecache.misses` | `ObservableCounter<long>` | — | `cache` | Misses on the named sub-cache. |
|
||
| `opc.ua.client.nodecache.evictions` | `ObservableCounter<long>` | — | `cache` | Evictions from the named sub-cache. |
|
||
| `opc.ua.client.nodecache.size` | `ObservableGauge<long>` | — | `cache` | Current entry count of the named sub-cache. |
|
||
|
||
### Wiring up a metrics exporter
|
||
|
||
#### Prometheus (ASP.NET Core scrape endpoint)
|
||
|
||
```csharp
|
||
using OpenTelemetry.Metrics;
|
||
|
||
var builder = WebApplication.CreateBuilder(args);
|
||
|
||
builder.Services.AddOpenTelemetry()
|
||
.ConfigureResource(r => r.AddService("opc-ua-client"))
|
||
.WithMetrics(m => m
|
||
.AddMeter("Opc.Ua.Core*") // channel manager, ClientBase, cert cache
|
||
.AddMeter("Opc.Ua.Client*") // node cache
|
||
.AddPrometheusExporter());
|
||
|
||
WebApplication app = builder.Build();
|
||
|
||
// Exposes /metrics in Prometheus text format.
|
||
app.MapPrometheusScrapingEndpoint();
|
||
|
||
await app.RunAsync();
|
||
```
|
||
|
||
Pair with the Prometheus server `scrape_configs` entry:
|
||
|
||
```yaml
|
||
scrape_configs:
|
||
- job_name: opc-ua-client
|
||
scrape_interval: 15s
|
||
static_configs:
|
||
- targets: ["opc-ua-client.svc.cluster.local:8080"]
|
||
```
|
||
|
||
The OpenTelemetry Prometheus exporter exposes one Prometheus metric
|
||
per OPC UA instrument; tag keys map directly to Prometheus labels.
|
||
The `.` separators in instrument names become `_` per Prometheus
|
||
naming rules (e.g. `opc_ua_client_request_duration`).
|
||
|
||
#### OTLP (OpenTelemetry Protocol → collector / vendor backend)
|
||
|
||
```csharp
|
||
using OpenTelemetry.Metrics;
|
||
using OpenTelemetry.Resources;
|
||
|
||
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
|
||
|
||
builder.Services.AddOpenTelemetry()
|
||
.ConfigureResource(r => r.AddService("opc-ua-client"))
|
||
.WithMetrics(m => m
|
||
.AddMeter("Opc.Ua.Core*")
|
||
.AddMeter("Opc.Ua.Client*")
|
||
.AddOtlpExporter(opt =>
|
||
{
|
||
opt.Endpoint = new Uri("http://otel-collector:4317");
|
||
opt.Protocol = OpenTelemetry.Exporter.OtlpExportProtocol.Grpc;
|
||
}));
|
||
|
||
await builder.Build().RunAsync();
|
||
```
|
||
|
||
For an HTTP/protobuf collector (default `http://otel-collector:4318/v1/metrics`),
|
||
set `opt.Protocol = OtlpExportProtocol.HttpProtobuf` and adjust the
|
||
endpoint.
|
||
|
||
Make sure the host's `ITelemetryContext` is materialized through the
|
||
same `ILoggerFactory` / `IServiceProvider` that wires the exporters
|
||
(see [Wiring into Microsoft.Extensions.DependencyInjection](#wiring-into-microsoftextensionsdependencyinjection)
|
||
above) so the `Meter` instances created by the stack are visible to
|
||
the OpenTelemetry MeterProvider.
|
||
|
||
### Opt-out / no-op contexts
|
||
|
||
Pass a no-op `ITelemetryContext` when telemetry is
|
||
unwanted (typically only in unit tests or constrained AOT
|
||
scenarios):
|
||
|
||
```csharp
|
||
sealed class NullTelemetry : TelemetryContextBase
|
||
{
|
||
public NullTelemetry()
|
||
: base(NullLoggerFactory.Instance) // Microsoft.Extensions.Logging.Abstractions
|
||
{
|
||
}
|
||
}
|
||
```
|
||
|
||
Passing literal `null` for an `ITelemetryContext` parameter is **not
|
||
recommended** — it triggers the legacy trace fallback and can
|
||
emit format exceptions when a non-semantic trace logger receives a
|
||
semantic logging template.
|
||
|
||
### Temporary compromises
|
||
|
||
A handful of static utilities still exist and take the telemetry
|
||
context as an additional argument. These are progressively being
|
||
removed:
|
||
|
||
- `CertificateFactory.Create / CreateCertificate / CreateCertificateWith{,PEM}PrivateKey`
|
||
are now `[Obsolete]` and forward to `Certificate.FromRawData(...)` /
|
||
`DefaultCertificateFactory.Instance.*` / `DefaultCertificateIssuer.Instance.*`.
|
||
Internal callers have migrated; new code should use the factory /
|
||
issuer interfaces (or their singletons) directly. See
|
||
[CertificateManager](CertificateManager.md).
|
||
- Any remaining public-static method that gained an `ITelemetryContext`
|
||
parameter accepts it as the last optional argument with a default
|
||
of `null` (or before `CancellationToken` for async methods).
|
||
- `IServiceMessageContext` is occasionally needed where no context is
|
||
in scope (e.g. inside the decoder during extension-object
|
||
deserialization). An ambient `ServiceMessageContext` is exposed as
|
||
an `AsyncLocal<T>` for these situations. The ambient pattern is
|
||
marked `Experimental` and must not be used in new code.
|
||
|
||
These compromises will disappear over time as the codebase is
|
||
refactored to pass context purely through constructors.
|
||
|
||
## 2. OPC UA server audit events
|
||
|
||
OPC UA Part 5 defines a family of `AuditXxxEventType` event types that
|
||
servers raise for security-relevant operations (session lifecycle,
|
||
secure-channel events, certificate validation, history modification,
|
||
node management, role mapping, etc.). The stack provides ready-made
|
||
helpers in `src/Opc.Ua.Server/Diagnostics/AuditEvents.cs` for
|
||
every event type the standard server raises.
|
||
|
||
### Enabling auditing
|
||
|
||
Auditing is controlled by the `ServerConfiguration.AuditingEnabled`
|
||
flag, which is off by default. A hosted server enables it through the
|
||
configuration builder:
|
||
`OpcUaServerOptions.ConfigureBuilder = builder => builder.SetAuditingEnabled(true)`.
|
||
When `false`, audit helpers short-circuit immediately so there is no
|
||
runtime cost.
|
||
|
||
`StandardServer` exposes the active state through
|
||
`IServerInternal.Auditing` (via `IAuditEventServer.Auditing`). Custom
|
||
`NodeManager` implementations should honour this flag.
|
||
|
||
### Raising an audit event
|
||
|
||
The `AuditEvents` static class exposes one `Report<EventName>` method
|
||
per spec event — for example `ReportAuditCreateSessionEvent`,
|
||
`ReportAuditOpenSecureChannelEvent`, `ReportAuditCertificateEvent`,
|
||
`ReportAuditAddNodesEvent`, `ReportAuditHistoryValueUpdateEvent`,
|
||
`ReportAuditRoleMappingRuleChangedEvent`. Each helper:
|
||
|
||
1. Returns immediately when `IAuditEventServer.Auditing` is false.
|
||
2. Constructs the spec-defined `AuditEventState` with the required
|
||
fields (`ActionTimeStamp`, `Status`, `ServerId`, `ClientAuditEntryId`,
|
||
`ClientUserId`, ...) populated from the supplied operation context.
|
||
3. Calls `IAuditEventServer.ReportAuditEvent(...)` which publishes
|
||
the event so any subscribed monitored item delivers it to clients.
|
||
|
||
```csharp
|
||
public sealed class MyAuditingNodeManager : CustomNodeManager
|
||
{
|
||
private void OnUpdateCertificate(ISystemContext context, ...)
|
||
{
|
||
// ... apply update ...
|
||
|
||
// Report a standard AuditCertificateUpdatedEventType notification.
|
||
Server.ReportCertificateUpdatedAuditEvent(
|
||
operationContext,
|
||
objectId,
|
||
methodId,
|
||
inputArguments,
|
||
certificateGroupId,
|
||
certificateTypeId,
|
||
certificate,
|
||
Logger);
|
||
}
|
||
}
|
||
```
|
||
|
||
`Server` is the `IAuditEventServer` exposed by `StandardServer` /
|
||
`IServerInternal`. The `logger` is your context logger from
|
||
`ITelemetryContext`.
|
||
|
||
### Redacted private-key material
|
||
|
||
`AuditEvents.RedactedPrivateKey` is the canonical placeholder used
|
||
in `UpdateCertificate` audit `InputArguments` per OPC 10000-12 §7.10.3
|
||
to prevent private-key bytes leaking into audit payloads. Use it in
|
||
custom audit emitters that handle private keys.
|
||
|
||
### Subscribing on the server
|
||
|
||
Audit events flow to monitored items just like any other OPC UA event.
|
||
A client must subscribe to the `Server` object (or a higher-level
|
||
`ObjectsFolder`) with an `EventFilter` that selects the audit event
|
||
type. The server's own `Auditing` property must be `true` for any
|
||
events to be published.
|
||
|
||
Audit events are additionally gated by the `ReceiveEvents` permission: the server validates `PermissionType.ReceiveEvents` on the event type and source node for every event-monitored item, so a session only receives audit events when its granted roles include that permission (typically the well-known `SecurityAdmin` role). An anonymous or otherwise unprivileged session receives no audit events even though they are being generated. In the `ConsoleReferenceServer`, connect with username `sysadmin` / password `demo` (mapped to `[SecurityAdmin, AuthenticatedUser]` in `ReferenceServer.cs`) to observe them.
|
||
|
||
> **Compliance note (OPC UA CTT).** The CTT *Auditing* conformance units collect audit events over a subscription and correlate them by `ClientAuditEntryId`. Configure the CTT connection to authenticate as a `SecurityAdmin` user (for this reference server, `sysadmin`/`demo`); an anonymous connection produces `TestFindActualFromExpected - Unable to Find Entry for ClientAuditEntryId ...` for every case because no audit events are delivered to the unprivileged session.
|
||
|
||
## 3. OPC UA server built-in diagnostics nodes
|
||
|
||
OPC UA Part 5 §6 defines a `Server` object with a rich `ServerStatus`
|
||
and `ServerDiagnostics` sub-tree. The stack populates these nodes
|
||
automatically through `src/Opc.Ua.Server/Diagnostics/DiagnosticsNodeManager.cs`.
|
||
|
||
### Enabling diagnostics
|
||
|
||
The runtime cost of maintaining the diagnostics tree (per-session,
|
||
per-subscription counters) is gated by `ServerConfiguration.DiagnosticsEnabled`
|
||
(default **true** in `OpcUaServerOptions`). Toggle it at runtime
|
||
through the `Server.ServerDiagnostics.EnabledFlag` variable —
|
||
writes flow through `DiagnosticsNodeManager.SetDiagnosticsEnabledAsync`,
|
||
which atomically pauses or resumes the periodic scan loop.
|
||
|
||
When disabled, the periodic scan stops and diagnostics-array reads return
|
||
`BadOutOfService`. Per-session and per-subscription diagnostic instances
|
||
are removed; the standard diagnostics containers remain present.
|
||
Re-enabling diagnostics resumes scanning if diagnostic items are still
|
||
being monitored.
|
||
|
||
Session and subscription array reads use stable snapshots. A concurrent
|
||
removal does not truncate an in-progress result or block behind its
|
||
diagnostic update callbacks; subsequent reads reflect the removal.
|
||
|
||
### Information-model surface
|
||
|
||
| Node | Variable type | Source |
|
||
|---|---|---|
|
||
| `Server.ServerStatus` | `ServerStatusDataType` | Liveness, state, build info, current time |
|
||
| `Server.ServerStatus.CurrentTime` | `UtcTime` | Server clock |
|
||
| `Server.ServerStatus.State` | `ServerState` | `Running`, `Failed`, `Shutdown`, ... |
|
||
| `Server.ServerStatus.BuildInfo` | `BuildInfoDataType` | Product URI, manufacturer, software/build version |
|
||
| `Server.ServerDiagnostics` | object | Container for diagnostics summaries |
|
||
| `Server.ServerDiagnostics.ServerDiagnosticsSummary` | `ServerDiagnosticsSummaryDataType` | Server-wide counters |
|
||
| `Server.ServerDiagnostics.SubscriptionDiagnosticsArray` | `SubscriptionDiagnosticsDataType[]` | Per-subscription counters |
|
||
| `Server.ServerDiagnostics.SessionsDiagnosticsSummary` | object | Per-session diagnostics container |
|
||
| `Server.ServerDiagnostics.SessionsDiagnosticsSummary.SessionDiagnosticsArray` | `SessionDiagnosticsDataType[]` | Per-session counters |
|
||
| `Server.ServerDiagnostics.SessionsDiagnosticsSummary.SessionSecurityDiagnosticsArray` | `SessionSecurityDiagnosticsDataType[]` | Per-session security info |
|
||
| `Server.ServerDiagnostics.EnabledFlag` | `Boolean` | Live toggle for diagnostics scanning |
|
||
|
||
Server vendors that need additional diagnostics should expose them
|
||
under their own object hierarchy rather than mutating the
|
||
spec-defined tree. Tooling such as UaExpert relies on that tree.
|
||
|
||
### Performance considerations
|
||
|
||
- Per-monitored-item counters are the most expensive contributor.
|
||
When `DiagnosticsEnabled = false` the scan loop short-circuits
|
||
before iterating monitored items.
|
||
- Subscription diagnostics aggregate across the publishing thread;
|
||
reading them while the server is under heavy load briefly stalls
|
||
publishing.
|
||
- One scan timer runs at a fixed 1 s interval while diagnostics
|
||
are enabled and at least one diagnostic item is in Sampling or
|
||
Reporting mode. Ordinary monitored items do not keep this timer alive.
|
||
Disabling or deleting the last active diagnostic item stops it.
|
||
|
||
## 4. Packet capture, dissection, and replay
|
||
|
||
The OPC UA packet-capture feature records UA traffic, stores the
|
||
secure-channel keys needed for offline decoding, reconstructs service
|
||
calls, and replays captured conversations. The reusable engine ships
|
||
as `OPCFoundation.NetStandard.Opc.Ua.Core.Diagnostics`.
|
||
Decoding follows OPC UA Part 6 secure conversation framing and reuses
|
||
the stack `UaSCUaBinaryChannel` path instead of reimplementing
|
||
cryptography.
|
||
|
||
The [UaLens capture window](UaLens.md#packet-capture-and-offline-replay) is a
|
||
more constrained packet-only adapter over these modules. Its private registry
|
||
filters out channel-token notifications, so packet-data consent does **not**
|
||
enable key extraction. It has separate raw-PCAP export consent and never enables
|
||
mock-client/network replay. The engine and MCP defaults below are unchanged.
|
||
|
||
Replay import and raw export require a complete, structurally valid PCAP or
|
||
PCAPNG container, independently of `StartCaptureRequest.RequireCompleteReplay`.
|
||
Raw export revalidates the exact exported bytes, so replacing an admitted replay
|
||
file cannot turn it into an arbitrary-file download. Keylogs, registered key
|
||
artifacts and PCAPNG decryption-secret blocks are excluded from ordinary capture
|
||
export; key disclosure remains a separate explicitly enabled capability.
|
||
|
||
`PcapFileReader.ReadAllAsync(path, requireCompleteFile: true, ct)` additionally
|
||
rejects missing/partial global headers and partial trailing records for direct
|
||
reader callers. Its original overload remains tolerant of an incomplete trailing
|
||
header for active-capture snapshots. Strict mode does not add keys or transmit data.
|
||
|
||
### Security model
|
||
|
||
This component is a **diagnostics-only** tool. It captures live OPC
|
||
UA traffic and writes both the raw frames and the symmetric channel
|
||
keys (signing key, encryption key, IVs, nonces) to disk so the
|
||
traffic can be decrypted offline. **Do not enable in production.**
|
||
Operators who enable any part of it should treat every output file
|
||
as a secret.
|
||
|
||
#### Defaults — opt-in everything
|
||
|
||
| Capability | Default | Opt-in mechanism |
|
||
|---|---|---|
|
||
| Frame capture | available | `PcapBindings.Install()` / `InstallClient()` / `InstallServer()`, or `AddPcap()` |
|
||
| Keylog extraction | available when capture runs | implicit when capture is active |
|
||
| Mock-client replay | **off** | `PcapOptions.AllowMockClientReplay = true` AND populate `PcapOptions.AllowedReplayEndpoints` |
|
||
| Tamper-evident audit log | **off** | `PcapOptions.EnableTamperEvidentAudit = true` |
|
||
| KMS-backed key escrow (instead of disk) | not registered | register your own `IKeyEscrowProvider` in DI |
|
||
| Env-var auto-start pcap | **off** | `AddPcapFromEnvironment()` + `OPCUA_PCAP_FILE=<path>` |
|
||
| Env-var stand-alone keylog | **off** | `AddPcapFromEnvironment()` + `OPCUA_KEYLOGFILE=<path>` |
|
||
|
||
#### File-system posture
|
||
|
||
| Artifact | Mode (Unix) | Encryption-at-rest | Rotation |
|
||
|---|---|---|---|
|
||
| `*.pcap` capture | `0600` | none (binary frames; key material lives elsewhere) | `MaxBytesPerCapture` (default 256 MB), then `.001.pcap` etc. |
|
||
| `*.keylog.json` / `*.keylog.txt` | `0600` | AES-256-GCM when constructed with a session key | `MaxBytesPerKeylog` (default 8 MB) |
|
||
| `*.keylog.json.key` (session key) | `0600` | n/a | rotated alongside keylog |
|
||
| `*.audit.jsonl` (tamper-evident audit) | `0640` | none, but hash-chained per line | append-only |
|
||
|
||
`MaxArtifactsPerSession` (default 16) prunes the oldest rotated files
|
||
when exceeded.
|
||
|
||
#### Default storage
|
||
|
||
The base folder defaults to a per-user data directory rather than a
|
||
shared `/tmp` directory:
|
||
|
||
- Linux: `~/.local/share/OPCFoundation/opcua-pcap`
|
||
- macOS: `~/Library/Application Support/OPCFoundation/opcua-pcap`
|
||
- Windows: `%LOCALAPPDATA%\OPCFoundation\opcua-pcap`
|
||
|
||
If the process has no user-profile directory (for example, a
|
||
distroless container without `HOME` or `XDG_DATA_HOME`), the default
|
||
falls back to a process-stable, randomly named directory beneath the
|
||
system temporary directory. The directory is created atomically, and
|
||
the random name prevents another user on a shared host from pre-creating
|
||
it. Configured and default base folders are normalized to absolute
|
||
paths before session folders are created.
|
||
|
||
The base folder is created with mode `0700` on Unix and inherits the
|
||
user profile ACL on Windows. Override via `PcapOptions.BaseFolder`.
|
||
|
||
#### Environment-variable configuration
|
||
|
||
For deployments where the operator sets capture / keylog destinations
|
||
out-of-band (Kubernetes manifests, systemd unit files, container
|
||
launchers) the binding ships an optional auto-start path activated by
|
||
the dedicated DI extension:
|
||
|
||
```csharp
|
||
services.AddPcapFromEnvironment();
|
||
// optional: configure other PcapOptions on the same call
|
||
services.AddPcapFromEnvironment(options =>
|
||
{
|
||
options.MaxBytesPerCapture = 64L * 1024 * 1024;
|
||
});
|
||
```
|
||
|
||
The extension reads two environment variables **once** during the
|
||
`AddXXX` call and registers an `IHostedService` that materializes the
|
||
behaviour at host start. Changing the variables later in the process
|
||
lifetime has no effect (matches the existing
|
||
`OPCUA_PCAP_ENABLE_DIAGNOSTICS` opt-in semantics):
|
||
|
||
| Variable | Effect |
|
||
|---|---|
|
||
| `OPCUA_PCAP_FILE` | Auto-start an in-process capture session on host start. Frames are written verbatim to this path; the parent directory becomes `PcapOptions.BaseFolder` so the capture-session's path-traversal validation still runs. |
|
||
| `OPCUA_KEYLOGFILE` | When set without `OPCUA_PCAP_FILE`: install a stand-alone keylog observer (SSLKEYLOGFILE-style) directly into `IChannelCaptureRegistry`. No pcap, no `CaptureSession`, no `BaseFolder` plumbing. When set together with `OPCUA_PCAP_FILE`: keylog records are written to this path inside the auto-started capture instead of the conventional `*.uakeys.json` next to the pcap. |
|
||
|
||
The keylog file extension selects the writer format:
|
||
|
||
- `*.txt` → NSS-style space-delimited hex tokens
|
||
(`UaKeyLogTextWriter`).
|
||
- everything else (including `*.json` and `*.uakeys.json`) →
|
||
JSON-lines (`UaKeyLogJsonWriter`).
|
||
|
||
Whitespace-only values are treated as unset, so accidentally exporting
|
||
`OPCUA_PCAP_FILE=""` does not silently activate auto-capture. When
|
||
both variables are unset `AddPcapFromEnvironment` behaves
|
||
exactly like `AddPcap`: the capture binding is registered
|
||
and no auto-start happens.
|
||
|
||
When the auto-start path is active the host emits a `Warning`-level
|
||
log line at startup naming the env var(s) consumed and the resolved
|
||
path(s); the variable values are never logged as anything other than
|
||
the literal path, and key material is never logged at all.
|
||
|
||
##### Example: Kubernetes / Docker
|
||
|
||
```yaml
|
||
env:
|
||
- name: OPCUA_PCAP_FILE
|
||
value: /var/log/opcua/cap.pcap
|
||
- name: OPCUA_KEYLOGFILE
|
||
value: /var/log/opcua/keys.uakeys.json
|
||
```
|
||
|
||
```csharp
|
||
// Program.cs
|
||
HostApplicationBuilder builder = Host.CreateApplicationBuilder();
|
||
builder.Services.AddOpcUa().AddClient(options => { });
|
||
builder.Services.AddPcapFromEnvironment();
|
||
await builder.Build().RunAsync();
|
||
```
|
||
|
||
##### Example: SSLKEYLOGFILE-style keylog only
|
||
|
||
When you only need to decrypt traffic captured by Wireshark or
|
||
`tcpdump` running outside the process, set just the keylog variable:
|
||
|
||
```bash
|
||
export OPCUA_KEYLOGFILE=$HOME/opcua-keys.uakeys.json
|
||
```
|
||
|
||
```csharp
|
||
builder.Services.AddOpcUa().AddClient(options => { });
|
||
builder.Services.AddPcapFromEnvironment();
|
||
```
|
||
|
||
Every OPC UA secure-channel token activation is appended to the
|
||
keylog file for the lifetime of the host. Use `decode_pcap_with_keys`
|
||
or any external decoder that consumes
|
||
`OPCFoundation.NetStandard.Opc.Ua.Core.Diagnostics`'s keylog format
|
||
to recover plaintext from a separately-recorded pcap.
|
||
|
||
##### Security note
|
||
|
||
Anyone (process, container runtime, host operator, sidecar) that can
|
||
set these environment variables can divert OPC UA channel keys and
|
||
traffic to attacker-controlled paths. Treat
|
||
`AddPcapFromEnvironment` as a privileged configuration
|
||
surface:
|
||
|
||
1. Only enable it on hosts where the env var is itself protected
|
||
(e.g., Kubernetes Secret with a restricted `serviceAccount`,
|
||
systemd `EnvironmentFile=` pointing at a `0600` file).
|
||
2. Pair the destination directory with file-system permissions so
|
||
only the service account can read the resulting `*.pcap` /
|
||
`*.uakeys.json` files. The binding writes them with mode `0600`
|
||
on Unix; the directory permissions are the operator's
|
||
responsibility.
|
||
3. The **Operator checklist** below applies in full when either
|
||
variable is set.
|
||
|
||
#### Audit events
|
||
|
||
When `IPcapAuditSink` is registered (default: `LoggerPcapAuditSink`),
|
||
every security-relevant operation emits a structured event:
|
||
|
||
- `StartCapture`, `StopCapture`
|
||
- `DumpKeys`, `DecodePcapWithKeys`
|
||
- `StartReplay`, `StopReplay`
|
||
- `FrameCaptured` — rate-limited to 1/min per channel
|
||
|
||
For tamper-evident storage register the optional
|
||
`HashChainedAuditFileSink` (writes a JSONL ledger with per-line HMACs).
|
||
|
||
#### Operator checklist
|
||
|
||
Before enabling any part of this binding in a non-development
|
||
environment:
|
||
|
||
1. Confirm the host process runs under a dedicated service account
|
||
with no shell access. Keylog files are `0600`; only that account
|
||
can read them.
|
||
2. Confirm `PcapOptions.BaseFolder` points to a per-user / per-service
|
||
directory (NOT `/tmp` or any shared directory).
|
||
3. Confirm remote diagnostic access is explicitly enabled
|
||
and that the MCP transport is authenticated (Bearer token / mutual
|
||
TLS) and audited.
|
||
4. If using replay, populate `AllowedReplayEndpoints` with the
|
||
minimum set of hostnames required. Never use a wildcard.
|
||
5. If using a KMS, register `IKeyEscrowProvider` and verify the disk
|
||
writer is no longer invoked.
|
||
6. If using `AddPcapFromEnvironment`, confirm the
|
||
`OPCUA_PCAP_FILE` / `OPCUA_KEYLOGFILE` values are set by a
|
||
privileged orchestrator (Kubernetes Secret, systemd
|
||
`EnvironmentFile`) and that the destination directory is owned by
|
||
the service account with mode `0700`. See the
|
||
**Environment-variable configuration** section above.
|
||
7. Subscribe to upstream CVE feeds for `SharpPcap`, `PacketDotNet` and the
|
||
native capture backend used by the deployment
|
||
(see the **Dependencies and governance** section near the bottom of
|
||
this document).
|
||
|
||
#### Out-of-scope (known limitations)
|
||
|
||
This binding does not yet satisfy a strong, production-grade security
|
||
bar prescription for handling secret material. The known gaps below
|
||
are acknowledged limitations that consumers must mitigate
|
||
operationally or accept as residual risk.
|
||
|
||
- **In-process keylog writer.** `UaKeyLog{Json,Text}Writer` and the
|
||
per-session AES key live in the same AppDomain as the host
|
||
application. A host-process compromise (e.g., an unrelated
|
||
vulnerability in the OPC UA Client) can read keys directly from
|
||
process memory before the encrypt-at-rest path runs. Mitigation
|
||
options:
|
||
1. Run the host under a **dedicated, least-privilege service account**
|
||
with interactive shell access disabled. Restrict its filesystem and
|
||
network permissions to those required.
|
||
2. Register a custom `IKeyEscrowProvider` that hands keys to a
|
||
KMS (e.g., Azure Key Vault HSM) instead of the disk writer.
|
||
The disk writer is then unused.
|
||
3. A separate-process keylog writer is on the roadmap; until
|
||
then, treat option 1 or 2 as required for any non-developer
|
||
deployment.
|
||
|
||
- **`SuppressNonceValidationErrors` is unchanged.** This OPC UA
|
||
Client configuration option remains as a footgun for misconfigured
|
||
deployments. Keep the default (`false`) and add telemetry if you
|
||
must override it.
|
||
|
||
- **Native dependencies on libpcap / Npcap.** `SharpPcap` is a managed
|
||
capture API that uses libpcap/Npcap for NIC capture; `PacketDotNet`
|
||
provides managed packet parsing. Maintain the selected native backend
|
||
as well as the managed packages (see **Dependencies and governance**).
|
||
Subscribe to upstream CVE feeds and update within 30 days for HIGH or
|
||
CRITICAL advisories.
|
||
|
||
### When to use it
|
||
|
||
- Debug OPC UA interoperability issues that only appear on a real wire.
|
||
- Capture a repro for a bug that cannot be reproduced under a debugger.
|
||
- Validate wire-level effects of stack, application, or
|
||
security-policy changes.
|
||
- Build decoder and replay tests from real captures.
|
||
|
||
### Architecture
|
||
|
||
```mermaid
|
||
graph TD
|
||
A[Capture source] --> B[pcap / pcapng file]
|
||
A --> C[keylog file]
|
||
B --> D[Offline decoder]
|
||
C --> D
|
||
D --> E[UaSCUaBinaryChannel decoding path]
|
||
E --> F[Service-call timeline]
|
||
F --> G[Formatters]
|
||
F --> H[Replay engines]
|
||
```
|
||
|
||
### Integration with the central channel manager
|
||
|
||
`Opc.Ua.Core.Diagnostics` composes with the central
|
||
[`IClientChannelManager`](Sessions.md#4-iclientchannelmanager--centralised-channel-sharing-and-reconnect)
|
||
introduced in issue [#3288](https://github.com/OPCFoundation/UA-.NETStandard/issues/3288)
|
||
via the global `TransportBindings.Channels` registry: `AddPcap`
|
||
installs a `PcapTransportChannelBinding` decorator over the TCP
|
||
channel factory, and `ClientChannelManager` — which by default
|
||
reads from that same global registry — picks the wrapped
|
||
factory up automatically. There is no extra wiring code; the
|
||
composition is pure layering at the transport binding level.
|
||
|
||
Capture preserves three channel-manager behaviors:
|
||
|
||
- **Sharing.** When multiple participants share a single
|
||
`IManagedTransportChannel` (the refcount + key-equivalence model in
|
||
`ManagedChannelKey`), the underlying `CapturingMessageSocket`
|
||
records all participant traffic onto a single wire stream. One
|
||
capture session covers a `Session`, a `DiscoveryClient`, and a
|
||
`RegistrationClient` all targeting the same endpoint.
|
||
- **Transparent reconnect.** During the manager's coalesced reconnect
|
||
cycle the wrapping socket is re-created against the new transport
|
||
but the `IChannelCaptureRegistry` keeps recording into the same
|
||
session file — the reconnect transition appears in the
|
||
timeline as a state edge rather than a capture interruption.
|
||
- **Faulted-entry swap.** The `Phase E` `SwapFaultedEntryAsync` path
|
||
produces a fresh `ChannelEntry` under the same `ManagedChannelKey`;
|
||
the active capture observer rolls onto the new entry so an
|
||
exhaustion-then-recover cycle is recorded end-to-end in one
|
||
session.
|
||
|
||
If you compose the channel manager and the capture binding via DI
|
||
the standard registration order is:
|
||
|
||
```csharp
|
||
services.AddOpcUa().AddClient(options => { }); // central IClientChannelManager
|
||
services.AddSingleton<OpcUaSessionManager>(); // your application services
|
||
services.AddPcap(); // capture binding + CaptureSessionManager
|
||
services.AddPcapFormatters(); // service-timeline / pcap / pcapng / json / csv / text
|
||
services.AddPcapReplay(); // mock-client / mock-server replay engines
|
||
```
|
||
|
||
Registration order does not strictly matter (the binding installs
|
||
synchronously into the process-wide registry), but the order above
|
||
reads top-down as "register the channel manager, then your services,
|
||
then opt in to capture". For non-DI consumers, call
|
||
`PcapBindings.Install()` / `InstallClient()` / `InstallServer()` at
|
||
startup to achieve the same effect — see
|
||
[Enabling pcap capture without dependency injection](#enabling-pcap-capture-without-dependency-injection).
|
||
|
||
### Capture sources
|
||
|
||
**NIC (`nic`)** captures live traffic through SharpPcap. It requires
|
||
libpcap on Linux/macOS or Npcap on Windows and can use interface
|
||
names and BPF filters.
|
||
|
||
**In-process client (`inproc-client`)** taps OPC UA client channels
|
||
in the current process. It uses `IFrameCaptureSink` for wire chunks
|
||
and client token activation for key material. No native driver is
|
||
required.
|
||
|
||
**In-process server (`inproc-server`)** passively taps channels
|
||
accepted by a hosted OPC UA server. It uses the same frame sink plus
|
||
`TcpListenerChannel.OnTokenActivated`. No native driver is required.
|
||
|
||
**Replay (`replay`)** reads an existing pcap or pcapng plus
|
||
`.uakeys.json` or `.uakeys.txt`. Use it for offline decode, summaries,
|
||
and mock-client or mock-server replay.
|
||
|
||
PubSub dissection and all shared key-loading paths authorize a supplied keylog
|
||
against the host-approved capture root immediately before opening it. Knowing an
|
||
absolute path outside that root does not grant use of its keys.
|
||
|
||
Capture requests accept these canonical wire names and also the CLR
|
||
member names (`Nic`, `InProcessClient`, `InProcessServer`, `Replay`) for
|
||
compatibility. Capture metadata is serialized with the canonical
|
||
hyphenated names. Output formats follow the same rule:
|
||
`pcap | pcapng | json | csv | text | service-timeline`, with CLR names
|
||
such as `PcapNg` and `ServiceTimeline` also accepted.
|
||
|
||
### Enabling pcap capture without dependency injection
|
||
|
||
The `AddPcap()` DI extension is a convenience wrapper. Everything it
|
||
does can be wired by hand, which is the right approach for a console
|
||
server, a `ServerBase`/`StandardServer` host that is not built around
|
||
`Microsoft.Extensions.DependencyInjection`, or any client that creates
|
||
sessions through the static/fluent APIs.
|
||
|
||
Capture needs both steps; installing a binding without starting a capture
|
||
session records nothing:
|
||
|
||
1. **Install a binding** so the affected channels become
|
||
capture-*aware*.
|
||
2. **Start a capture session** (a `CaptureSessionManager` session)
|
||
sharing the *same* `IChannelCaptureRegistry`. Recording is gated by
|
||
the registry's `CurrentObserver`, which is `null` until a session is
|
||
running. Only traffic that flows *while a session is active* is
|
||
written.
|
||
|
||
There are three install helpers, all returning (or accepting) the
|
||
shared `IChannelCaptureRegistry`:
|
||
|
||
| Helper | Wraps | Captures |
|
||
|---|---|---|
|
||
| `PcapBindings.InstallClient(registry)` | the `opc.tcp` **channel** factory | outbound client channels created through `ClientChannelManager` |
|
||
| `PcapBindings.InstallServer(registry)` | the `opc.tcp` **listener** factory | inbound client→server channels accepted by a hosted server |
|
||
| `PcapBindings.Install(registry)` | both of the above | both directions on that one registry |
|
||
|
||
> **Two registries.** A hosted server and an in-process client use
|
||
> *different* transport binding registries. A server accepts inbound
|
||
> connections through the **listener** factory in
|
||
> `server.Server.TransportBindings` (a per-server registry). A non-DI
|
||
> client creates outbound channels through the process-wide
|
||
> `ClientChannelManager.DefaultChannelBindings`. Installing the server
|
||
> binding does **not** make client channels capture-aware, and vice
|
||
> versa. Install into the registry that matches the traffic you want,
|
||
> and share one `IChannelCaptureRegistry` across both installs so a
|
||
> single session records everything.
|
||
|
||
#### Server capture (inbound client→server traffic)
|
||
|
||
Install the server binding on the server's transport bindings
|
||
**before** the server is started (before its listeners open), then
|
||
drive a `CaptureSessionManager`:
|
||
|
||
```csharp
|
||
using Opc.Ua.Pcap.Bindings;
|
||
using Opc.Ua.Pcap.Capture;
|
||
using Opc.Ua.Pcap.Capture.Sources;
|
||
using Opc.Ua.Pcap.Models;
|
||
|
||
// 1. Install the server listener binding BEFORE starting the server.
|
||
// (Reuse the returned registry for the capture session below.)
|
||
IChannelCaptureRegistry registry =
|
||
PcapBindings.InstallServer(server.Server!.TransportBindings);
|
||
|
||
// ... configure node managers, certificates, etc. ...
|
||
await server.StartAsync().ConfigureAwait(false); // listeners open now
|
||
|
||
// 2. Start recording. The capture source factory and the binding MUST
|
||
// share the same registry instance.
|
||
var manager = new CaptureSessionManager(
|
||
new DefaultCaptureSourceFactory(registry),
|
||
baseFolder: @"C:\captures");
|
||
await using (manager.ConfigureAwait(false))
|
||
{
|
||
CaptureSession session = await manager.StartAsync(new StartCaptureRequest
|
||
{
|
||
Source = CaptureSourceKind.InProcessServer,
|
||
SessionFolder = @"C:\captures\server-run",
|
||
}, ct).ConfigureAwait(false);
|
||
|
||
// 3. Every client that connects while the session is running has its
|
||
// inbound/outbound frames and channel-token key material recorded.
|
||
|
||
await manager.StopAsync(session.Id, ct).ConfigureAwait(false);
|
||
// session.SessionFolder now contains capture.pcap (+ keys.uakeys.json).
|
||
}
|
||
```
|
||
|
||
`InstallServer` is a no-op when the registry has no `opc.tcp` listener
|
||
factory, and idempotent when the binding is already installed.
|
||
|
||
##### Env-var driven server capture (non-DI hosts)
|
||
|
||
Hosts that build their server through the classic `ApplicationInstance`
|
||
path (no `IServiceProvider` to drive the `AddPcapFromEnvironment()` hosted
|
||
service) can opt in to the **same** environment variables the client uses.
|
||
Call `PcapServerCapture.TryStartFromEnvironmentAsync` with the server's
|
||
transport bindings **before** the server starts and dispose the returned
|
||
handle on shutdown:
|
||
|
||
```csharp
|
||
using Opc.Ua.Pcap.Capture; // PcapServerCapture
|
||
|
||
// Before Application.StartAsync(server) / before the listeners open.
|
||
IAsyncDisposable? capture = await PcapServerCapture.TryStartFromEnvironmentAsync(
|
||
server.Server!.TransportBindings,
|
||
telemetry.LoggerFactory).ConfigureAwait(false);
|
||
|
||
// ... run the server ...
|
||
|
||
// On shutdown:
|
||
if (capture is not null)
|
||
{
|
||
await capture.DisposeAsync().ConfigureAwait(false);
|
||
}
|
||
```
|
||
|
||
A server started **without** the variables changes nothing (the call is a
|
||
complete no-op and returns `null`). A server started **with**
|
||
`OPCUA_PCAP_FILE` set installs the server listener binding and records
|
||
inbound client→server traffic to the file's folder; `OPCUA_KEYLOGFILE` on
|
||
its own installs a stand-alone key-log observer. These are the same
|
||
variables and semantics documented under
|
||
[Environment-variable configuration](#environment-variable-configuration)
|
||
for the client, so a single `OPCUA_PCAP_FILE`/`OPCUA_KEYLOGFILE` pair drives
|
||
both. The Console Reference Server wires this in automatically. (The pcap
|
||
capture package targets net8.0+.)
|
||
|
||
#### Client capture (outbound channels the app opens)
|
||
|
||
Install the channel binding into the registry the client actually uses.
|
||
For the non-DI/static client path that is
|
||
`ClientChannelManager.DefaultChannelBindings`:
|
||
|
||
```csharp
|
||
using Opc.Ua; // ClientChannelManager
|
||
using Opc.Ua.Pcap.Bindings;
|
||
|
||
IChannelCaptureRegistry registry =
|
||
PcapBindings.InstallClient(ClientChannelManager.DefaultChannelBindings);
|
||
|
||
// then start a CaptureSessionManager session with
|
||
// Source = CaptureSourceKind.InProcessClient, exactly as above.
|
||
```
|
||
|
||
#### Both directions in one process
|
||
|
||
Share a single registry so one session records inbound and outbound
|
||
traffic together:
|
||
|
||
```csharp
|
||
var registry = new ChannelCaptureRegistry();
|
||
|
||
// server side (inbound): the hosted server's per-server registry
|
||
PcapBindings.InstallServer(server.Server!.TransportBindings, registry);
|
||
|
||
// client side (outbound): the process-wide client default registry
|
||
PcapBindings.InstallClient(ClientChannelManager.DefaultChannelBindings, registry);
|
||
|
||
// one CaptureSessionManager + one StartAsync(Source = InProcessServer or
|
||
// InProcessClient) now records both, because both bindings publish to the
|
||
// same registry observer.
|
||
```
|
||
|
||
> When a *single* binding registry serves both roles (for example a
|
||
> combined host that opens listeners and also acts as a client through
|
||
> that same registry), `PcapBindings.Install(registry)` wires both in
|
||
> one call.
|
||
|
||
### Quick start: in-process client capture
|
||
|
||
```csharp
|
||
services.AddOpcUa().AddClient(options => { }); // central IClientChannelManager
|
||
services.AddPcap(); // capture binding + CaptureSessionManager
|
||
|
||
CaptureSessionManager manager = serviceProvider.GetRequiredService<CaptureSessionManager>();
|
||
|
||
var session = await manager.StartAsync(new StartCaptureRequest
|
||
{
|
||
Source = CaptureSourceKind.InProcessClient,
|
||
MaxDurationSeconds = 60,
|
||
SessionFolder = "C:/captures"
|
||
}, ct);
|
||
|
||
// Do real OPC UA work here — every channel created through the central
|
||
// IClientChannelManager (Session.CreateAsync, ManagedSessionBuilder,
|
||
// DiscoveryClient, RegistrationClient, GDS clients, …) is automatically
|
||
// wrapped by the capture binding. Channel sharing means a single
|
||
// recording stream covers every participant on the same endpoint.
|
||
|
||
await manager.StopAsync(session.Id, ct);
|
||
|
||
TraceFormatterRegistry formatters = serviceProvider.GetRequiredService<TraceFormatterRegistry>();
|
||
FormatResult timeline = await formatters
|
||
.Get(FormatKind.ServiceTimeline)
|
||
.FormatAsync(session.Source, maxFrames: null, ct);
|
||
byte[] bytes = timeline.Bytes;
|
||
```
|
||
|
||
### Quick start: replay and decode an existing pcap
|
||
|
||
```csharp
|
||
services.AddPcap();
|
||
|
||
CaptureSessionManager manager = serviceProvider.GetRequiredService<CaptureSessionManager>();
|
||
|
||
var session = await manager.StartAsync(new StartCaptureRequest
|
||
{
|
||
Source = CaptureSourceKind.Replay,
|
||
PcapFilePath = "C:/captures/issue-1423.pcap",
|
||
KeyLogFilePath = "C:/captures/issue-1423.uakeys.json",
|
||
SessionFolder = "C:/captures"
|
||
}, ct);
|
||
|
||
FormatResult timeline = await serviceProvider
|
||
.GetRequiredService<TraceFormatterRegistry>()
|
||
.Get(FormatKind.ServiceTimeline)
|
||
.FormatAsync(session.Source, maxFrames: null, ct);
|
||
```
|
||
|
||
### Quick start: mock-server replay
|
||
|
||
```csharp
|
||
services.AddPcap();
|
||
|
||
ReplaySessionManager replays = serviceProvider.GetRequiredService<ReplaySessionManager>();
|
||
|
||
ReplaySession replay = await replays.StartAsync(new StartReplayRequest
|
||
{
|
||
PcapFilePath = "C:/captures/server-conversation.pcap",
|
||
KeyLogFilePath = "C:/captures/server-conversation.uakeys.json",
|
||
Mode = ReplayMode.MockServer,
|
||
ListenPort = 4840
|
||
}, ct);
|
||
|
||
Console.WriteLine($"Mock server listening on {replay.ListenUri}");
|
||
```
|
||
|
||
### Security profile coverage
|
||
|
||
Every stack-supported profile can be decoded because the offline
|
||
decoder reuses `UaSCUaBinaryChannel`, `ChannelToken`, and the stack
|
||
cryptography helpers:
|
||
|
||
- `Basic128Rsa15`
|
||
- `Basic256`
|
||
- `Basic256Sha256`
|
||
- `Aes128_Sha256_RsaOaep`
|
||
- `Aes256_Sha256_RsaPss`
|
||
- `RSA_DH_AesGcm`
|
||
- `RSA_DH_ChaChaPoly`
|
||
- `ECC_nistP256`, `ECC_nistP256_AesGcm`, `ECC_nistP256_ChaChaPoly`
|
||
- `ECC_nistP384`, `ECC_nistP384_AesGcm`, `ECC_nistP384_ChaChaPoly`
|
||
- `ECC_brainpoolP256r1`, `ECC_brainpoolP256r1_AesGcm`, `ECC_brainpoolP256r1_ChaChaPoly`
|
||
- `ECC_brainpoolP384r1`, `ECC_brainpoolP384r1_AesGcm`, `ECC_brainpoolP384r1_ChaChaPoly`
|
||
- `ECC_curve25519`, `ECC_curve25519_AesGcm`, `ECC_curve25519_ChaChaPoly`
|
||
|
||
### Transport coverage
|
||
|
||
- `opc.tcp`: full capture, decode, and replay support.
|
||
- `opc.tcp` reverse connect: full support; the TCP stream is decoded
|
||
through the same Part 6 framing.
|
||
- `opc.https`: best-effort. The pcap can be captured, but decoding
|
||
encrypted TLS payloads requires an external TLS keylog such as
|
||
`SSLKEYLOGFILE`.
|
||
|
||
### File format reference
|
||
|
||
The library writes two artifacts per session: a pcap of the captured
|
||
chunks and a keylog of the activated tokens.
|
||
|
||
#### `.uakeys.json` format
|
||
|
||
`.uakeys.json` is JSON Lines (JSONL): one UTF-8 JSON object per line.
|
||
Each object describes one activated OPC UA secure-channel token.
|
||
|
||
| Field | Type | Meaning |
|
||
|---|---|---|
|
||
| `channelId` | `uint32` | OPC UA secure-channel id. |
|
||
| `tokenId` | `uint32` | Secure-channel token id used in symmetric chunks. |
|
||
| `securityPolicyUri` | `string` | OPC UA security policy URI. |
|
||
| `securityMode` | `string` | One of `None`, `Sign`, or `SignAndEncrypt`. |
|
||
| `createdAt` | `string` | Token creation time as ISO-8601 UTC. |
|
||
| `lifetimeMs` | `int` | Token lifetime in milliseconds. |
|
||
| `clientNonce` | base64 `string` | Client nonce for key derivation. |
|
||
| `serverNonce` | base64 `string` | Server nonce for key derivation. |
|
||
| `clientSigningKey` | base64 `string` | Client-to-server signing key; omitted for `None`. |
|
||
| `clientEncryptingKey` | base64 `string` | Client-to-server encryption key; omitted for `None`. |
|
||
| `clientInitializationVector` | base64 `string` | Client-to-server IV; omitted for `None`. |
|
||
| `serverSigningKey` | base64 `string` | Server-to-client signing key; omitted for `None`. |
|
||
| `serverEncryptingKey` | base64 `string` | Server-to-client encryption key; omitted for `None`. |
|
||
| `serverInitializationVector` | base64 `string` | Server-to-client IV; omitted for `None`. |
|
||
|
||
Worked example:
|
||
|
||
```json
|
||
{"channelId":1001,"tokenId":7,"securityPolicyUri":"http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256","securityMode":"SignAndEncrypt","createdAt":"2026-06-06T11:48:00Z","lifetimeMs":3600000,"clientNonce":"Y2xpZW50","serverNonce":"c2VydmVy","clientSigningKey":"MDEyMw==","clientEncryptingKey":"NDU2Nw==","clientInitializationVector":"ODlhYg==","serverSigningKey":"Y2RlZg==","serverEncryptingKey":"MDEyMw==","serverInitializationVector":"NDU2Nw=="}
|
||
```
|
||
|
||
For `SecurityMode=None`, key fields are omitted:
|
||
|
||
```json
|
||
{"channelId":1002,"tokenId":1,"securityPolicyUri":"http://opcfoundation.org/UA/SecurityPolicy#None","securityMode":"None","createdAt":"2026-06-06T11:49:00Z","lifetimeMs":3600000,"clientNonce":"","serverNonce":""}
|
||
```
|
||
|
||
#### `.uakeys.txt` format
|
||
|
||
`.uakeys.txt` is a Wireshark-style, single-line keylog format. The
|
||
file begins with this header:
|
||
|
||
```text
|
||
# OPC UA channel key log v1
|
||
```
|
||
|
||
Each record uses a single space as the field separator:
|
||
|
||
```text
|
||
OPCUA_CHANNEL <channelId-hex> <tokenId-hex> <securityPolicyUri> <securityMode> <client_signing_hex> <client_encrypting_hex> <client_iv_hex> <server_signing_hex> <server_encrypting_hex> <server_iv_hex>
|
||
```
|
||
|
||
Hex values are uppercase with no separators. `-` denotes a null or
|
||
empty field. Lines beginning with `#` are comments.
|
||
|
||
Worked example:
|
||
|
||
```text
|
||
# OPC UA channel key log v1
|
||
OPCUA_CHANNEL 000003E9 00000007 http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256 SignAndEncrypt 30313233 34353637 38396162 63646566 30313233 34353637
|
||
OPCUA_CHANNEL 000003EA 00000001 http://opcfoundation.org/UA/SecurityPolicy#None None - - - - - -
|
||
```
|
||
|
||
#### PCAP file format
|
||
|
||
Produced `.pcap` files use standard libpcap and are compatible with
|
||
Wireshark and other readers.
|
||
|
||
NIC captures preserve real link-layer and IP/TCP headers. In-process
|
||
taps write BSD-loopback records (`link_type=0`) and synthesize IP/TCP
|
||
headers around each OPC UA chunk so dissectors see TCP traffic while
|
||
the UA Secure Conversation chunk bytes remain exact.
|
||
|
||
For synthesized in-process captures:
|
||
|
||
- Client endpoint: `127.0.1.x`
|
||
- Server endpoint: `127.0.2.x`
|
||
- `x = channelId & 0xFF`
|
||
- Server port: `4840`
|
||
- Client port: `49152 + (channelId & 0x3FFF)`
|
||
|
||
The synthesized addresses are deterministic local analysis
|
||
identifiers, not real endpoints.
|
||
|
||
#### Wireshark interop
|
||
|
||
Wireshark's bundled OPC UA dissector can read the generated pcap
|
||
files and understands HEL, ACK, OPN, MSG, and CLO framing. It cannot
|
||
decrypt encrypted MSG or CLO chunks because it does not know the OPC
|
||
UA symmetric keys.
|
||
|
||
The `.uakeys.txt` format is line-oriented and Wireshark-style so a
|
||
future Lua plugin can:
|
||
|
||
1. Load `# OPC UA channel key log v1` files.
|
||
2. Index records by `channelId` and `tokenId`.
|
||
3. Match OPC UA symmetric chunks to a token id.
|
||
4. Pass the matching signing, encrypting, and IV material to the
|
||
decoder.
|
||
|
||
This only defines the future plugin format; no delivery date is
|
||
promised.
|
||
|
||
### Dependencies and governance
|
||
|
||
The packet-capture binding uses the managed packages below. NIC capture
|
||
additionally requires the native libpcap/Npcap backend installed for the host.
|
||
|
||
| Package | Role and native requirements | Upstream advisories |
|
||
|---|---|---|
|
||
| `PacketDotNet` | Managed packet parsing; not a native capture library | https://github.com/dotpcap/packetnet/security/advisories |
|
||
| `SharpPcap` | Managed capture API over the native `libpcap` / `Npcap` backend | https://github.com/dotpcap/sharppcap/security/advisories |
|
||
|
||
Both packages are pinned in `Directory.Packages.props` and tracked in the
|
||
project SBOM. Operators should maintain the selected native backend
|
||
separately; a NuGet package update does not necessarily update the installed
|
||
capture library or driver. Subscribe to advisory feeds for the managed
|
||
packages and native backend and review at least monthly. HIGH or CRITICAL
|
||
CVEs should be addressed within 30 days.
|
||
|
||
Run `dotnet list package --vulnerable --include-transitive` against
|
||
`src/Opc.Ua.Core.Diagnostics/Opc.Ua.Core.Diagnostics.csproj` in
|
||
your CI pipeline; the project does this as part of its release gate.
|
||
|
||
### Security considerations
|
||
|
||
> **Keylog files are secrets.** See [Security model](#security-model)
|
||
> at the top of this section for the full posture.
|
||
|
||
### Limitations
|
||
|
||
- The stack does not produce live TLS keylogs for `opc.https`;
|
||
provide an external TLS keylog when TLS decryption is required.
|
||
- PubSub UDP and MQTT capture are out of scope for this feature.
|
||
- A Wireshark Lua plugin is not included. The text keylog format is
|
||
documented so a plugin can be added later.
|
||
|
||
### Troubleshooting
|
||
|
||
- `list_interfaces` returns empty: install libpcap or Npcap and
|
||
ensure the process has permission to enumerate adapters. The
|
||
`linkType` field is normally `null` because enumeration does not open
|
||
adapters.
|
||
- NIC `start_capture` returns permission denied: grant packet-capture
|
||
privileges (for example `CAP_NET_RAW` on Linux) or run the process
|
||
elevated. The error is returned by `start_capture`; it is not reported
|
||
later as an empty successful capture.
|
||
- `decode_pcap_with_keys` returns 0 service calls: verify the pcap
|
||
and keylog are from the same capture and include the same channel
|
||
and token ids.
|
||
- Frames are captured but keys are missing: start the in-process
|
||
capture before opening the secure channel so token activation is
|
||
observed.
|
||
|
||
## 5. PubSub packet capture and dissection
|
||
|
||
The `OPCFoundation.NetStandard.Opc.Ua.PubSub.Diagnostics` package is the PubSub
|
||
(Part 14) counterpart of the UA-SC capture engine described in
|
||
[§4 Packet capture, dissection, and replay](#4-packet-capture-dissection-and-replay). It
|
||
captures the raw NetworkMessages exchanged over the UDP datagram and MQTT broker
|
||
transports, writes them to `.pcap` / `.pcapng` for Wireshark, and dissects them
|
||
back into structured DataSets — including **decryption of encrypted UADP
|
||
messages** when the matching security keys are available. Targets `net8.0`,
|
||
`net9.0`, `net10.0`.
|
||
|
||
PubSub is connectionless and message-secured, so it uses its own frame and
|
||
key-material abstractions rather than the UA-SC channel/token model, but reuses
|
||
the [§4](#4-packet-capture-dissection-and-replay) `.pcap` / `.pcapng` writers.
|
||
|
||
### Architecture — capturing transport decorator
|
||
|
||
Capture is implemented as a **transport decorator**, not as code inside the UDP /
|
||
MQTT transports. `AddPubSubPcap()` wraps every registered
|
||
`IPubSubTransportFactory` in a `CapturingPubSubTransportFactory`, whose
|
||
`CapturingPubSubTransport` decorates the real `IPubSubTransport`: on send and
|
||
receive it taps the raw payload to the active observer and delegates everything
|
||
else. This mirrors the UA-SC `CapturingMessageSocket` decorator — the UDP
|
||
and MQTT transports contain **no** capture code, and the decorator is inserted
|
||
only when the diagnostics package is configured via DI. When no capture session
|
||
has installed an observer, the tap is a single volatile read.
|
||
|
||
- `IPubSubCaptureObserver` receives each frame's bytes + a `PubSubCaptureContext`
|
||
(direction, transport profile, topic, timestamp).
|
||
- `IPubSubCaptureRegistry` / `PubSubCaptureRegistry` is a lock-free holder for the
|
||
active observer, shared between the decorator and the capture tooling.
|
||
|
||
> Call `AddPubSubPcap()` (or `AddPubSubPcapFromEnvironment()`) **after** the
|
||
> transport registrations (`AddUdpTransport` / `AddMqttTransport`) so the
|
||
> factories exist to be decorated.
|
||
|
||
### Capturing and dissecting
|
||
|
||
`PubSubCaptureSessionManager` owns a single in-process capture session;
|
||
`InProcessPubSubCaptureSource` buffers frames for replay.
|
||
`PubSubOfflineDissector` projects captured bytes into DataSets by reusing the
|
||
standard `UadpDecoder` / `JsonDecoder`; malformed input yields an undecodable
|
||
result rather than throwing. `PubSubPcapWriter` writes UDP frames to
|
||
`.pcap` / `.pcapng` (synthesized Ethernet/IPv4/UDP framing); MQTT payloads go to
|
||
the JSON / text formatters.
|
||
|
||
```csharp
|
||
IPubSubCaptureRegistry registry = serviceProvider
|
||
.GetRequiredService<IPubSubCaptureRegistry>();
|
||
await using var manager = new PubSubCaptureSessionManager(registry);
|
||
|
||
IPubSubCaptureSource source = await manager.StartAsync();
|
||
// ... run the publisher / subscriber ...
|
||
await manager.StopAsync();
|
||
|
||
var dissector = new PubSubOfflineDissector();
|
||
await foreach (PubSubCaptureFrame frame in source.ReadCapturedFramesAsync(null, ct))
|
||
{
|
||
PubSubDissectionResult result = await dissector.DissectAsync(frame, ct);
|
||
}
|
||
```
|
||
|
||
### Decrypting encrypted UADP
|
||
|
||
Encrypted UADP NetworkMessages
|
||
([Part 14 §8.3](https://reference.opcfoundation.org/specs/OPC-10000-14/v1.05.06/8.3),
|
||
[Annex A.2.2.5](https://reference.opcfoundation.org/specs/OPC-10000-14/v1.05.06/A.2.2.5))
|
||
carry a `SecurityTokenId` in the UADP SecurityHeader. The dissector decrypts a
|
||
secured frame when a key resolves for that token id — reusing the
|
||
production `UadpSecurityWrapper` — then dissects the recovered cleartext.
|
||
Keys come from an `IPubSubKeyResolver`: `CapturedKeyLogKeyResolver` (a captured
|
||
key log via `PubSubKeyLogReader`, or the keys buffered during capture) or
|
||
`SksKeyResolver` (a live `IPubSubSecurityKeyProvider`, e.g. a pull provider
|
||
backed by `OpcUaSecurityKeyServiceClient.GetSecurityKeys`). When no key resolves,
|
||
the result reports `Encrypted` + the token id ("key required"). JSON PubSub has
|
||
no message-level security in Part 14 (confidentiality is the MQTT TLS transport),
|
||
so JSON frames are always dissected as cleartext.
|
||
|
||
### Environment-variable auto-capture
|
||
|
||
`AddPubSubPcapFromEnvironment()` auto-starts an in-process capture and flushes it
|
||
on host shutdown:
|
||
|
||
| Variable | Effect |
|
||
| --- | --- |
|
||
| `OPCUA_PUBSUB_PCAP_FILE` | Auto-start capture; write to this `.pcap` / `.pcapng` on stop. |
|
||
|
||
### Security considerations
|
||
|
||
The captured key log contains **live security keys** in plaintext JSON-lines.
|
||
`PubSubKeyMaterial` defensively copies and zeroizes key bytes on dispose, but the
|
||
key-log file must be protected like a private key and deleted when no longer
|
||
needed. Capture is opt-in and inert until an observer is registered; decryption
|
||
is an offline diagnostic aid that reuses the production security primitives
|
||
unchanged.
|
||
|
||
## 6. Related references
|
||
|
||
- [Dependency Injection](DependencyInjection.md) — how the
|
||
stack composes its services, including telemetry, around
|
||
`IServiceCollection`.
|
||
- [Sessions](Sessions.md) — the central
|
||
`IClientChannelManager` that packet capture decorates.
|
||
- [Migration Guide](MigrationGuide.md) — previous releases used
|
||
static `Utils.SetLogger` / `Utils.Trace*` instead of
|
||
`ITelemetryContext`. The migration appendix lists every breaking
|
||
change required to adopt the telemetry context.
|
||
- [Certificate Manager](CertificateManager.md) — certificate
|
||
factories and stores all take `ITelemetryContext` on construction.
|
||
- [CryptoProvider](CryptoProvider.md) — the `opc.ua.crypto.*`
|
||
metrics and the audit events that record which cryptographic module
|
||
performed an operation, including a warning when it carries no
|
||
validation.
|