Webority.Azure.DataProtection
0.3.0
See the version list below for details.
dotnet add package Webority.Azure.DataProtection --version 0.3.0
NuGet\Install-Package Webority.Azure.DataProtection -Version 0.3.0
<PackageReference Include="Webority.Azure.DataProtection" Version="0.3.0" />
<PackageVersion Include="Webority.Azure.DataProtection" Version="0.3.0" />
<PackageReference Include="Webority.Azure.DataProtection" />
paket add Webority.Azure.DataProtection --version 0.3.0
#r "nuget: Webority.Azure.DataProtection, 0.3.0"
#:package Webority.Azure.DataProtection@0.3.0
#addin nuget:?package=Webority.Azure.DataProtection&version=0.3.0
#tool nuget:?package=Webority.Azure.DataProtection&version=0.3.0
Webority.Azure
Shared Azure infrastructure for the Webority product fleet — one canonical, optimized implementation of the Azure plumbing every product used to hand-copy (and drift on).
Package IDs stay Webority.Azure.* for a tidy nuget.org grouping, but the C# namespaces are
deliberately Webority.AzureStorage / Webority.AzureTelemetry / Webority.AzureDataProtection /
Webority.AzureNotificationHubs — not Webority.Azure.*. A Webority.Azure.* namespace would
shadow the Azure SDK's own Azure root namespace for any consumer code living under a
Webority.* namespace, since C# walks enclosing namespaces and finds Webority.Azure before it
ever reaches global Azure.Core/Azure.Identity.
| Package | What it gives a product |
|---|---|
Webority.Azure.Storage |
Blob / Queue / Table services: singleton cached clients, streaming downloads (DownloadStreamingAsync), seekable OpenReadAsync for range-resumable large downloads, resumable chunked block upload (caller-supplied block ids shareable with a browser client, plus GetStagedBlockIdsAsync to resume an interrupted upload) with content type on commit, HTTPS-only read and write SAS for both auth models (user-delegation key cached), Base64 queues with known-queue cache, Table service + IDistributedCache implementation. |
Webority.Azure.DataProtection |
One-call ASP.NET Core DataProtection key persistence to blob storage. |
Webority.Azure.Telemetry |
One-call OpenTelemetry → Azure Monitor bootstrap (connection string, always-on head sampling, failed-only span filtering with successful requests sampled after the fact, export-level log floor) replacing the per-head copied block. See "Telemetry collection policy" below. |
Webority.Azure.NotificationHubs |
Push notifications (FCM v1 + APNS) over Azure Notification Hubs with typed payload building. |
Auth model (Storage)
One options contract, two modes — set exactly one:
Azure:Storage:StorageAccountName→ Entra / managed identity via a narrowedDefaultAzureCredentialchain (ManagedIdentity in Azure, Azure CLI locally — nothing else, so no slow credential probing on constrained plans). One cached credential per process.Azure:Storage:ConnectionString→ shared key (the local-development default: developers without Azure access use the staging account's connection string).
Conventions
- All services register as singletons (Azure SDK clients own their transport and are built
for process-wide reuse — see
dotnet.md, HttpClient/SDK-client rule). - Options are validated on start (
ValidateDataAnnotations().ValidateOnStart()); a misconfigured host crashes at boot, never at first request. - Public type names match the pre-extraction fleet implementations (
IAzureStorageBlobService, …), so migrating a product is ausingswap plus deleting its local copy.
Telemetry collection policy
AddWeborityTelemetry's fleet defaults implement one rule: collect only what is consumed.
Application Insights ingestion is billed, and near nobody looks at the Metrics blade or a
complete log of every successful outbound call — so none of that ships to Azure Monitor unless a
head opts in.
- No metrics export by default (
EnableMetrics = false). This suppresses the distro's standard-metrics/performance-counter pipeline (AzureMonitorOptions.EnableStandardMetrics/EnablePerformanceCounters, bothtrueby default upstream) and the ASP.NET Core/HttpClient runtime metersUseAzureMonitorwires in on .NET 8+, via a catch-all dropping view. SetEnableMetrics = trueto restore metrics fleet-wide;DroppedMetricsthen trims just the known-noisy subset instead of everything. - Failed-only spans, sampled successes. Failed spans (
Status == Error, or any span carrying a recorded exception event) are always kept, and so is any span answering an HTTP status of 400 or above. ASP.NET Core leaves a 4xx atUnsetby semantic convention, so without that rule a refusal a caller saw was sampled like a success and three in four never arrived, and a 5xx recorded by instrumentation that sets no span status went the same way. Successful dependency/client spans are always dropped. Successful server/request spans are kept with probabilitySuccessfulRequestSamplingRatio(default0.25), decided deterministically per trace so every span belonging to one trace agrees. - "Always kept" is literal, because head sampling is switched off. A head sampler decides at
span start, before a status code, a response or an exception exists, so it cannot tell a failure
from a success and nothing downstream can bring back a trace it dropped. The package therefore
pins the exporter to always-on (
SamplingRatioto1.0andTracesPerSecondto null, both needed because the distro defaults the second to5.0and lets it win) and makes every trim after the span ends, where the status is known.SuccessfulRequestSamplingRatiois the one ratio the package has.- The cost, stated plainly. Every span is created and fully recorded before it can be judged, which is CPU and allocation a head sampler would have saved. Application Insights also stops extrapolating counts: the exporter stamps one sample rate on every item, taken from its own head ratio, and reads no per-span rate, so successful request counts read as the kept fraction while failure counts are exact. Read a fall in successful request volume after upgrading as this, not as lost traffic.
- The one hole the package cannot close: do not set
OTEL_TRACES_SAMPLERorOTEL_TRACES_SAMPLER_ARGon a Webority head. The exporter reads both from the environment and either one installs a head sampler underneath the package, which puts back exactly the trace loss the pin above removes, silently and with no signal in the app's own config. Nothing in the fleet sets them today.
- Log export floors at Warning (
MinimumLogExportLevel). This filters only whatOpenTelemetryLoggerProviderforwards to Azure Monitor — a head's ownILoggercalls, and any other registered provider (console, debug, ...), are unaffected.
These are all options on WeborityTelemetryOptions, overridable per head via the configure
delegate or the WeborityTelemetry configuration section — this is the fleet default, not a
hard rule.
The distro's own AzureMonitor configuration section is not a supported surface here. The
package calls the UseAzureMonitor(Action<AzureMonitorOptions>) overload, and only the
parameterless overload registers the internal options type that reads that section, so a head
putting an AzureMonitor block in its appsettings finds it silently inert. That is deliberate
rather than an oversight: the section carries SamplingRatio and TracesPerSecond, so honouring
it would hand a head back the head sampler this package pins off. Configure the exporter through
the configure delegate.
Upgrading
Breaking on
0.x:WeborityTelemetry:SamplingRatiois retired, and a configuration that still carries it is refused at startup. It set the exporter's head sampler, which decides at span start and so dropped failed requests, 4xx refusals and unhandled exceptions at the same rate as successes: the keep-every-failure rules beside it could only ever trim what it had already let through. Head sampling is pinned to always-on now, andSuccessfulRequestSamplingRatio(default0.25) is the one ratio, applied to successful requests only. Remove the key from each head'sappsettings, and move its value toSuccessfulRequestSamplingRatioonly if that head really wants a different success ratio. The refusal is deliberate: binding would ignore the key silently and leave a head reading its own config as a decision nothing applies. It fires before theEnabledcheck as well, so a head that has switched telemetry off is told about the stale key too, rather than finding out the first time it switches telemetry back on.The distro's rate-limited head sampler is switched off too.
AzureMonitorOptions.TracesPerSeconddefaults to5.0and takes precedence overSamplingRatio, so a busy head was capped at five traces a second whatever ratio it set, with failures discarded above that line. The package sets it to null.Expect more Warning-and-above logs to reach Azure Monitor, not just more traces. The distro's trace-based logs sampler is on by default and exports a log record only when its trace was sampled, so while the rate limiter was capping traces it was quietly suppressing those logs too. With head sampling pinned always-on, every record at or above
MinimumLogExportLevelnow exports. That is the intended direction, and on a busy head it is a real rise in billed ingestion: check the component's daily cap before upgrading.A 5xx with no span status is kept like a 4xx. The rule read 400 to 499; it reads 400 and above now, so a server error recorded by instrumentation that leaves the span status
Unsetis no longer sampled like a success.Breaking on
0.x:SlidingExpirationis refused instead of silently written as a fixed window. A row carries one absolute expiry and a read cannot extend it, so an entry asked to slide never slid: the caller asked for one thing and got another, with nothing said.SetandSetAsyncnow throwArgumentExceptionwheneverSlidingExpirationis set, including when an absolute window sits beside it. UseAbsoluteExpirationRelativeToNowand write the entry again when it should live longer. A head callingAddSession()against this cache is the one to check before upgrading:DistributedSessionsetsSlidingExpirationon every commit.A cache entry shorter than a second is written with a one-second life, not a zero-second one. The relative and sliding branches truncated the window to whole seconds, so anything under a second became a row that had already expired when it was written and every later read missed. The absolute branch already floored at one second; all of them do now.
The cache table has one default name,
DistributedCache. The option's own default saidPermissionCachewhile the cache fell back toDistributedCachewhen noTablesection was configured, so adding"Table": {}to a head's config moved the whole cache to another table and emptied it. The surviving value is the one an unconfigured head already resolves, so nothing in a deployed head changes. A head that configuredAzure:Storage:Table:CacheTableNameexplicitly is unaffected either way; one that relied on the option default while binding an empty section will now read the table it was already writing to.A table dropped after the first write is recreated instead of failing forever. The service remembers that it created a table so it stops paying for a create on every write, and that memory outlived the table: drop it by hand, from a cleanup job, or between deployments, and every later write failed for the life of the process. A 404 from a write now forgets the table, creates it and retries once. The failed first attempt is still reported as a failure, because a table disappearing under a running head is worth seeing.
Table cache rows written by an earlier version are treated as expired and rewritten. The cache stores its TTL in
ExpiresDateTimeUtc(previouslyExpiresAtUtc) and its write time inCachedDateTimeUtc(previouslyCachedAtUtc). A row whose expiry cannot be read — column absent, null, or unparseable — is a miss, never a never-expiring hit, so pre-existing rows simply miss once and are rewritten. No action needed, and no dual-read shim: reading both names would restore exactly the ambiguity that let a stale short-TTL entry validate forever.A table cache miss is quiet. The cache reads with the not-found-tolerant overload, so a missing row no longer throws inside the SDK, logs an error trace, or exports as a failed dependency. Products that count failed dependencies will see the revocation-check misses disappear from that stream. Writes are unchanged.
A table cache delete of a row that is already gone is quiet too. The SDK never threw on that 404, so the catch around the delete was doing nothing, but the request still carried no response classifier and the pipeline called every such delete a failure. A per-call policy now classifies 204 and 404 as the success pair for a DELETE, so a sign out racing a TTL, a second instance evicting the same key, or a replayed webhook stops producing an error trace and a failed dependency. A delete that fails for any other reason still throws.
Breaking on
0.x:IAzureStorageTableServicegained three synchronous members,GetValue,SetValueandRemoveValue.AzureTableDistributedCacheneeds them becauseIDistributedCachehas a synchronous half, and its synchronous members used to block on the asynchronous ones, parking a thread pool thread for each round trip. Consuming products need no change; anything that implements the interface itself, a hand-written test double above all, will not compile until it adds the three.A missing blob is quiet on the three read paths; a missing container is now loud.
GetAsync,OpenReadAsyncandGetStagedBlockIdsAsyncalready answered a missing blob with null or an empty list, but the pipeline underneath called the 404 a failure, so each ordinary miss wrote an error event and exported a failed dependency. Reads now treat a 404 as a non-failure only when the service saysBlobNotFound. The SDK still throws either way, because it decides that from the status code rather than from the classifier, so a missing blob behaves exactly as before.Breaking on
0.x: a read only absorbsBlobNotFoundnow, and throws on any other 404. A missing container answers 404 like a missing blob, and all three read paths used to absorb it and answer null or empty, so a typo in a container name was indistinguishable from an absent file.ContainerNotFoundis misconfiguration, not a miss, and a 404 carrying any other code is a case the service does not understand, which is the one that must least become an empty result. Both are let out now, logged by the pipeline as the failures they are. A product that relied on a read of a non-existent container answering empty will start seeingRequestFailedException. Writes are untouched, and still create the container on first use.The existence check is the exception, by SDK design.
ExistsAsyncanswersfalsefor a missing container as well as a missing blob, becauseBlobBaseClient.ExistsAsyncabsorbs that 404 inside the SDK before our code sees it. The three read paths are the loud ones; do not read afalsefrom the existence check as proof that the container is right.Blob containers are created on first write.
SaveAsyncand the block-staging paths create the resolved container (private,PublicAccessType.None) once per container name per process, matching the queue and table services. Products can drop their own create-if-missing call before every upload. Read paths still fail on a missing container, so a wrong container name surfaces instead of being silently created.
Releasing
Version lives once in Directory.Build.targets (<Version>), in targets and not props because
props is imported before the csproj body, where the per-project IsPackable the version block is
conditioned on is not yet set. Both workflows read it from there. Push to main runs tests, packs
all four packages, and publishes to public nuget.org (.github/workflows/publish.yml).
Work happens on development; a release is a PR development → main merged with a merge
commit, per the fleet git conventions.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Azure.Extensions.AspNetCore.DataProtection.Blobs (>= 1.5.3)
- Microsoft.AspNetCore.DataProtection (>= 10.0.10)
- Microsoft.Extensions.Configuration.Abstractions (>= 10.0.10)
- Webority.Azure.Storage (>= 0.3.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.10.0 | 36 | 10/4/2026 |
| 0.9.0 | 36 | 10/4/2026 |
| 0.8.0 | 47 | 10/3/2026 |
| 0.7.0 | 81 | 9/27/2026 |
| 0.6.0 | 90 | 9/25/2026 |
| 0.5.1 | 99 | 9/25/2026 |
| 0.5.0 | 87 | 9/22/2026 |
| 0.4.0 | 90 | 9/22/2026 |
| 0.3.1 | 99 | 9/22/2026 |
| 0.3.0 | 98 | 9/15/2026 |
| 0.2.0 | 96 | 9/13/2026 |
| 0.1.4 | 125 | 8/12/2026 |
| 0.1.3 | 112 | 8/12/2026 |
| 0.1.2 | 109 | 8/12/2026 |
| 0.1.1 | 113 | 8/12/2026 |
| 0.1.0 | 113 | 8/12/2026 |