mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-21 09:06:37 +08:00
c4e0721a29
## Summary
### Documentation
- Stage the documentation index as a learning path: a first application,
independent client, server, and PubSub paths, then data and services,
companion models, production, advanced integration, and draft or
experimental capabilities.
- Add Getting started (repository samples or NuGet packages), OPC UA
concepts, Your first information model, and a production readiness
checklist.
- Add Native AOT application publishing, a first PubSub publisher and
subscriber exchange, and the UDP transport options.
- Add or expand contents navigation throughout `docs/`, and keep
contributor documentation separate.
- Correct outdated SDK examples and capability guidance, consolidate
repeated explanations into canonical guides, and repair local links and
heading structure.
- Rewrite What's New in 2.0 as a concise feature overview with short
linked details; keep migration instructions in the Migration Guide.
- Expand the feature catalogue with specification IDs, GDS, LDS, the OPC
10000-21 onboarding registrar, ISA-95, xRegistry, OpenUSD, and
sample-local models; distinguish draft status, explain crypto
pluggability, and bring performance guidance forward.
- Address review feedback on wording, active monitored items during
shadow reload, and the `Samples.md` filename and references.
- Regenerate the `opcua-v20-migration` plugin's bundled copy of the
migration guides so it matches the edited `docs/migrate/2.0.x` guides.
- Merge the latest `master`, preserving upstream resource-isolation
guidance within the task-based documentation index.
- Retain deferred Lingity findings where appropriate; this PR does not
claim that every document meets the Lingity score thresholds.
### Fixes found while verifying the guides
Each fix has regression tests that fail without it.
- **Client:** V2 subscriptions compared their lifetime with the
operation timeout instead of the session timeout, and logged a spurious
warning. `ISubscriptionEngineContext`, which is new in 2.0, gains a
`SessionTimeout` property; implementations must add it.
- **PubSub:** a subscriber now registers the `DataSetMetaData`
configured on its readers (OPC 10000-14 6.2.9.4), and the UADP decoder
decodes DataSetMessages that carry no ConfigurationVersion. Before, a
secured subscriber of the reference sample received no DataSets.
- **Source generator:** a ModelDesign method that declares its arguments
inline, without a method type, produced code that did not compile; it
now uses `MethodState`. The generated code of existing models is
unchanged.
- **MinimalClient sample:** waits for its first data-change notification
before it disconnects.
### Known issues, not addressed here
- UDP multicast: the publisher does not send on the interface that the
subscriber joins, and `MulticastLoopback` defaults to false. The PubSub
guide uses unicast for the exchange on one computer.
- The UDP metadata announcement is sent unsecured on secured writer
groups; a secured subscriber drops it with a warning.
- A server does not start in globalization-invariant mode, because
`ResourceManager` creates `CultureInfo("en-US")`. The Native AOT guide
cautions against the mode.
## Validation
- `git diff --check` and
`./.azurepipelines/validate-migration-plugin.ps1` (15 bundled migration
docs validated, plugin manifests synchronized).
- Local Markdown links, anchors, file-link casing, and contents lists
checked across all 87 documents under `docs/`. The index links every
top-level guide and every target of the previous index.
- Both Getting started routes, Your first information model, the PubSub
exchange, and the Native AOT publish of the Getting started client and
server were run as documented. The NuGet route and the tutorial use the
public `2.0.0-preview.5` packages.
- The changed libraries build for all target frameworks in Release
without warnings.
- Tests on .NET 10: Opc.Ua.SourceGeneration.Tests 199 passed;
Opc.Ua.SourceGeneration.Core.Tests 3,977 passed, 8 skipped;
Opc.Ua.Server.Tests `Fluent` category 621 passed; Opc.Ua.Client.Tests
subscription-engine tests 70 passed; Opc.Ua.PubSub.Tests 1,374 passed, 2
skipped; PubSub fuzz replay 145 passed. The new and affected tests also
pass on .NET Framework 4.8.
- Earlier merge validation: 174 resource-isolation tests passed on .NET
10.
- The full solution suite was not run locally.
Review and validation artifacts are maintained outside the repository.
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1567 lines
68 KiB
Markdown
1567 lines
68 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.
|
||
|
||
- [1. Telemetry context (`ITelemetryContext`)](#1-telemetry-context-itelemetrycontext)
|
||
- [2. OPC UA server audit events](#2-opc-ua-server-audit-events)
|
||
- [3. OPC UA server built-in diagnostics nodes](#3-opc-ua-server-built-in-diagnostics-nodes)
|
||
- [4. Packet capture, dissection, and replay](#4-packet-capture-dissection-and-replay)
|
||
- [5. PubSub packet capture and dissection](#5-pubsub-packet-capture-and-dissection)
|
||
- [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 genuinely 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).
|
||
|
||
### 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 genuinely
|
||
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. The spec-defined tree is what tooling such as
|
||
UaExpert relies on.
|
||
|
||
### 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`; the OPC UA
|
||
MCP server exposes it as capture, decode, and replay tools.
|
||
Decoding follows OPC UA Part 6 secure conversation framing and reuses
|
||
the stack `UaSCUaBinaryChannel` path instead of reimplementing
|
||
cryptography.
|
||
|
||
### 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 (`start_capture`) | available | `PcapBindings.Install()` / `InstallClient()` / `InstallServer()`, or `AddPcap()` |
|
||
| Keylog extraction | available when capture runs | implicit when capture is active |
|
||
| MCP `dump_keys` / `decode_pcap_with_keys` / `replay_pcap` | **off** | `PcapOptions.EnableDiagnosticsTools = true` or env `OPCUA_PCAP_ENABLE_DIAGNOSTICS=1` |
|
||
| 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>` |
|
||
|
||
When any diagnostic tool is enabled the host emits a `Warning`-level
|
||
log line at startup so the choice is observable in production logs.
|
||
|
||
#### 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`.
|
||
|
||
Every MCP tool that accepts a caller-supplied capture or key-log path
|
||
(`decode_pcap_with_keys`, `summarize_service_calls`, `replay_pcap`,
|
||
`pubsub_decode_pcap`, `pubsub_dissect_capture`, `pubsub_load_keylog`,
|
||
`pubsub_write_pcap`) confines it to this folder through
|
||
`McpCapturePath.ResolveAndValidate`, so a path that resolves outside the
|
||
root is rejected rather than read. The root is resolved with the
|
||
precedence `OpcUaMcpOptions.PcapBaseFolder` → `PcapOptions.BaseFolder` →
|
||
the per-user default above.
|
||
|
||
#### 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 `EnableDiagnosticsTools` is set explicitly (not defaulted)
|
||
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` and `PacketDotNet`
|
||
(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 service account with no shell
|
||
access** so a compromise can't pivot. The keylog files are
|
||
already mode `0600` and confined to the per-user data
|
||
directory; the service account boundary is the missing piece.
|
||
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` and
|
||
`PacketDotNet` are unmanaged native loads (see the **Dependencies
|
||
and governance** section). Subscribe to upstream CVE feeds and bump
|
||
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.
|
||
|
||
Three properties of the channel manager flow through to capture for
|
||
free:
|
||
|
||
- **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.
|
||
|
||
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.
|
||
|
||
There are **two independent steps**, and forgetting the second is the
|
||
most common reason "nothing is captured":
|
||
|
||
1. **Install a binding** so the affected channels become
|
||
capture-*aware*. This alone records nothing.
|
||
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.SessionId, 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.SessionId, ct);
|
||
|
||
var bytes = await manager.GetCaptureAsync(
|
||
session.SessionId,
|
||
FormatKind.ServiceTimeline,
|
||
ct);
|
||
```
|
||
|
||
### 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);
|
||
|
||
var timeline = await manager.GetCaptureAsync(
|
||
session.SessionId,
|
||
FormatKind.ServiceTimeline,
|
||
ct);
|
||
```
|
||
|
||
### Quick start: mock-server replay
|
||
|
||
```csharp
|
||
services.AddPcap();
|
||
|
||
CaptureSessionManager manager = serviceProvider.GetRequiredService<CaptureSessionManager>();
|
||
|
||
var replay = await manager.ReplayAsync(new ReplayPcapRequest
|
||
{
|
||
PcapFilePath = "C:/captures/server-conversation.pcap",
|
||
KeyLogFilePath = "C:/captures/server-conversation.uakeys.json",
|
||
Mode = ReplayMode.MockServer,
|
||
ListenEndpointUrl = "opc.tcp://localhost:4840/CapturedServer"
|
||
}, ct);
|
||
```
|
||
|
||
### 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 has two native-code transitive
|
||
dependencies:
|
||
|
||
| Package | Native binding | Upstream advisories |
|
||
|---|---|---|
|
||
| `PacketDotNet` | Wireshark-style packet decode | https://github.com/dotpcap/packetnet/security/advisories |
|
||
| `SharpPcap` | `libpcap` / `Npcap` capture | https://github.com/dotpcap/sharppcap/security/advisories |
|
||
|
||
Both are pinned in `Directory.Packages.props` and tracked in the
|
||
project SBOM. Operators deploying this binding should subscribe to
|
||
the upstream advisory feeds 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. |
|
||
|
||
### MCP tools
|
||
|
||
The reference MCP server exposes the PubSub surface as tools — Action /
|
||
configuration, Security Key Service, in-process publish/subscribe runtime, and
|
||
capture / dissection. See [McpServer.md](McpServer.md#pubsub-tools) for the full
|
||
catalogue.
|
||
|
||
### 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.
|
||
- [MCP Server](McpServer.md) — surfaces the packet-capture
|
||
tools (`start_capture`, `dump_keys`, `decode_pcap_with_keys`,
|
||
`replay_pcap`) over JSON-RPC for LLM-driven workflows.
|
||
- [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.
|