Webority.Analytics 0.19.0

There is a newer version of this package available.
See the version list below for details.
dotnet add package Webority.Analytics --version 0.19.0
                    
NuGet\Install-Package Webority.Analytics -Version 0.19.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Webority.Analytics" Version="0.19.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Webority.Analytics" Version="0.19.0" />
                    
Directory.Packages.props
<PackageReference Include="Webority.Analytics" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Webority.Analytics --version 0.19.0
                    
#r "nuget: Webority.Analytics, 0.19.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Webority.Analytics@0.19.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Webority.Analytics&version=0.19.0
                    
Install as a Cake Addin
#tool nuget:?package=Webority.Analytics&version=0.19.0
                    
Install as a Cake Tool

webority-analytics

The fleet's product-analytics instrumentation layer (EPIC-00297, project 43). One concern, one repo, two published packages (hybrid npm + NuGet, mirroring webority-ui):

Package Registry What
@webority/analytics (analytics-js/) npmjs.org Browser SDK. PostHog wrapped once: consent-gated init, PublicId-only identify, the locked event vocabulary as typed helpers, registerEvents guard (an unregistered name is dropped with a one-time warning, never sent)
Webority.Analytics (Webority.Analytics/) nuget.org Server SDK. Options-bound (Analytics section, IsConfigured degrade), channel-buffered background PostHog /batch/ sender via IHttpClientFactory, IFeedbackRelay to the OS feedback-ingest endpoint

Group analytics (a paid PostHog add-on, so enable it in the PostHog project before relying on it): the browser exposes group(type, key, props), gated on analytics consent like identify. On the server, IAnalyticsClient.Identify sends $identify with $set, IAnalyticsClient.Group sends $groupidentify, and the Track overload with a groups argument attributes an event to groups through $groups. Use public ids for both person and group keys, never internal ids.

initAnalytics takes host, clientKey, product, surface, environment, and optionally enabled, logger, sessionRecording, heatmaps, captureExceptions and webVitals. initConsent must run first, or initAnalytics throws: the visitor's analytics consent is the only thing that switches PostHog between its two modes: with consent it persists the visitor as usual; without it PostHog still counts the visit, but cookieless and anonymous (cookieless_mode: "on_reject", no person profile, no cookie or storage), so enable cookieless mode in the PostHog project. identify and group wait for consent: the last identify made earlier is remembered and applied when consent arrives (resetIdentity forgets it). Page views follow history changes, so a single-page app reports each route. (Breaking in 0.5.0: the getConsent option and setConsent(bool) are gone. A portal adds initConsent({ requiresOptIn, cookieDomain }) ahead of initAnalytics.) Session replay is OFF unless a product passes sessionRecording: { enabled: true }; maskAllInputs (default true) and maskTextSelector ride along only when it is on. Turning replay on is a disclosed product decision (it records page content), so the package never defaults it. heatmaps and captureExceptions are off unless passed too, so a PostHog project setting alone never starts them. webVitals: true sends PostHog web vitals (LCP, CLS, FCP and INP) as $web_vitals events; it is off unless passed, and network timing for replay stays with the project setting; without consent they are sent cookieless like any event, and replay records only with consent. Replayed addresses, network requests and heatmap page keys get the same secret scrub as events.

Sending an event name that was never registered never throws. It is warned about once per name and dropped, matching IAnalyticsClient.Track on the server half, which also logs and drops rather than throwing. The warning goes to the logger passed to initAnalytics, preferring its warn method and falling back to info; with no logger it goes to the browser console, so the mistake is visible by default. The set of already-warned names is capped at 50. registerEvents still throws on a name that is not snake_case: that is a startup declaration, not a send in a user's click handler.

The server sender attempts a batch at most three times, pausing 1s and then 2s between attempts, when the failure is one that passes on its own: a network error, a timeout, or status 408, 429 or 5xx. Every other 4xx and anything thrown before a request exists is dropped at once, because the same request would fail the same way. On a 429 or a 503, a Retry-After header replaces the fixed pause, in either the delta-seconds or the HTTP-date form. It is never obeyed below the pause it replaces, so a zero cannot start a tight loop, and never above 30 seconds, so a bad value cannot stall the sender. After the budget the batch is dropped and the loss is logged. Capture stays lossy by contract: it never blocks the caller, and the queue drops new events rather than growing.

Analytics:Enabled is the server package's single on-off switch. False stops event capture and the feedback relay together, so a product that turns analytics off sends nothing at all.

