Skip to content

Commit 12ff0c8

Browse files
authored
Merge branch 'main' into fix/health-check-config
2 parents 1f42b25 + 4de42df commit 12ff0c8

7 files changed

Lines changed: 837 additions & 15 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# 9. Vendor-Agnostic Privacy-Aware Data Layer
2+
3+
Date: 2026-03-16
4+
5+
## Status
6+
7+
Accepted
8+
9+
## Context
10+
11+
The portal needs analytics and observability to understand user behavior, measure feature adoption, and support operational monitoring. Multiple analytics vendors (e.g., Mixpanel, Google Analytics, Adobe Analytics) may be used across different state deployments, and vendor choices may change over time. Directly integrating vendor SDKs throughout the codebase would create tight coupling, make vendor switches expensive, and risk leaking PII to vendors that should not receive it.
12+
13+
We need a single, canonical source of truth for page metadata, user attributes, and tracked events that:
14+
15+
- Decouples application code from any specific analytics vendor.
16+
- Enforces privacy scoping so PII is only accessible to authorized consumers.
17+
- Supports multiple concurrent vendor integrations without code changes.
18+
- Is understandable and maintainable by state partner agencies after handoff.
19+
20+
## Decision
21+
22+
We adopt a **vendor-agnostic data layer** following the [W3C Customer Experience Digital Data Layer (CEDDL)](https://www.w3.org/2013/12/ceddl-201312.pdf) recommendations. The implementation lives in `src/SEBT.Portal.Web/src/lib/data-layer.ts` as a framework-agnostic TypeScript class.
23+
24+
**Canonical data structure** bound to `window.digitalData`:
25+
26+
- `page` — page-level metadata with `category` and `attribute` sub-objects.
27+
- `user` — user-level data with a `profile` sub-object.
28+
- `event[]` — append-only event log with `eventName`, `eventData`, `timeStamp`, and `scope`.
29+
- `privacy.accessCategories` — declared access scopes.
30+
- `initialized` — boolean flag indicating readiness.
31+
32+
**Privacy-aware scoping:**
33+
34+
- Each data element can be assigned one or more access scopes (e.g., `"default"`, `"analytics"`, `"marketing"`).
35+
- Page data is publicly readable by default (no scope restriction).
36+
- User data automatically receives `"default"` scope, restricting access unless explicitly broadened.
37+
- Scope inheritance walks the path hierarchy from specific to general, so child elements inherit parent scope restrictions.
38+
- Scope metadata is stored in a private `Map`, not on the data objects themselves.
39+
40+
**Loose coupling via DOM CustomEvents:**
41+
42+
- All mutations emit namespaced `CustomEvent`s on `document` (e.g., `digitalData:PageElementSet`, `digitalData:UserProfileSet`, `digitalData:EventTracked`).
43+
- Vendor integration bridges subscribe to these events and forward data according to their scope permissions.
44+
- A global `DataLayer:Initialized` event signals readiness.
45+
- An `eventTypes` object on the root provides type-safe event name constants for bridge consumers.
46+
47+
**React integration** uses a `DataLayerProvider` component that initializes the data layer once on mount via `useRef`, wrapped as the outermost provider in the app layout.
48+
49+
## Consequences
50+
51+
- **Application code** calls `digitalData.page.set(...)`, `digitalData.user.set(...)`, and `digitalData.trackEvent(...)` without knowing which vendors consume the data.
52+
- **Adding a new vendor** requires only a new event listener bridge — no changes to application code or the data layer itself.
53+
- **Removing a vendor** means removing its bridge. No application code changes.
54+
- **PII protection** is enforced at the data layer boundary. A vendor bridge for `"analytics"` scope cannot read user data that only has `"default"` scope.
55+
- **State partners** can configure different vendor bridges per deployment without modifying the core portal.
56+
- **Testing** is straightforward because the class is framework-agnostic and testable with jsdom.
57+
58+
## References
59+
60+
- W3C CEDDL specification: https://www.w3.org/2013/12/ceddl-201312.pdf
61+
- Implementation: `src/SEBT.Portal.Web/src/lib/data-layer.ts`
62+
- Tests: `src/SEBT.Portal.Web/src/lib/data-layer.test.ts`
63+
- Provider: `src/SEBT.Portal.Web/src/providers/DataLayerProvider.tsx`

src/SEBT.Portal.Api/SEBT.Portal.Api.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@
114114
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.2.1" />
115115
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.2.1" />
116116
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.2.1" />
117+
<!-- Required by state connector plugins that use Kiota-generated API clients -->
118+
<PackageReference Include="Microsoft.Kiota.Abstractions" Version="1.21.2" />
117119
</ItemGroup>
118120

119121
<ItemGroup>

src/SEBT.Portal.Web/src/app/layout.tsx

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { getState, getStateName } from '@/lib/state'
44
import {
55
AuthProvider,
66
AxeProvider,
7+
DataLayerProvider,
78
FeatureFlagsProvider,
89
I18nProvider,
910
QueryProvider
@@ -98,21 +99,23 @@ export default async function RootLayout({
9899
className={`usa-js-loading ${primaryFont.variable}`}
99100
>
100101
<body>
101-
<QueryProvider>
102-
<AuthProvider>
103-
<FeatureFlagsProvider>
104-
<I18nProvider>
105-
<SkipNav />
106-
<AxeProvider>
107-
<Header state={state} />
108-
<main id="main-content">{children}</main>
109-
<HelpSection state={state} />
110-
<Footer state={state} />
111-
</AxeProvider>
112-
</I18nProvider>
113-
</FeatureFlagsProvider>
114-
</AuthProvider>
115-
</QueryProvider>
102+
<DataLayerProvider>
103+
<QueryProvider>
104+
<AuthProvider>
105+
<FeatureFlagsProvider>
106+
<I18nProvider>
107+
<SkipNav />
108+
<AxeProvider>
109+
<Header state={state} />
110+
<main id="main-content">{children}</main>
111+
<HelpSection state={state} />
112+
<Footer state={state} />
113+
</AxeProvider>
114+
</I18nProvider>
115+
</FeatureFlagsProvider>
116+
</AuthProvider>
117+
</QueryProvider>
118+
</DataLayerProvider>
116119
{/* USWDS initialization script - uses nonce for CSP compliance */}
117120
{/* suppressHydrationWarning: nonce changes per request, mismatch is expected */}
118121
<script

0 commit comments

Comments
 (0)