Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions web-admin/src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
} from "@rilldata/web-admin/features/navigation/nav-utils";
import OrganizationTabs from "@rilldata/web-admin/features/organizations/OrganizationTabs.svelte";
import { initCloudMetrics } from "@rilldata/web-admin/features/telemetry/initCloudMetrics";
import SessionRecordingConsentBanner from "@rilldata/web-common/lib/analytics/SessionRecordingConsentBanner.svelte";
import BannerCenter from "@rilldata/web-common/components/banner/BannerCenter.svelte";
import NotificationCenter from "@rilldata/web-common/components/notifications/NotificationCenter.svelte";
import { featureFlags } from "@rilldata/web-common/features/feature-flags";
Expand Down Expand Up @@ -171,3 +172,4 @@
</QueryClientProvider>

<NotificationCenter />
<SessionRecordingConsentBanner />
146 changes: 146 additions & 0 deletions web-common/src/lib/analytics/SessionRecordingConsentBanner.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
<script lang="ts">
import { onMount } from "svelte";
import Button from "../../components/button/Button.svelte";
import {
getSessionRecordingConsent,
setSessionRecordingConsent,
} from "./posthog";

let showBanner = false;

// EU country codes (ISO 3166-1 alpha-2)
const EU_COUNTRIES = new Set([
"AT",
"BE",
"BG",
"HR",
"CY",
"CZ",
"DK",
"EE",
"FI",
"FR",
"DE",
"GR",
"HU",
"IE",
"IT",
"LV",
"LT",
"LU",
"MT",
"NL",
"PL",
"PT",
"RO",
"SK",
"SI",
"ES",
"SE",
// EEA countries
"IS",
"LI",
"NO",
// UK (still follows similar privacy laws)
"GB",
]);

async function isEUOrCalifornia(): Promise<boolean> {
try {
const response = await fetch("https://ipapi.co/json/");
if (!response.ok) return true; // Default to showing banner if geo fails
const data = await response.json();

const isEU = EU_COUNTRIES.has(data.country_code);
const isCalifornia =
data.country_code === "US" && data.region_code === "CA";

return isEU || isCalifornia;
} catch {
// If geolocation fails, default to showing banner (safer for compliance)
return true;
}
}

onMount(async () => {
// Only proceed if consent hasn't been given yet
if (getSessionRecordingConsent() !== null) return;

// Check if user is in EU or California
const requiresConsent = await isEUOrCalifornia();
if (requiresConsent) {
showBanner = true;
} else {
// Non-EU/CA users: auto-grant consent
setSessionRecordingConsent("granted");
}
});

function accept() {
setSessionRecordingConsent("granted");
showBanner = false;
}

function decline() {
setSessionRecordingConsent("denied");
showBanner = false;
}
</script>

{#if showBanner}
<div class="consent-banner">
<div class="consent-content">
<p class="consent-title">Usage Analytics</p>
<p class="consent-message">
We collect anonymous usage analytics to improve our app. Sensitive data
(passwords, emails, phone numbers) is automatically masked and not
collected.
</p>
<p class="consent-disclaimer">
By accepting, you consent to usage data collection in accordance with
our
<a
href="https://www.rilldata.com/privacy-policy"
target="_blank"
rel="noopener noreferrer">Privacy Policy</a
>. You can change this preference at any time in settings.
</p>
<div class="consent-actions">
<Button type="secondary" onClick={decline} small>Decline</Button>
<Button type="primary" onClick={accept} small>Accept</Button>
</div>
</div>
</div>
{/if}

<style lang="postcss">
.consent-banner {
@apply fixed bottom-4 right-4 z-50;
@apply bg-surface-background border rounded-md shadow-lg;
@apply max-w-sm p-4;
}

.consent-content {
@apply flex flex-col gap-3;
}

.consent-title {
@apply text-sm font-semibold text-fg-primary;
}

.consent-message {
@apply text-xs text-fg-secondary leading-relaxed;
}

.consent-disclaimer {
@apply text-[11px] text-fg-muted leading-relaxed;
}

.consent-disclaimer a {
@apply text-accent-primary-action underline;
}

.consent-actions {
@apply flex gap-2 justify-end;
}
</style>
80 changes: 78 additions & 2 deletions web-common/src/lib/analytics/posthog.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,45 @@
import posthog, { type Properties } from "posthog-js";

const POSTHOG_API_KEY = import.meta.env.RILL_UI_PUBLIC_POSTHOG_API_KEY;
const CONSENT_KEY = "rill_session_recording_consent";

export type SessionRecordingConsent = "granted" | "denied" | null;

/**
* Get the stored session recording consent preference
*/
export function getSessionRecordingConsent(): SessionRecordingConsent {
if (typeof window === "undefined") return null;
const value = localStorage.getItem(CONSENT_KEY);
if (value === "granted" || value === "denied") return value;
return null;
}

/**
* Store the session recording consent preference and apply it
*/
export function setSessionRecordingConsent(consent: "granted" | "denied") {
if (typeof window === "undefined") return;
localStorage.setItem(CONSENT_KEY, consent);
applySessionRecordingConsent(consent);
}

/**
* Apply session recording settings based on consent
*/
function applySessionRecordingConsent(consent: SessionRecordingConsent) {
if (!posthog.__loaded) return;

if (consent === "granted") {
// Start session recording with selective masking (UI visible)
posthog.startSessionRecording();
} else if (consent === "denied") {
// Start session recording with full masking (privacy mode)
// PostHog doesn't support changing mask config at runtime,
// so we stop recording entirely for declined consent
posthog.stopSessionRecording();
}
}

export function initPosthog(rillVersion: string, sessionId?: string | null) {
// No need to proceed if PostHog is already initialized
Expand All @@ -11,17 +50,54 @@ export function initPosthog(rillVersion: string, sessionId?: string | null) {
return;
}

const consent = getSessionRecordingConsent();

// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
posthog.init(POSTHOG_API_KEY, {
api_host: "https://us.i.posthog.com", // TODO: use a reverse proxy https://posthog.com/docs/advanced/proxy
session_recording: {
maskAllInputs: true,
maskTextSelector: "*",
// Selective input masking by type
maskAllInputs: false,
maskInputOptions: {
password: true, // Always mask passwords
email: true, // Mask emails (customer data)
tel: true, // Mask phone numbers (customer data)
// Don't mask these types
color: false,
date: false,
number: false,
search: false,
text: false,
url: false,
},
// Custom masking function for granular control
maskInputFn: (text, element) => {
const inputElement = element as HTMLInputElement | undefined;
// Always mask passwords
if (inputElement?.type === "password") {
return "*".repeat(text.length);
}
// Mask inputs marked as sensitive via data attribute
if (element?.getAttribute("data-sensitive") === "true") {
return "*".repeat(text.length);
}
// Mask email and phone inputs
if (inputElement?.type === "email" || inputElement?.type === "tel") {
return "*".repeat(text.length);
}
// Show everything else (UI elements remain visible)
return text;
},
// Keep UI text visible (buttons, labels, navigation)
maskTextSelector: undefined,
// Network request settings
recordHeaders: true,
recordBody: false,
},
autocapture: true,
enable_heatmaps: true,
// Start with session recording disabled until consent is given
disable_session_recording: consent !== "granted",
bootstrap: {
sessionID: sessionId ?? undefined,
},
Expand Down
Loading