posthog-js is an optional peer dependency: the product installs it, and our package uses that one copy. It is needed only by the main entry (initAnalytics and the rest of PostHog capture); a /site-only consumer never loads PostHog, so it does not need to install it. A product that also uses PostHog directly would otherwise end up with two engines running, each with its own session and identity, which splits every funnel silently.

React Native apps (@webority/analytics/react-native)

The mobile entry wraps posthog-react-native with the web entry's rules. Install posthog-react-native in the app; it is an optional peer, so a web-only product does not install it, and without it nothing is sent. Pass the app's own key-value store as storage (mobile-core Storage, or AsyncStorage: anything with async getItem and setItem); without it the choice lasts until the app closes.

import { initMobileAnalytics, setConsentRegion, setAnalyticsConsent, identify, group, trackEvent, trackScreen, registerEvents } from "@webority/analytics/react-native";

registerEvents(["inspection_started"]);
await initMobileAnalytics({ host, clientKey, product: "lumex", surface: "surveyor", environment, requiresOptIn: true, storage: Storage });
setConsentRegion(regionFromServer); // "eea" keeps opt-in, "other" grants by default when no choice is stored
await setAnalyticsConsent(true); // from the app's consent screen
identify(user.publicId);
group(account.publicId);
trackScreen("InspectionOverview");
trackEvent("inspection_started", { step: 1 });
  • Nothing is sent without consent. Mobile has no cookieless mode, so PostHog starts opted out (defaultOptIn: false) and opts in only while analytics consent holds. product, surface and environment are registered as super properties at start and ride on every event.
  • The choice is stored on the device under webority_analytics_consent as { analytics, savedAtUtc }. getAnalyticsConsent() returns { analytics, decided }; onAnalyticsConsentChange(listener) fires when it changes, including when the stored choice is read at start, and returns an unsubscribe function.
  • Identity waits for consent. identify(publicId) and group(accountPublicId) (group type account) made earlier are kept, and the last of each is applied on grant. Pass PublicIds only. resetIdentity() on sign-out forgets them and puts the labels and the opt-in state back.
  • Same vocabulary and guard as the web. events is the shared list; trackEvent drops an unregistered name with one warning per name. trackScreen sends a PostHog $screen view and needs no registration.
  • Never throws into the app. Every PostHog or storage failure is swallowed; the first one is logged once, in development builds (__DEV__) only, through the logger passed to initMobileAnalytics. Local environment, enabled: false, or an empty host or clientKey start no PostHog; Local logs events through the logger's info instead.
  • The SDK's own lifecycle events, session replay, feature flags and surveys are off.

Public websites import @webority/analytics/site, which loads PostHog only when initSiteAnalytics starts it. Razor pages load the same code as dist/webority-analytics-site.global.js, which exposes window.WeborityAnalytics. The main entry re-exports all of it for portals. Call order is fixed, and getting it wrong throws at startup:

import { captureAttribution, initConsent, loadTags } from "@webority/analytics/site";

