Files
6fe5399510 Add UaLens desktop engineering workspace (#4822)
# 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>
2026-10-21 17:18:19 +02:00

1710 lines
76 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.