Webority.Analytics
0.28.2
dotnet add package Webority.Analytics --version 0.28.2
NuGet\Install-Package Webority.Analytics -Version 0.28.2
<PackageReference Include="Webority.Analytics" Version="0.28.2" />
<PackageVersion Include="Webority.Analytics" Version="0.28.2" />
<PackageReference Include="Webority.Analytics" />
paket add Webority.Analytics --version 0.28.2
#r "nuget: Webority.Analytics, 0.28.2"
#:package Webority.Analytics@0.28.2
#addin nuget:?package=Webority.Analytics&version=0.28.2
#tool nuget:?package=Webority.Analytics&version=0.28.2
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.
The workspace group
The customer workspace is the fleet's one shared group type, workspace, keyed by the workspace's PublicId, so
usage per paying workspace reads the same way in every product.
- Browser and React Native:
group(WORKSPACE_GROUP_TYPE, workspace.publicId, { plan, seats })once the workspace is known (sign-in, restore, workspace switch). Every later event carries it. Made before consent it is held, one per group type, and applied on the grant;resetIdentity()forgets it. A type or key that is not a string (an internal number id) is dropped with a warning through thelogger. - Server:
analytics.TrackInWorkspace(eventName, userPublicId, workspacePublicId, properties)sends the event with$groups: { workspace: workspacePublicId }. Set the workspace's properties withanalytics.Group(AnalyticsGroupTypes.Workspace, workspacePublicId, new Dictionary<string, object> { ["plan"] = "team", ["seats"] = 5 }). The server keeps no ambient workspace: pass it on each event.
Feature flags and experiments
Off by default, because loading flags costs a request on every page or app start. A product turns them on with
featureFlags: true in initAnalytics or initMobileAnalytics.
import { getFeatureFlag, isFeatureEnabled, onFeatureFlags } from "@webority/analytics";
initAnalytics({ host, clientKey, product, surface, environment, featureFlags: true });
onFeatureFlags(() => render(isFeatureEnabled("new_report_layout"), getFeatureFlag("pricing_page_test")));
isFeatureEnabled(key)returnstrueorfalse;getFeatureFlag(key)returns the variant key of a multivariate flag (an experiment arm),trueorfalsefor a plain flag, ornull. Both call PostHog's own functions, so PostHog records the exposure ($feature_flag_called) and stamps$feature/<key>on later events, which is what an experiment reads.- Consent first. No flag request is made before analytics consent: the first load is held and runs on the grant.
Without consent, after a revoke, with flags off, in Local, or while PostHog loads, they return
falseandnull. They never throw. onFeatureFlags(listener)calls the listener each time flags load or reload (consent, identify, group). When flags cannot load now it is called once on the next turn, so a screen waiting on it shows its default. It returns an unsubscribe function.- Server:
await analytics.IsFeatureEnabledAsync(flagKey, userPublicId, groups)andawait analytics.GetFeatureFlagVariantAsync(flagKey, userPublicId, groups)(the variant key, ornull; a plain on/off flag reads throughIsFeatureEnabledAsync) ask PostHog's/flagsendpoint with the project key through the package's PostHog HTTP client. One retry, two seconds at most in all, thenfalseornull, and a caller that cancels gets the same; never throws. Each records one$feature_flag_calledper person, flag and answer. Pass the workspace asAnalyticsGroupTypes.Workspaceingroupsfor a flag rolled out by workspace. Read it once per request and keep the answer. The server does not know the visitor's consent; call it only for a person analytics may name.
Running an experiment. Create the experiment in PostHog with its flag key and goal metric (an event from the
catalogue). Gate the change on getFeatureFlag(key) === "test" (or the server call). Turn featureFlags on for that
product. PostHog's experiment view reads the exposures and the goal events and reports the result; the outcome check
reads the same result.
initAnalytics takes host, clientKey, product, surface, environment, and optionally
enabled, logger, sessionRecording, heatmaps, captureExceptions, webVitals and featureFlags. 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 sends each batch once through the standard resilience handler on its client. The handler makes up to
three attempts, pausing 1s and then 2s between them, when the failure is one that passes on its own: a network error, a
timeout, or status 408, 429 or 5xx. A Retry-After the server sends is honoured, up to 30 seconds, so a bad value cannot
stall the sender. Every other 4xx and anything thrown before a request exists is dropped at once, because the same request
would fail the same way. After the handler gives up, 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.
Product tours: trackTours()
One call turns every <wui-tour> (@webority/ui-elements) on the page into four fleet events, with no code per tour.
import { initAnalytics, trackTours } from "@webority/analytics";
initAnalytics({ host, clientKey, product, surface, environment });
const stopTours = trackTours(); // call the returned function to stop listening
It listens on document for the tour's bubbling events and sends through trackEvent, so consent and the Local and
disabled behaviour are the same as for every other event.
| Tour event | Analytics event | Properties |
|---|---|---|
wui-tour:start |
tour_started |
tour_id, step_count, trigger |
wui-tour:step |
tour_step_viewed |
tour_id, step_position, step_count, trigger |
wui-tour:close, reason finished |
tour_completed |
tour_id, step_position, step_count, trigger |
wui-tour:close, reason skipped |
tour_skipped |
tour_id, step_position, step_count, via, trigger |
tour_idis the element'stour-idattribute; a tour without one sends nothing.triggeris read from an optionaltriggerattribute on the element and left out when absent.- Close reasons
alreadySeenandnoTargets(nothing showed) send nothing. A count the tour did not report is left out. - Call it once at start-up; a second call would send every event twice.
The event catalogue: tracking-plan.json
Each product declares every event it sends in one file, .webority/tracking-plan.json, which extends the fleet
base plan shipped in the package ("extends": "@webority/analytics/fleet", the locked vocabulary). The build
checks the product's code against it and the constants can be generated from it. The fleet events are still written
out in vocabulary.js and AnalyticsEvents as well. Tests fail when either one differs from the fleet plan: the
catalogue test compares the plan with vocabulary.js, and a C# test compares AnalyticsEvents with the
FleetEventCatalogue generated from the plan.
Each event records what happened, its funnel stage (acquisition, activation, engagement, retention,
revenue, referral, support or quality), its owner (the product area accountable), its personalData class
(none, pseudonymous for an opaque public id only, personal for anything that names a person), the senders
that send it, and its properties with their types (string, integer, number, boolean, array, object;
optionally values, nullable and a property's own personalData). commonProperties may ride on every event; the
fleet declares product, surface, environment and the website's touch properties. A product lists a fleet event
again only to add its senders and properties; it cannot change what the event means. A short entry like that for an
event the base plan does not hold (a misspelt fleet name, say) is refused, because it would leave the event with no
stage, owner or personal-data class.
{
"$schema": "../Lumex.Web.Customer/node_modules/@webority/analytics/dist/tracking-plan.schema.json",
"product": "lumex",
"extends": "@webority/analytics/fleet",
"events": {
"signup_completed": { "senders": ["server"], "properties": { "source": { "type": "string" } } },
"report_generated": {
"description": "A report was downloaded.",
"stage": "activation",
"owner": "reports",
"personalData": "pseudonymous",
"senders": ["server"],
"properties": { "kind": { "type": "string" }, "watermarked": { "type": "boolean" } }
}
},
"sources": [
{
"sender": "server",
"language": "csharp",
"constants": ["Lumex.Application/Analytics/ProductEvents.cs"],
"references": ["ProductEvents"],
"ignoreConstants": ["LogCategory"],
"scan": ["Lumex.Application", "Lumex.Web.Api"]
},
{
"sender": "portal",
"language": "javascript",
"constants": ["Lumex.Web.Customer/src/utils/productEvents.js"],
"references": ["ProductEvents"],
"scan": ["Lumex.Web.Customer/src"]
},
{
"sender": "website",
"language": "javascript",
"calls": ["lumexTrack"],
"scan": ["Lumex.Web/wwwroot", "Lumex.Web/Pages"],
"exclude": ["Lumex.Web/wwwroot/lib"]
}
]
}
The check. webority-analytics-check-events --plan .webority/tracking-plan.json exits 1 and lists each problem
with its file and line:
- an event constant in a source's
constantsfiles that is not in the plan (a C# constant written as= AnalyticsEvents.Xresolves through the fleet plan); - a use of a constant (
ProductEvents.X, or the package'sAnalyticsEvents.Xin C#) whose sender the event's entry does not list, or that names no constant (JavaScript only, where it would sendundefined); - an event name written in a send: in JavaScript every
trackSiteEvent("name", ...)call, plus any function a source names incalls(a site's own wrapper,"calls": ["lumexTrack"]), read from a string literal or the literal arms of a ternary and held to the same sender and property rules. A source of calls only needs noconstants; - a property written inline beside the event, in a C# dictionary initializer (
["key"] = value) or a JavaScript object literal in the same call or array, that is declared neither on the event nor as a common property; - a plan that breaks its own rules: an unknown field, a missing stage, a fleet event redefined, a short entry for an event the base does not hold, a property retyped.
A JavaScript source also reads the inline scripts of .html, .cshtml and .razor files under its scan folders;
list vendored folders in exclude. A send in a quoted handler attribute (onclick="...") and a name passed to a
send in a variable are not seen.
It skips build output, node_modules, test folders (tests, *.Tests) and *.test.* files. Properties a helper
builds away from the event (a properties variable filled elsewhere) are not visible to it. For those, pass
--events captured.jsonl, one { "event": "...", "properties": { ... } } per line from a test run or an export, and
each is checked for its name, every property, its type, nullability and allowed values. PostHog's own $ events and
properties are skipped. Paths are relative to the repo holding .webority/; --root overrides that. The scan reads
source as text, not through a parser. A reference inside a string that spans lines, a C# raw or nested-quote
interpolated string, or a // inside a JavaScript regular expression can mislead it; the full list is in the
header of src/catalogue/scan.js.
The constants. webority-analytics-generate-events writes them from the plan:
webority-analytics-generate-events --plan .webority/tracking-plan.json --sender portal \
--js src/utils/productEvents.js --js-export ProductEvents --js-keys pascal
webority-analytics-generate-events --plan .webority/tracking-plan.json --sender server \
--cs Lumex.Application/Analytics/ProductEventNames.g.cs --cs-namespace Lumex.Application.Analytics --cs-class ProductEventNames
--sender keeps one sender's events (a portal registers only its own), --product-only leaves out the fleet's, and
--check writes nothing and exits 1 when a file is missing or stale, which is the build step. The package does the
same for itself: Webority.Analytics/FleetEventCatalogue.g.cs is generated from the fleet plan
(npm run catalogue:generate) and npm test fails when it is stale.
The schema (dist/tracking-plan.schema.json) gives an editor completion and validation; the check enforces the
same rules without a schema library, so it has no runtime dependency. The schema names the fleet events, so on the
fleet base it accepts a short entry only for one of them. A plan that extends another file instead is one the schema
cannot see the base of, so there only the check catches a short entry for an unknown event.
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, WORKSPACE_GROUP_TYPE } 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(WORKSPACE_GROUP_TYPE, workspace.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,surfaceandenvironmentare registered as super properties at start and ride on every event. - The choice is stored on the device under
webority_analytics_consentas{ 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)andgroup(WORKSPACE_GROUP_TYPE, workspacePublicId, props)made earlier are kept, and the last of each (per group type) is applied on grant. PassPublicIds only.resetIdentity()on sign-out forgets them and puts the labels and the opt-in state back. - Same vocabulary and guard as the web.
eventsis the shared list;trackEventdrops an unregistered name with one warning per name.trackScreensends a PostHog$screenview 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 theloggerpassed toinitMobileAnalytics. Local environment,enabled: false, or an emptyhostorclientKeystart no PostHog; Local logs events through the logger'sinfoinstead. - The SDK's own lifecycle events, session replay and surveys are off. Feature flags are off unless
featureFlags: trueis passed; thenisFeatureEnabled,getFeatureFlagandonFeatureFlagswork as on the web, and flags load only afteroptIn.
Consent, marketing tags and attribution (@webority/analytics/site)
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 savedwebority_cookie_consentchoice.requiresOptIncomes from the host, which knows the country, for example from Cloudflare'sCF-IPCountryheader throughrequiresOptIn(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,initConsentfolds 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.regionCookieis the cookie's name (the fleet useswui_region); its value iseeaorother, read once at init. With no saved choice,othergrants analytics and functional by default (marketing stays off, Google's ad signals followadStorage), and the banner still opens so the visitor can opt out.eea, a missing cookie or any other value keeps the opt-in default;strictOptInignores it; a saved choice always wins.setConsentRegion("eea" | "other")applies the same rule when the region arrives after init, and firesonConsentChangelisteners 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, andloadTagsholds Tag Manager and Ahrefs until the visitor consents (see below).requiresOptInmay be left out; passingfalsethrows, because it contradicts the mode. Off by default, so existing sites keep the regional defaults.adStoragesays whether marketing consent also grants Google'sad_storage,ad_user_dataandad_personalization. It defaults totrue, and tofalseunderstrictOptIn, where Accept then grants analytics only. A strict site that runs advertising tags passesadStorage: true.cookieDayssets 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: falsemeans the banner should ask.saveConsent(preferences)writes the cookie (365 days unlesscookieDayssays otherwise), sends a Consent Mode update, and notifiesonConsentChangelisteners and awebority-consent-changewindow event. The analytics category is also what switches PostHog's capture on and off.loadTagsloads 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. UnderstrictOptIn, Ahrefs waits for analytics consent. Tag Manager waits for analytics consent, or for marketing consent whenadStorageis on; marketing alone withoutadStorageloads 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) replaceshttps://www.googletagmanager.com/gtm.jswith the site's own address for the loader, for examplehttps://metrics.example.com/gtm.js;?id=<gtmId>is appended (&id=if the address already has a query). The address must serve Google'sgtm.js, typically through a proxy. A visitor whose saved choice already grants Tag Manager (analytics, or marketing withadStorage) 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()andgetAttribution()recordutm_*,gclid,gbraid,wbraid,fbclid,msclkid,li_fat_id, an outside referrer (or areferrerquery 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 carriesaiSource(chatgpt,perplexity,gemini,copilot,claude...), found fromutm_sourceor the referrer host. - Every landing touch also pushes
{ event: "acquisition_touch", acquisition_channel, ai_source }todataLayer(ai_sourceonly when an assistant sent the visit).resolveChannel(touch)andresolveAiSource(touch)are exported, mirroring the server resolver.
- Google Consent Mode extras:
initConsentalso setsads_data_redactionandurl_passthroughtotrue. Google applies both only whilead_storageis denied: ad click ids are redacted from requests, andgclidand 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_Storageandanalytics_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 todataLayerfor public websites with no PostHog:lead_submitted,whatsapp_click,calculator_use,phone_click,demo_booked,contact_submitted,trial_start_clickandsample_report_download(the same names are inevents,track.leadSubmitted()and friends for portals, and in the server'sAnalyticsEvents). It pushes nothing until the visitor has granted analytics consent, the gate PostHog follows, and a name outside the catalogue is warned about once and dropped unless the product passed it toregisterEvents(exported from@webority/analytics/sitetoo). Each push carriesfirst_touch_channel,last_touch_channeland, for an assistant visit,first_touch_ai_source/last_touch_ai_source. OnceinitSiteAnalyticshas run, the event goes to PostHog as well, with the same properties (held until PostHog starts), 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 ahostand aclientKey, withenabled: false, or whenenvironmentisLocal. A site that never calls it loads no PostHog at all. - PostHog loads on the same schedule as Tag Manager: the visitor's first interaction (scroll, pointer, touch or key), or
1.5 seconds after the load event, whichever comes first, so its script never competes with the first paint. A
trackSiteEventmade before then is held, up to 100 events, and sent in order straight after PostHog starts, under the consent in force at that moment. PostHog stamps the held events and the page view with the time it starts, not the time they happened (at most 1.5 seconds after the load event plus the script's download); their order is kept. - 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 followsonConsentChange. Without consent the visit is still counted, cookieless and anonymous. product,surfaceandenvironmentare 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'sidentifydoes the join. - The ES module imports
posthog-js(the optional peer) on demand. The browser bundle does not carry it: thereinitSiteAnalyticsloads PostHog's ownarray.jsfrom 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,
session_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, the
$session_entry_* properties and the page keys of $heatmap_data. 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) ofMarketingTouchbinds the browser JSON with ASP.NET's default web settings.AcquisitionChannelResolver.Resolve(touch)returns anAcquisitionChannel: Google, Meta, LinkedIn, Microsoft, AiAssistant, OrganicSearch, OrganicSocial, Referral, Other or Direct.utm_sourcewins 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 withoutwww.) is OrganicSearch, a social network is OrganicSocial, anything else is Referral (sodocs.google.comis 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 asaiSource, which binds toMarketingTouch.AiSource. Some assistants' apps send no referrer, so expect an undercount.ConsentRegion.RequiresOptIn(countryCode)gives server-rendered pages the value to pass toinitConsent. It reads the country fromConsentRegion.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.Grantedis true andDeclinedis false.Unsetfollows 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 });
gaClientIdis therandom.timestamppart of the visitor's_gacookie (GA1.1.1234567890.1700000000gives1234567890.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. eventNamefollows 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,emailor any keyTrackAnonymousrefuses 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 through the standard resilience handler (a network error, a timeout, 408, 429 or 5xx, pausing 1s then 2s, or theRetry-AfterGoogle 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 | 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
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.13)
- Microsoft.Extensions.Http (>= 10.0.13)
- Microsoft.Extensions.Http.Resilience (>= 10.10.0)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 10.0.13)
- Microsoft.Extensions.Options.DataAnnotations (>= 10.0.13)
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.28.2 | 457 | 10/28/2026 |
| 0.28.1 | 123 | 10/28/2026 |
| 0.28.0 | 227 | 10/26/2026 |
| 0.27.0 | 166 | 10/25/2026 |
| 0.26.0 | 287 | 10/24/2026 |
| 0.25.1 | 492 | 10/21/2026 |
| 0.25.0 | 425 | 10/17/2026 |
| 0.24.0 | 269 | 10/16/2026 |
| 0.23.3 | 170 | 10/16/2026 |
| 0.23.2 | 1,136 | 10/13/2026 |
| 0.23.1 | 216 | 10/12/2026 |
| 0.23.0 | 230 | 10/12/2026 |
| 0.22.0 | 136 | 10/12/2026 |
| 0.21.0 | 319 | 10/11/2026 |
| 0.20.0 | 480 | 10/10/2026 |
| 0.19.2 | 105 | 10/10/2026 |
| 0.19.1 | 93 | 10/10/2026 |
| 0.19.0 | 122 | 10/10/2026 |
| 0.18.0 | 93 | 10/10/2026 |
| 0.17.0 | 97 | 10/9/2026 |