initConsent({ requiresOptIn, cookieDomain }); // first; from the server's country check, see below
// or, to ask every visitor first: initConsent({ strictOptIn: true, cookieDays: 182 });
loadTags({ gtmId, gtmUrl, clarityId, ahrefsKey }); // ids from marketing.json#seo via apply-tokens
captureAttribution();                       // every page load, before a router strips the query
  • initConsent({ requiresOptIn }) sets Google Consent Mode's defaults: denied in the EEA, the UK and Switzerland, granted everywhere else. Then it applies the visitor's saved webority_cookie_consent choice. requiresOptIn comes from the host, which knows the country, for example from Cloudflare's CF-IPCountry header through requiresOptIn(countryCode). An unknown country opts in. cookieDomain (for example ".capnix.ai") makes one choice cover the website and every portal subdomain. Leave it out and the choice stays on the current host. Adding it is safe for visitors who chose before it was set: when a host-only cookie sits beside the shared one, the newer choice wins, initConsent folds the two into the shared cookie, and every save clears the host-only one. A newer choice or a withdrawal is never hidden by an older cookie.
  • initConsent({ requiresOptIn, regionCookie }) lets a host that learns the visitor's region on an earlier request pass it in a cookie instead of a per-page flag. regionCookie is the cookie's name (the fleet uses wui_region); its value is eea or other, read once at init. With no saved choice, other grants analytics and functional by default (marketing stays off, Google's ad signals follow adStorage), and the banner still opens so the visitor can opt out. eea, a missing cookie or any other value keeps the opt-in default; strictOptIn ignores it; a saved choice always wins. setConsentRegion("eea" | "other") applies the same rule when the region arrives after init, and fires onConsentChange listeners when the effective consent changes.
  • initConsent({ strictOptIn: true }) is for a site that asks every visitor first, in every country. Consent Mode starts denied everywhere, with no region split, and loadTags holds Tag Manager and Ahrefs until the visitor consents (see below). requiresOptIn may be left out; passing false throws, because it contradicts the mode. Off by default, so existing sites keep the regional defaults.
  • adStorage says whether marketing consent also grants Google's ad_storage, ad_user_data and ad_personalization. It defaults to true, and to false under strictOptIn, where Accept then grants analytics only. A strict site that runs advertising tags passes adStorage: true.
  • cookieDays sets how long the saved choice lasts, counted from the choice. It defaults to 365; it must be a whole number above zero.
  • getConsent() returns { essential, analytics, functional, marketing, decided }. decided: false means the banner should ask.
  • saveConsent(preferences) writes the cookie (365 days unless cookieDays says otherwise), sends a Consent Mode update, and notifies onConsentChange listeners and a webority-consent-change window event. The analytics category is also what switches PostHog's capture on and off.
  • loadTags loads Tag Manager on the first interaction, or 1.5 seconds after load, and Ahrefs straight away. Clarity loads only with analytics consent: lazily on scroll or after 3 seconds, or when consent is granted later. A revoke takes effect from the next page. GA4 is not loaded here: it belongs inside the Tag Manager container, and loading it twice counts every page view twice. Under strictOptIn, Ahrefs waits for analytics consent. Tag Manager waits for analytics consent, or for marketing consent when adStorage is on; marketing alone without adStorage loads nothing, because the container would then only carry GA4. Either consent counts whether given on this page or saved on an earlier visit, and once it arrives Tag Manager still waits for the next interaction or 1.5 seconds. gtmUrl (optional) replaces https://www.googletagmanager.com/gtm.js with the site's own address for the loader, for example https://metrics.example.com/gtm.js; ?id=<gtmId> is appended (&id= if the address already has a query). The address must serve Google's gtm.js, typically through a proxy. A visitor whose saved choice already grants Tag Manager (analytics, or marketing with adStorage) gets it at DOM-ready, without the interaction wait; undecided and first-time visitors keep the deferred load. Server-side Tag Manager itself is out of scope.
  • captureAttribution() and getAttribution() record utm_*, gclid, gbraid, wbraid, fbclid, msclkid, li_fat_id, an outside referrer (or a referrer query parameter carried between our own domains) and the landing path.
    • The first touch is kept, and the last touch is replaced by each later campaign or referral visit. A direct visit changes nothing.
    • With marketing consent the record lives 90 days in localStorage under webority_attribution; without it, only for the tab. It moves between the two stores when consent changes.
    • The JSON matches the server's MarketingAttribution, so products post it as it is. A touch from an AI assistant carries aiSource (chatgpt, perplexity, gemini, copilot, claude...), found from utm_source or the referrer host.
    • Every landing touch also pushes { event: "acquisition_touch", acquisition_channel, ai_source } to dataLayer (ai_source only when an assistant sent the visit). resolveChannel(touch) and resolveAiSource(touch) are exported, mirroring the server resolver.
  • Google Consent Mode extras: initConsent also sets ads_data_redaction and url_passthrough to true. Google applies both only while ad_storage is denied: ad click ids are redacted from requests, and gclid and friends stay on the URLs we link to, so a denied visitor's click survives navigation.
  • Clarity receives the visitor's choice through its consent API v2 (consentv2, ad_Storage and analytics_Storage) when it loads and on every consent change after, so a withdrawal reaches a recording that is already running.
  • trackSiteEvent(name, props) pushes a catalogue event to dataLayer for public websites with no PostHog: lead_submitted, whatsapp_click, calculator_use, phone_click, demo_booked, contact_submitted, trial_start_click and sample_report_download (the same names are in events, track.leadSubmitted() and friends for portals, and in the server's AnalyticsEvents). It pushes nothing until the visitor has granted analytics consent, the gate PostHog follows, and a name outside the catalogue is dropped. Each push carries first_touch_channel, last_touch_channel and, for an assistant visit, first_touch_ai_source / last_touch_ai_source. Once initSiteAnalytics has started PostHog, the event goes there as well, with the same properties, and without consent it is counted cookieless like the page view.

PostHog on a public website: initSiteAnalytics

initConsent({ requiresOptIn, cookieDomain: ".lumex.zone" });
initSiteAnalytics({ host, clientKey, product, surface: "website", environment, sessionRecording, heatmaps, captureExceptions, webVitals });
  • Call it once per page, after initConsent, or it throws. It does nothing without a host and a clientKey, with enabled: false, or when environment is Local. A site that never calls it loads no PostHog at all.
  • Consent works as on the portal: PostHog runs with cookieless_mode: "on_reject", opts in straight after init when the visitor's analytics consent is already granted, starts opted out otherwise, and follows onConsentChange. Without consent the visit is still counted, cookieless and anonymous.
  • product, surface and environment are registered before the opt-in, so the first page view carries them. Every page load is a page view; autocapture and session replay are off, and person profiles are made only for identified people.
  • With consent, the device id is kept in localStorage and in a cookie that posthog-js sets on the parent domain (cross_subdomain_cookie), the domain the consent cookie shares. A visit on the website and a later sign-in on a portal subdomain then join one person, when the portal uses the same PostHog project and also keeps a cookie. A website never identifies anyone; the portal's identify does the join.
  • The ES module imports posthog-js (the optional peer) on demand. The browser bundle does not carry it: there initSiteAnalytics loads PostHog's own array.js from the PostHog host (<host>/static/array.js, or the assets domain PostHog's snippet uses for its cloud hosts), through PostHog's standard snippet queue.

Addresses are scrubbed before they reach PostHog

Sign-in, reset and invite links carry tokens and emails in the address, and PostHog copies the address into every event and into the person's first-seen properties. Both PostHog paths (initAnalytics and initSiteAnalytics) install a before_send hook that removes the query keys token, code, email, access_token, id_token, state and password (any case), and drops the fragment whole when it carries one of them. Every other key and fragment is kept as it was. It covers $current_url, $referrer, $pathname, the $initial_* person properties and the $session_entry_* properties. The React Native entry sends screen names, not addresses, so it needs no hook. The hook is a safety net: pages should still keep emails and long-lived tokens out of the address.

GA4: segment AI assistant traffic

GA4 files chatgpt.com under Referral. In Admin > Data display > Channel groups, create a custom group with a channel named AI Assistants placed above Referral, condition Source matches regex:

(^|\.)(chatgpt\.com|chat\.openai\.com|openai|chatgpt|perplexity(\.ai)?|gemini(\.google\.com)?|copilot(\.microsoft\.com)?|claude(\.ai)?|you\.com|phind(\.com)?|deepseek(\.com)?|grok(\.com)?|meta\.ai|poe\.com)$

For a view that does not depend on the referrer, register ai_source and acquisition_channel as event-scoped custom dimensions and read them from the Tag Manager Data Layer Variables of the acquisition_touch trigger.

Tag helper: head script (webority-ui, not here)

The Razor <app-analytics-head> tag helper is AppAnalyticsHeadTagHelper in webority-ui, not in this repository. The bundle is the part to defer, with the Consent Mode defaults staying inline in the head.

On the server, Webority.Analytics provides the matching types. The package stores nothing; products keep the attribution on their own records.

  • MarketingAttribution (FirstTouch, LastTouch) of MarketingTouch binds the browser JSON with ASP.NET's default web settings.
  • AcquisitionChannelResolver.Resolve(touch) returns an AcquisitionChannel: Google, Meta, LinkedIn, Microsoft, AiAssistant, OrganicSearch, OrganicSocial, Referral, Other or Direct.
    • utm_source wins for the paid platforms, and separators are ignored ("Google Ads", "google_ads").
    • A click id decides when the source is missing or unrecognised, so paid detection always comes first.
    • utm_source=chatgpt.com (or any assistant name) is AiAssistant.
    • A tagged but unrecognised source is Other.
    • An untagged touch with an outside referrer is read from the referrer's host: an AI assistant is AiAssistant, a search engine (google.<tld>, bing.com, duckduckgo.com, search.yahoo.com, ecosia.org, yandex.<tld>, search.brave.com, with or without www.) is OrganicSearch, a social network is OrganicSocial, anything else is Referral (so docs.google.com is Referral).
    • No campaign parameter and no referrer, or no touch at all, is Direct.
  • AcquisitionChannelResolver.ResolveAiSource(touch) returns the assistant (chatgpt, perplexity, gemini, copilot, claude, you, phind, deepseek, grok, meta_ai, poe) or null. The browser stores the same value on the touch as aiSource, which binds to MarketingTouch.AiSource. Some assistants' apps send no referrer, so expect an undercount.
  • ConsentRegion.RequiresOptIn(countryCode) gives server-rendered pages the value to pass to initConsent. It reads the country from ConsentRegion.CountryHeaderName (CF-IPCountry), and a test holds its list in step with the browser package's.
  • ConsentRegion.AllowsIdentity(choice, countryCode) says whether a request may carry the visitor's identity. AnalyticsConsentChoice.Granted is true and Declined is false. Unset follows the region: true outside the opt-in countries, false inside them and for an unknown country.

Anonymous server events

IAnalyticsClient.TrackAnonymous(eventName, properties) is for a visitor whose identity may not be used (for example when AllowsIdentity is false). Each event gets a fresh random distinct id and $process_person_profile: false, so PostHog builds no person profile and no two events join. It drops the event with a warning, and never throws, when properties carries $groups, $set, $set_once, distinct_id, account or $anon_distinct_id, because each would link the event to a person or a group. Every other drop rule of Track applies.

if (ConsentRegion.AllowsIdentity(choice, country)) analytics.Track("signup_completed", publicId);
else analytics.TrackAnonymous("signup_completed");

GA4 conversions from the server

services.AddGoogleAnalyticsConversions() registers IGoogleAnalyticsConversions, for a conversion that happens on the server (a lead saved, a payment taken) and has to reach the GA4 property the browser already feeds.

conversions.SendConversion("generate_lead", gaClientId, choice, country, new Dictionary<string, object> { ["value"] = 12 });
  • gaClientId is the random.timestamp part of the visitor's _ga cookie (GA1.1.1234567890.1700000000 gives 1234567890.1700000000). Anything else is dropped.
  • It sends only when ConsentRegion.AllowsIdentity(choice, country) is true, so a declined visitor, and an undecided one in an opt-in country or of unknown country, is never sent.
  • eventName follows GA4's rules: a letter, then letters, digits or underscores, at most 40 characters.
  • Google's ad signals are always sent as denied. No user id, user properties or IP are sent, and parameters named user_id, email or any key TrackAnonymous refuses are rejected.
  • Like Track, it never throws and never waits on Google: the event is queued and sent by a background sender, retried twice on a transient failure (a network error, a timeout, 408, 429 or 5xx, pausing 1s then 2s, or the Retry-After Google sends), and a send that still fails is logged and dropped.
"GoogleAnalytics": { "Enabled": true, "MeasurementId": "G-XXXXXXXXXX", "ApiSecret": "<Measurement Protocol API secret>" }

Off by default. Enabled with a missing MeasurementId or ApiSecret fails at boot. The secret is per data stream (GA4 Admin, Data streams, Measurement Protocol API secrets); keep it in secrets, not source.

Products consume these; nothing product-specific lives here. Event names are the fleet vocabulary locked on EPIC-00297. Extending it is a fleet decision, not a per-product edit.

Config reaches products via .webority/marketing.json → apply-tokens/prebuild → appsettings (~/.claude/conventions/marketing.md). The PUBLIC PostHog client key is committed product config; the server-side personal API key never leaves OS config.

Publishing: on merge to main, per cicd.md package-repo standard (versions named by the owner).

Product 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (3)

Showing the top 3 NuGet packages that depend on Webority.Analytics:

Package Downloads
Webority.Ui.Razor

Webority shared Razor component library: the App* tag helpers and the admin shell for Razor Pages heads, bundling the @webority/theme design system as a static web asset.

Webority.Ads

Ad platform core for Webority products: the AdConversion outbox row and its queue and dispatcher, the AdLead intake log with its handler seam, normalise-then-hash of customer data, and the EF Core mappings a product applies to its own DbContext.

Webority.Insights.Ai

The weekly AI review over a product's Webority Insights rollups: a 28-day pack built from the product's own tables, one Webority.Ai agent run per lens (product, experience, search), and evidence-backed proposals submitted through the product's sink to the Webority OS insight queue. The product schedules it.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.30.0 82 11/1/2026
0.29.0 64 11/1/2026
0.28.2 849 10/28/2026
0.28.1 137 10/28/2026
0.28.0 237 10/26/2026
0.27.0 168 10/25/2026
0.26.0 288 10/24/2026
0.25.1 492 10/21/2026
0.25.0 428 10/17/2026
0.24.0 269 10/16/2026
0.23.3 170 10/16/2026
0.23.2 1,137 10/13/2026
0.23.1 218 10/12/2026
0.23.0 232 10/12/2026
0.22.0 139 10/12/2026
0.21.0 320 10/11/2026
0.20.0 481 10/10/2026
0.19.2 106 10/10/2026
0.19.1 94 10/10/2026
0.19.0 123 10/10/2026
Loading failed