# HistorianGateway backend — issues identified **Date:** 2026-06-27 **Context:** Installed the `ZB.MOM.WW.HistorianGateway` sidecar on **wonder-app-vd03** and updated OtOpcUa to `origin/master` (`245316d8`, PR #423 "HistorianGateway as the OtOpcUa historian backend"), then smoke-tested the OtOpcUa→gateway link live. These are the issues found during that work. > **File/line references are against `origin/master` @ `245316d8`** (the build that contains the gateway > backend — `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/`). They will not resolve on older > local checkouts that predate PR #423. > **Live evidence** came from the gateway's Prometheus `/metrics` on the box: an OPC UA `historyread` on a > historized tag produced `historian_gateway_grpc_requests_total{method="ReadRaw",service="HistorianRead"}=1` > and `historian_session_pool_warm_sessions=1`, while a historized-tag **deploy** produced **zero** > `EnsureTags` calls. --- ## Issue 1 — `GatewayTagProvisioner` is never wired; EnsureTags auto-provisioning is dormant (HIGH, confirmed) **Summary.** PR #423 ships `GatewayTagProvisioner` (the `IHistorianProvisioning` implementation that calls the gateway's `EnsureTags`) and unit tests for it, but it is **never registered in DI and never passed into the `AddressSpaceApplier`**. At runtime the applier uses the no-op `NullHistorianProvisioning`, so **deploying historized tags does NOT provision them in the historian**. The documented "EnsureTags auto-provisions historian tags on deploy" behaviour does not occur. **Root cause (code).** - `AddressSpaceApplier` constructor coalesces a missing provisioner to the no-op: - `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs:63` `_provisioning = provisioning ?? NullHistorianProvisioning.Instance;` - The **only** construction site omits the `provisioning:` argument: - `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs:258` ```csharp var applier = new AddressSpaceApplier( addressSpaceSink, loggerFactory.CreateLogger(), historizedSubscriptions: historizedSubscriptions); // <-- no provisioning: ``` - **No `IHistorianProvisioning` / `GatewayTagProvisioner` registration exists anywhere** in the repo (`grep` over `origin/master` finds only the interface definition, `NullHistorianProvisioning`, and the `GatewayTagProvisioner` constructor definition itself — no `AddSingleton` and no `new GatewayTagProvisioner(…)`). - Consequently `ProvisionHistorizedTags` (called unconditionally at `AddressSpaceApplier.cs:208`, body at `:226`, dispatch at `:261`) builds the request list and invokes `_provisioning.EnsureTagsAsync(...)` on the **no-op**, which returns without contacting the gateway. **Live evidence.** On wonder-app-vd03: flagged the `parts-count` equipment tag `isHistorized:true`, deployed, and restarted the host. The gateway `/metrics` showed **no** gRPC request series for `HistorianTags`/`EnsureTags` (zero gateway calls). By contrast, an explicit OPC UA `historyread` on the same tag *did* reach the gateway (`ReadRaw`), confirming the read path is wired but the provisioning path is not. **Impact.** - Historized tags are never created in the historian on deploy. - When **continuous historization** (`ContinuousHistorization:Enabled=true`) is later turned on, the recorder drains live values to historian tags that were never provisioned — those writes will fail / land nowhere unless the tags are created by some other means. - Docs (`docs/Historian.md`, repo `CLAUDE.md`, and the umbrella scadaproj `CLAUDE.md`) describe provisioning as active; that claim is currently false. See Issue 4. **Recommended fix (two parts).** 1. **Register the provisioner** in the Host alongside the existing gateway wiring — in `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs`, inside the `if (serverHistorianOptions.Enabled)` block that already calls `AddServerHistorian` / `AddAlarmHistorian` — e.g. ```csharp builder.Services.AddSingleton(sp => new GatewayTagProvisioner( HistorianGatewayClientAdapter.Create(serverHistorianOptions, sp.GetRequiredService()), sp.GetRequiredService>())); ``` (mirror the `GatewayHistorianValueWriter` registration pattern already in that block). 2. **Pass it into the applier** at `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs:258` by resolving it the same way sibling deps are resolved there (`resolver.GetService<…>()`): ```csharp var provisioning = resolver.GetService(); var applier = new AddressSpaceApplier( addressSpaceSink, loggerFactory.CreateLogger(), provisioning: provisioning, historizedSubscriptions: historizedSubscriptions); ``` 3. **Add a wiring/integration test** asserting `EnsureTags` is invoked on a historized-tag deploy. The existing `GatewayTagProvisioner` unit tests pass but do not cover the DI wiring, which is exactly the gap that let this ship dormant. --- ## Issue 2 — Provisioning success is logged nothing; the dormant path is invisible (LOW, observability) **Summary.** The provisioning continuation in `AddressSpaceApplier` only emits a log line when `result.Failed > 0 || result.Skipped > 0`. A fully-successful provisioning (`Ensured>0, Failed=0, Skipped=0`) **and** the no-op `NullHistorianProvisioning` path both log **nothing**. This is what made Issue 1 invisible: there was no log line either way, so "no provisioning log" could not be distinguished from "provisioning ran and fully succeeded." **Location.** `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs` — the `EnsureTagsAsync(...).ContinueWith(...)` block (~lines 261–283); the Information log is gated behind `if (result.Failed > 0 || result.Skipped > 0)`. **Recommended fix.** Emit an Information tally on every dispatch (e.g. `requested/ensured/skipped/failed`), or at minimum whenever `Ensured > 0`, so a successful provisioning is observable and a no-op is detectable. --- ## Issue 3 — HistoryRead on a historized-but-unprovisioned tag blocks for the full CallTimeout (~30s) (MEDIUM, needs investigation) **Summary.** An OPC UA `historyread` on a tag that is historized but has no corresponding historian tag/data returned only after ~30 s — the gateway's `ReadRaw` recorded `historian_gateway_grpc_request_duration_seconds_sum ≈ 30.5` for the single call, matching the `ServerHistorian:CallTimeout` default of `00:00:30`. The read did not fail fast on an unknown tag; it ran to the timeout and then errored (`outcome="error"`). **Notes / attribution.** - The slow leg is the gateway→historian read of a non-existent tag; the OtOpcUa-facing symptom is a 30 s OPC UA HistoryRead latency governed by `ServerHistorianOptions.CallTimeout` (default 30 s, `…Runtime/Historian/ServerHistorianOptions.cs`). - This is **coupled to Issue 1**: because provisioning never runs, freshly-historized tags don't exist in the historian, so every HistoryRead on them hits this slow path until tags/data exist. - Single data point (one unprovisioned tag); confirm whether the slow path is the historian, the gateway, or the client-side timeout before changing behaviour. **Recommended action.** Primarily fix Issue 1 so historian tags exist. Separately, consider a shorter default HistoryRead timeout and/or fast-fail when the historian reports an unknown tag, so a missing tag doesn't hang an OPC UA HistoryRead for 30 s. --- ## Issue 4 — Docs describe provisioning as active (LOW, documentation) `docs/Historian.md`, the repo `CLAUDE.md`, and the umbrella `scadaproj/CLAUDE.md` state that the gateway backend "auto-provisions historian tags on deploy (EnsureTags)". Given Issue 1, that is inaccurate in the shipped build. Update the docs once Issue 1 is fixed, or flag the provisioning behaviour as not-yet-wired in the interim. --- ## Issue 5 — Provisioning covers only *newly-added* tags; flagging an existing tag for historization never provisions it (MEDIUM, follow-up to the Issue 1 fix) **Status:** found while live-verifying the Issue 1 fix (commit `257214f7`) on wonder-app-vd03. **Summary.** The Issue 1 fix correctly wires `GatewayTagProvisioner` and is confirmed deployed (the new `"...provisioning completed (dispatched…"` log literal is present in the deployed `OpcUaServer.dll`). However, `ProvisionHistorizedTags` still iterates **only** `plan.AddedEquipmentTags`: - `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs` (`ProvisionHistorizedTags`): `foreach (var tag in plan.AddedEquipmentTags)` — `ChangedEquipmentTags` is **not** considered. So when an operator flags an **existing** tag `isHistorized:true` and deploys, that tag is classified **Changed** (its config changed; the tag already existed) and is **never provisioned**. This is compounded by the bootstrap "restore served state" behaviour: a host restart re-applies the served address space **incrementally**, so an existing tag comes back as *unchanged/changed*, never *Added* — i.e. a restart does not provision it either. **Live evidence.** Flagging `parts-count` (an existing tag) historized + deploy + restart produced **zero** `EnsureTags` calls at the gateway (`/metrics` had no `HistorianTags/EnsureTags` series). The read path on the same tag *did* reach the gateway (`HistorianRead/ReadRaw`), confirming the tag was historized and the gateway link works — only provisioning was skipped. (A raw `dbo.Tag` INSERT of a brand-new historized tag did not materialise either, because a raw insert bypasses the normal compose/validate path — so the clean way to exercise the *added* path is to author a new historized tag via the AdminUI.) **Impact.** The most common operator action — take an existing tag and turn on historization — does **not** auto-provision the historian tag. Continuous historization for such a tag would then write to a tag that does not exist. Only genuinely **new** historized tags get provisioned. **Recommended fix.** Provision the union of added **and** newly-historized changed tags: also iterate `plan.ChangedEquipmentTags` (or compute "tags whose `IsHistorized` transitioned false→true") and include them in the `EnsureTags` request. `EnsureTags` is idempotent, so re-ensuring an already-present tag is safe. Add a test that flips an existing tag to historized and asserts `EnsureTags` is invoked. --- ## Pre-existing observation (not introduced by this work) - **Serilog log path is service-CWD-relative.** On wonder-app-vd03 the host writes to `C:\Windows\System32\logs\otopcua-.log` (because the service CWD is `System32`), not under the install directory. This was noted in prior sessions; re-observed here. Not a gateway-integration issue, but it makes on-box diagnosis awkward and is worth fixing (anchor the Serilog file sink to an absolute/content-root path). --- ## What is working (for context) The OtOpcUa↔gateway **read and credential path is confirmed working end-to-end** on wonder-app-vd03: an OPC UA HistoryRead routed through `GatewayHistorianDataSource` to the gateway over h2c, authenticated with a peppered-HMAC API key (`historian:read` scope), and leased a real historian session (the gateway reached wonder-sql-vd03). Only the **provisioning** half (Issue 1) is unwired.