mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix(analytics): harden analytics opt-out and feedback surfaces (#423)
Server stops phoning Sentry home after opt-out (release-health sessions + client reports off); settings saves diff-send only changed keys so a stale tab cannot revert an instance-wide opt-out; disabling analytics hides the feedback UI immediately; optIn resumes PostHog after re-enable; onboarding survey writes time out at 15s; inline tool-feedback prompt arms a shown-cooldown.
This commit is contained in:
@@ -27,6 +27,11 @@ if (ANALYTICS_BAKED.sentryDsn) {
|
||||
environment: process.env.NODE_ENV || "production",
|
||||
tracesSampleRate: ANALYTICS_BAKED.sampleRate,
|
||||
sendDefaultPii: false,
|
||||
// Release-health request-sessions and client-report envelopes are sent outside
|
||||
// beforeSend/beforeSendTransaction, so the runtime opt-out below would not stop
|
||||
// them. Disable both so an opted-out instance truly stops phoning home.
|
||||
integrations: [Sentry.httpIntegration({ trackIncomingRequestsAsSessions: false })],
|
||||
sendClientReports: false,
|
||||
// Runtime opt-out: drop the whole transaction when analytics is off.
|
||||
tracesSampler: () => (sentryActive() ? ANALYTICS_BAKED.sampleRate : 0),
|
||||
beforeSend(event) {
|
||||
|
||||
@@ -19,10 +19,15 @@ interface ToolFeedbackPromptProps {
|
||||
}
|
||||
|
||||
const GLOBAL_LAST_PROMPT_KEY = "snapotter-feedback-last-prompt-at";
|
||||
const GLOBAL_LAST_SHOWN_KEY = "snapotter-feedback-last-shown-at";
|
||||
const PROMPTS_DISABLED_KEY = "snapotter-feedback-prompts-disabled";
|
||||
const TOOL_PROMPT_PREFIX = "snapotter-feedback-tool-prompt:";
|
||||
const GLOBAL_PROMPT_COOLDOWN_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const TOOL_PROMPT_COOLDOWN_MS = 90 * 24 * 60 * 60 * 1000;
|
||||
// A prompt that was merely shown (not acted on) still arms a short cooldown so an
|
||||
// ignored card does not reappear on the very next result. Interacting arms the much
|
||||
// longer cooldowns above.
|
||||
const SHOWN_PROMPT_COOLDOWN_MS = 3 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function readNumber(key: string): number {
|
||||
try {
|
||||
@@ -61,6 +66,8 @@ function disablePrompts(): void {
|
||||
function shouldShowPrompt(toolId: string): boolean {
|
||||
if (!toolId || promptsDisabled()) return false;
|
||||
const now = Date.now();
|
||||
const lastShown = readNumber(GLOBAL_LAST_SHOWN_KEY);
|
||||
if (lastShown && now - lastShown < SHOWN_PROMPT_COOLDOWN_MS) return false;
|
||||
const lastGlobal = readNumber(GLOBAL_LAST_PROMPT_KEY);
|
||||
if (lastGlobal && now - lastGlobal < GLOBAL_PROMPT_COOLDOWN_MS) return false;
|
||||
const lastTool = readNumber(`${TOOL_PROMPT_PREFIX}${toolId}`);
|
||||
@@ -73,6 +80,10 @@ function markPromptHandled(toolId: string): void {
|
||||
writeNow(`${TOOL_PROMPT_PREFIX}${toolId}`);
|
||||
}
|
||||
|
||||
function markPromptShown(): void {
|
||||
writeNow(GLOBAL_LAST_SHOWN_KEY);
|
||||
}
|
||||
|
||||
export function ToolFeedbackPrompt({
|
||||
toolId,
|
||||
jobStatus = "completed",
|
||||
@@ -88,7 +99,9 @@ export function ToolFeedbackPrompt({
|
||||
|
||||
useEffect(() => {
|
||||
if (!analyticsLoaded || !analyticsConfig?.enabled) return;
|
||||
setVisible(shouldShowPrompt(toolId));
|
||||
const show = shouldShowPrompt(toolId);
|
||||
if (show) markPromptShown();
|
||||
setVisible(show);
|
||||
}, [analyticsLoaded, analyticsConfig?.enabled, toolId]);
|
||||
|
||||
if (!analyticsLoaded || !analyticsConfig?.enabled) return null;
|
||||
|
||||
@@ -26,8 +26,14 @@ import {
|
||||
surveyIdForSource,
|
||||
} from "@/lib/feedback";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { withTimeout } from "@/lib/with-timeout";
|
||||
import { useAnalyticsStore } from "@/stores/analytics-store";
|
||||
|
||||
// A hung write (connection black-holed, never erroring) would otherwise leave both
|
||||
// buttons disabled forever with no exit but a page reload; time it out so the overlay
|
||||
// re-enables and the admin can retry or dismiss.
|
||||
const WRITE_TIMEOUT_MS = 15_000;
|
||||
|
||||
const USAGE_TYPES: { value: FeedbackUsageType; Icon: typeof User; wide?: boolean }[] = [
|
||||
{ value: "personal", Icon: User },
|
||||
{ value: "team_internal", Icon: Users },
|
||||
@@ -97,7 +103,7 @@ export function UsageSurveyOverlay() {
|
||||
|
||||
async function recordSettingsKey(key: string) {
|
||||
const value = new Date().toISOString();
|
||||
await apiPut("/v1/settings", { [key]: value });
|
||||
await withTimeout(apiPut("/v1/settings", { [key]: value }), WRITE_TIMEOUT_MS);
|
||||
setSettings((current) => ({ ...(current ?? {}), [key]: value }));
|
||||
}
|
||||
|
||||
@@ -107,13 +113,16 @@ export function UsageSurveyOverlay() {
|
||||
const answerKey = JSON.stringify({ usageType, importantAreas: [...importantAreas].sort() });
|
||||
try {
|
||||
if (submittedAnswerKeyRef.current !== answerKey) {
|
||||
await submitFeedback({
|
||||
source: "onboarding",
|
||||
surveyId: surveyIdForSource("onboarding"),
|
||||
promptVariant: promptVariantForSource("onboarding"),
|
||||
usageType,
|
||||
importantAreas,
|
||||
});
|
||||
await withTimeout(
|
||||
submitFeedback({
|
||||
source: "onboarding",
|
||||
surveyId: surveyIdForSource("onboarding"),
|
||||
promptVariant: promptVariantForSource("onboarding"),
|
||||
usageType,
|
||||
importantAreas,
|
||||
}),
|
||||
WRITE_TIMEOUT_MS,
|
||||
);
|
||||
submittedAnswerKeyRef.current = answerKey;
|
||||
}
|
||||
await recordSettingsKey("onboarding.usageSurvey.answeredAt");
|
||||
|
||||
@@ -35,6 +35,7 @@ import { useMobile } from "@/hooks/use-mobile";
|
||||
import { apiDelete, apiGet, apiPost, apiPut, clearToken, formatHeaders } from "@/lib/api";
|
||||
import { shouldShowInstallFeedbackCard } from "@/lib/feedback";
|
||||
import { format, plural } from "@/lib/format";
|
||||
import { changedSettings, writableSettings } from "@/lib/settings-payload";
|
||||
import { getCategoryName, getToolDescription, getToolName } from "@/lib/tool-i18n";
|
||||
import { cn, copyToClipboard } from "@/lib/utils";
|
||||
import { useAnalyticsStore } from "@/stores/analytics-store";
|
||||
@@ -529,25 +530,15 @@ function GeneralSection() {
|
||||
|
||||
/* ────────────────────── System ────────────────────── */
|
||||
|
||||
// PUT /v1/settings rejects server-managed read-only keys (instance_id, cookie_secret)
|
||||
// with 400 READONLY_SETTING, and GET returns redacted secrets as the literal "********"
|
||||
// (cookie_secret, oidc_client_secret, siem_webhook_auth). Echoing either back breaks the
|
||||
// save or overwrites a real secret with the mask, so strip both before any bulk save.
|
||||
const READONLY_SETTING_KEYS = new Set(["instance_id", "cookie_secret"]);
|
||||
function writableSettings(settings: Record<string, string>): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(settings).filter(
|
||||
([key, value]) => !READONLY_SETTING_KEYS.has(key) && value !== "********",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function SystemSection() {
|
||||
const { t } = useTranslation();
|
||||
const { role } = useAuth();
|
||||
const analyticsConfig = useAnalyticsStore((s) => s.config);
|
||||
const analyticsConfigLoaded = useAnalyticsStore((s) => s.configLoaded);
|
||||
const [settings, setSettings] = useState<Record<string, string>>({});
|
||||
// Snapshot of the last server state, so a save sends only the fields this tab
|
||||
// changed (never a stale value that could clobber another admin's concurrent edit).
|
||||
const originalSettingsRef = useRef<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveMsg, setSaveMsg] = useState<string | null>(null);
|
||||
@@ -557,15 +548,20 @@ function SystemSection() {
|
||||
|
||||
useEffect(() => {
|
||||
apiGet<{ settings: Record<string, string> }>("/v1/settings")
|
||||
.then((data) => setSettings(data.settings))
|
||||
.then((data) => {
|
||||
setSettings(data.settings);
|
||||
originalSettingsRef.current = data.settings;
|
||||
})
|
||||
.catch(() => {
|
||||
// Fallback defaults if endpoint not ready
|
||||
setSettings({
|
||||
const fallback = {
|
||||
fileUploadLimitMb: "100",
|
||||
defaultTheme: "system",
|
||||
defaultLocale: "en",
|
||||
loginAttemptLimit: "5",
|
||||
});
|
||||
};
|
||||
setSettings(fallback);
|
||||
originalSettingsRef.current = fallback;
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
@@ -596,18 +592,22 @@ function SystemSection() {
|
||||
setSaving(true);
|
||||
setSaveMsg(null);
|
||||
try {
|
||||
await apiPut("/v1/settings", writableSettings(settings));
|
||||
if (settings.analyticsEnabled === "false") {
|
||||
const { optOut } = await import("@/lib/analytics");
|
||||
optOut();
|
||||
} else {
|
||||
// Re-enabling takes effect on the next config refetch / reload.
|
||||
const changed = changedSettings(originalSettingsRef.current, settings);
|
||||
await apiPut("/v1/settings", writableSettings(changed));
|
||||
if ("analyticsEnabled" in changed) {
|
||||
const { optIn, optOut } = await import("@/lib/analytics");
|
||||
if (settings.analyticsEnabled === "false") optOut();
|
||||
else optIn();
|
||||
// Refresh the cached config so this tab converges immediately: optOut()
|
||||
// stops the SDKs but leaves the store enabled, which would keep feedback
|
||||
// surfaces visible (and silently drop their submissions) until a refocus.
|
||||
useAnalyticsStore.getState().fetchConfig();
|
||||
}
|
||||
if (settings.defaultTheme) {
|
||||
const theme = settings.defaultTheme as "light" | "dark" | "system";
|
||||
useThemeStore.getState().setTheme(theme);
|
||||
}
|
||||
originalSettingsRef.current = { ...settings };
|
||||
setSaveMsg(t.settings.system.saveSuccess);
|
||||
} catch {
|
||||
setSaveMsg(t.settings.system.saveFailed);
|
||||
@@ -1076,13 +1076,20 @@ function SecuritySection() {
|
||||
function AdminSecuritySettings() {
|
||||
const { t } = useTranslation();
|
||||
const [settings, setSettings] = useState<Record<string, string>>({});
|
||||
// Snapshot of the last server state; a save sends only the fields this tab changed
|
||||
// so an unrelated edit here can never echo (and revert) another admin's change,
|
||||
// such as an instance-wide analytics opt-out.
|
||||
const originalSettingsRef = useRef<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveMsg, setSaveMsg] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiGet<{ settings: Record<string, string> }>("/v1/settings")
|
||||
.then((data) => setSettings(data.settings))
|
||||
.then((data) => {
|
||||
setSettings(data.settings);
|
||||
originalSettingsRef.current = data.settings;
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
@@ -1095,7 +1102,11 @@ function AdminSecuritySettings() {
|
||||
setSaving(true);
|
||||
setSaveMsg(null);
|
||||
try {
|
||||
await apiPut("/v1/settings", writableSettings(settings));
|
||||
await apiPut(
|
||||
"/v1/settings",
|
||||
writableSettings(changedSettings(originalSettingsRef.current, settings)),
|
||||
);
|
||||
originalSettingsRef.current = { ...settings };
|
||||
setSaveMsg(t.settings.security.securitySettingsSaved);
|
||||
} catch {
|
||||
setSaveMsg(t.settings.security.securitySettingsFailed);
|
||||
|
||||
@@ -67,6 +67,14 @@ export async function initAnalytics(config: AnalyticsConfig): Promise<void> {
|
||||
}
|
||||
|
||||
if (posthog) {
|
||||
// Clear any persisted opt-out from a previous disabled period. opt_out_capturing()
|
||||
// writes a localStorage flag that survives reloads, so without this a browser that
|
||||
// once opted out would stay silent even after the instance re-enables analytics.
|
||||
try {
|
||||
posthog.opt_in_capturing();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// app_version only; no instance_id, so plain events stay person-less.
|
||||
posthog.register({ app_version: (await import("@snapotter/shared")).APP_VERSION });
|
||||
}
|
||||
@@ -160,3 +168,13 @@ export function optOut(): void {
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
/** Reverse a prior optOut() in this tab: resume PostHog capture without a reload. */
|
||||
export function optIn(): void {
|
||||
enabled = true;
|
||||
try {
|
||||
posthog?.opt_in_capturing();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// PUT /v1/settings rejects server-managed read-only keys (instance_id, cookie_secret)
|
||||
// with 400 READONLY_SETTING, and GET returns redacted secrets as the literal "********".
|
||||
// Echoing either back breaks the save or overwrites a real secret with the mask, so strip
|
||||
// both before any bulk save.
|
||||
const READONLY_SETTING_KEYS = new Set(["instance_id", "cookie_secret"]);
|
||||
|
||||
export function writableSettings(settings: Record<string, string>): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(settings).filter(
|
||||
([key, value]) => !READONLY_SETTING_KEYS.has(key) && value !== "********",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Only the keys this tab actually changed from the snapshot it loaded at mount.
|
||||
// Saving the whole settings blob lets a value captured at mount clobber another
|
||||
// admin's concurrent change, most dangerously flipping an instance-wide analytics
|
||||
// opt-out back on when saving an unrelated field.
|
||||
export function changedSettings(
|
||||
original: Record<string, string>,
|
||||
current: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(current)) {
|
||||
if (original[key] !== value) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Reject if `promise` has not settled within `ms`. Guards UI that disables its
|
||||
// controls while a write is in flight: without a ceiling, a request that hangs
|
||||
// (connection black-holed, never errors) would leave the controls disabled forever.
|
||||
export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);
|
||||
});
|
||||
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
Reference in New Issue
Block a user