feat(feedback): gate onboarding survey on first processing, add prompt lifecycle events (#615)

Defers the onboarding usage survey to the instance's first successful processing (the worker writes a one-time onboarding.firstProcessedAt marker and the overlay gates on it), so it reaches engaged users instead of first-landing visitors.

Replaces the two questions telemetry already answers (modality preference from tool_used, install method from instance_started) with what it can't infer: prior tool, self-host motivation, and discovery source.

Adds feedback_prompt_shown and feedback_prompt_dismissed on all five feedback surfaces (usage survey, per-job prompt, admin install card, global nav dialog, search-miss) so skip and completion rates are measurable, not just submissions. New survey strings translated into all 20 non-English locales.
This commit is contained in:
SnapOtter
2026-07-21 16:31:30 +00:00
committed by GitHub
parent b20bca3c3c
commit 129e42b95c
41 changed files with 950 additions and 209 deletions
+9
View File
@@ -31,6 +31,7 @@ import {
getBundleForTool,
getOptionalBundleForTool,
isToolInputError,
ONBOARDING_FIRST_PROCESSED_KEY,
type PipelineExecutedProperties,
TOOLS,
} from "@snapotter/shared";
@@ -53,6 +54,7 @@ import {
} from "../lib/object-storage.js";
import { OCR_MAX_ENCODED_INPUT_BYTES } from "../lib/ocr-limits.js";
import { SCRUB_PDF_PRODUCER_TOOLS, scrubPdfProducer } from "../lib/pdf-producer.js";
import { setSettingIfAbsent } from "../lib/settings-helpers.js";
import { timeoutMessage } from "../lib/timeout.js";
import { InputValidationError } from "../modality/contract.js";
import {
@@ -493,6 +495,13 @@ async function processToolJob(job: Job<ToolJobData>): Promise<ToolJobResult> {
},
data.analyticsDistinctId,
);
// Mark the instance's first successful processing so the onboarding
// survey only appears once the admin has produced a real result
// (shouldShowUsageSurvey gate in web feedback.ts). First-write-wins, so
// the timestamp reflects the genuine first job and later jobs no-op.
void setSettingIfAbsent(ONBOARDING_FIRST_PROCESSED_KEY, new Date().toISOString()).catch(
() => {},
);
}
// Record queue wait time and completion on the OTel span
+11
View File
@@ -2,10 +2,13 @@ import {
ANALYTICS_BAKED,
ANALYTICS_EVENTS,
APP_VERSION,
type FeedbackDiscoverySource,
type FeedbackErrorCategory,
type FeedbackFrictionArea,
type FeedbackImportantArea,
type FeedbackInstallMethod,
type FeedbackPriorTool,
type FeedbackSelfHostMotivation,
type FeedbackSentiment,
type FeedbackSource,
type FeedbackSurveyId,
@@ -38,6 +41,11 @@ export interface FeedbackEventProperties {
usage_type?: FeedbackUsageType;
important_areas?: FeedbackImportantArea[];
friction_area?: FeedbackFrictionArea;
// Onboarding survey (telemetry-blind) answers: what they used before, why they
// self-host, and how they found SnapOtter. See analytics/feedback.ts.
prior_tool?: FeedbackPriorTool;
selfhost_motivation?: FeedbackSelfHostMotivation;
discovery_source?: FeedbackDiscoverySource;
error_category?: FeedbackErrorCategory;
}
@@ -138,6 +146,9 @@ function cleanFeedbackProperties(properties: FeedbackEventProperties): Record<st
copyString("install_method");
copyString("usage_type");
copyString("friction_area");
copyString("prior_tool");
copyString("selfhost_motivation");
copyString("discovery_source");
copyString("error_category");
if (properties.important_areas?.length) {
+10
View File
@@ -15,6 +15,16 @@ export async function upsertSetting(key: string, value: string): Promise<void> {
});
}
/**
* Insert a setting only if the key does not already exist (first-write-wins).
* Uses ON CONFLICT DO NOTHING, so repeated calls are cheap no-ops after the
* first and the original value is preserved. Used for one-time markers like the
* instance's first successful processing that gate the onboarding survey.
*/
export async function setSettingIfAbsent(key: string, value: string): Promise<void> {
await db.insert(schema.settings).values({ key, value }).onConflictDoNothing();
}
/**
* Read a numeric setting from the DB `settings` table.
* Returns `defaultValue` when the key is missing, non-numeric, or on DB error.
+9
View File
@@ -1,8 +1,11 @@
import {
FEEDBACK_DISCOVERY_SOURCE_VALUES,
FEEDBACK_ERROR_CATEGORY_VALUES,
FEEDBACK_FRICTION_AREA_VALUES,
FEEDBACK_IMPORTANT_AREA_VALUES,
FEEDBACK_INSTALL_METHOD_VALUES,
FEEDBACK_PRIOR_TOOL_VALUES,
FEEDBACK_SELFHOST_MOTIVATION_VALUES,
FEEDBACK_SENTIMENT_VALUES,
FEEDBACK_SOURCE_VALUES,
FEEDBACK_SURVEY_ID_VALUES,
@@ -58,6 +61,9 @@ const feedbackBodySchema = z
usageType: z.enum(FEEDBACK_USAGE_TYPE_VALUES).optional(),
importantAreas: z.array(z.enum(FEEDBACK_IMPORTANT_AREA_VALUES)).max(5).optional(),
frictionArea: z.enum(FEEDBACK_FRICTION_AREA_VALUES).optional(),
priorTool: z.enum(FEEDBACK_PRIOR_TOOL_VALUES).optional(),
selfHostMotivation: z.enum(FEEDBACK_SELFHOST_MOTIVATION_VALUES).optional(),
discoverySource: z.enum(FEEDBACK_DISCOVERY_SOURCE_VALUES).optional(),
errorCategory: z.enum(FEEDBACK_ERROR_CATEGORY_VALUES).optional(),
})
.superRefine((value, ctx) => {
@@ -97,6 +103,9 @@ function toPostHogProperties(body: z.infer<typeof feedbackBodySchema>): Feedback
usage_type: body.usageType,
important_areas: body.importantAreas,
friction_area: body.frictionArea,
prior_tool: body.priorTool,
selfhost_motivation: body.selfHostMotivation,
discovery_source: body.discoverySource,
error_category: body.errorCategory,
};
}