mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix(telemetry): data-quality pass (opt-in noise, onboarding split, file_count, OIDC) (#614)
Five fixes to the PostHog event stream, from an audit of what we actually collect versus what's flowing in. Each one is test-first. ## What changed **Silenced the `$opt_in` noise.** `initAnalytics` called `opt_in_capturing()` on every page load to clear a stale opt-out flag, and posthog-js emits an `$opt_in` event on every call. That was 10k+ events a month (up to 55 per user) carrying no signal: analytics is on by default with an admin opt-out, so there is no per-user consent to record. Both call sites now pass `captureEventName: false`. **Split the onboarding survey out of `feedback_submitted`.** The onboarding usage survey rode the same event as real feedback, so about 93% of "feedback" was actually onboarding profiling. It now emits `onboarding_survey_submitted`, so feedback metrics mean feedback again. **Set `pipeline_executed.file_count`.** It was declared in the properties interface but never populated. A pure `pipelineExecutedProps` helper now derives it (batch size for a batch run, else 1) and is shared by the success and failure paths, which also drops a duplicated payload. **Tracked OIDC login failures.** All six OIDC callback failure branches bumped the Prometheus counter and wrote an audit log but never emitted `auth_login_failed`. A `recordOidcFailure` helper mirrors the password path. **Added `TELEMETRY.md`.** A contributor-facing event dictionary: every event, its properties, where it fires, and the privacy invariants, with the allowlists as source of truth. A drift test fails if any `ANALYTICS_EVENTS` value goes undocumented. I left the published telemetry guide (`apps/docs/guide/telemetry.md`) alone. It is high-level and still accurate, and editing it would pull in the 21-locale stale-gate for no gain. ## Verification - Unit (63 tests): `analytics-events`, `telemetry-doc-drift`, `api/analytics`, `web/analytics`, `worker.behavior` - Integration (41 tests): `oidc-auth`, `feedback` - Full typecheck across all 9 workspaces - Biome clean on the changed files All green locally.
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
# Telemetry event dictionary
|
||||
|
||||
Contributor reference for every analytics event SnapOtter can emit, what each carries, and where it fires. For the user-facing summary and opt-out steps, see the published [telemetry guide](apps/docs/guide/telemetry.md). For the privacy stance, see the in-app privacy policy.
|
||||
|
||||
Product analytics is on by default and set instance-wide by an admin under Settings > System > Privacy. Nothing is sent when it is off.
|
||||
|
||||
## Source of truth
|
||||
|
||||
The code is authoritative; this doc is the map. A drift test (`tests/unit/shared/telemetry-doc-drift.test.ts`) asserts every event name in `ANALYTICS_EVENTS` appears here, so add a row when you add an event.
|
||||
|
||||
- Event names: `packages/shared/src/analytics/events.ts` (`ANALYTICS_EVENTS`)
|
||||
- Server property allowlist: `apps/api/src/lib/analytics-allowlist.ts`
|
||||
- Client property allowlist: the `ALLOWED` map in `apps/web/src/lib/analytics.ts`
|
||||
- Feedback enum values: `packages/shared/src/analytics/feedback.ts`
|
||||
|
||||
## Invariants
|
||||
|
||||
- Every event passes a strict per-event property allowlist before it leaves the process, on both the client and the server. Anything not listed is dropped, so filenames, tool settings, and free text cannot leak.
|
||||
- We never send file names, paths, contents, OCR text, EXIF, extracted document text, IP address, or account identity. The single exception is feedback contact details (email, name, company), and only when the user ticks the contact-consent box.
|
||||
- `instance_id` rides events as a property, not an `identify()` call. Events stay anonymous and person-less while still rolling up per instance.
|
||||
- Autocapture and session replay are off. Exceptions go to Sentry, not PostHog.
|
||||
- One opt-out gate stops all egress: `analyticsEnabled()` on the server, the live `enabled` flag on the client, and a build-time bake (`SNAPOTTER_ANALYTICS=off`) that can strip it entirely.
|
||||
|
||||
## Server events
|
||||
|
||||
Emitted from `apps/api` through `trackEvent()`; properties are filtered by `analytics-allowlist.ts`.
|
||||
|
||||
| Event | Fires when | Key properties |
|
||||
| --- | --- | --- |
|
||||
| `instance_started` | Once per boot | `arch`, `os_platform`, `deploy_mode`, `gpu_present` |
|
||||
| `auth_login` | A login succeeds | `method` (`password` or `oidc`) |
|
||||
| `auth_login_failed` | A login attempt fails | `method` (`password` or `oidc`) |
|
||||
| `tool_used` | A tool job finishes | `tool_id`, `status`, `duration_ms`, `category`, `is_ai_tool`, `is_batch`, `input_format`, `output_format`, `bytes_in`, `bytes_out`, `execution_hint`, `error_code`, `error_kind` |
|
||||
| `pipeline_executed` | A pipeline run finishes | `step_count`, `tool_ids`, `is_batch`, `file_count`, `duration_ms`, `status` |
|
||||
| `ai_bundle_action` | An AI bundle is installed, uninstalled, reset, or imported | `bundle_id`, `action`, `duration_ms` |
|
||||
|
||||
### Feedback events
|
||||
|
||||
Both ride `POST /api/v1/feedback` and go through `cleanFeedbackProperties()`, a cleaner separate from the allowlist above. Enum values live in `feedback.ts`.
|
||||
|
||||
| Event | Fires when | Key properties |
|
||||
| --- | --- | --- |
|
||||
| `feedback_submitted` | A user submits genuine feedback (nav button, tool result, failed job, search miss, admin installer card) | `source`, `sentiment`, `feedback_type`, `message`, `survey_id`, `prompt_variant`, `tool_id`, `search_query`, `job_status`, `error_category`, `contact_ok`, and, only with consent, `contact_email` / `contact_name` / `company` |
|
||||
| `onboarding_survey_submitted` | A user completes the onboarding usage survey (`source: onboarding`) | `usage_type`, `important_areas`, `install_method`, `friction_area`, `survey_id`, `prompt_variant` |
|
||||
|
||||
The onboarding survey is a profiling questionnaire, not feedback, so it gets its own event. Splitting the two keeps onboarding responses from swamping feedback metrics.
|
||||
|
||||
## Client events
|
||||
|
||||
Emitted from `apps/web` through `track()`; properties are filtered by the `ALLOWED` map in `analytics.ts`.
|
||||
|
||||
| Event | Fires when | Key properties |
|
||||
| --- | --- | --- |
|
||||
| `tool_opened` | A tool page opens | `tool_id`, `category`, `modality` |
|
||||
| `file_added` | Files are added | `file_count` |
|
||||
| `tool_started` | Processing starts | `tool_id`, `is_batch`, `file_count` |
|
||||
| `batch_processed` | A batch run finishes | `tool_id`, `file_count`, `status` |
|
||||
| `result_downloaded` | A result is downloaded | `tool_id` |
|
||||
| `result_saved` | A result is saved to the library | `tool_id` |
|
||||
| `search` | A tool search runs | `results_count`, `clicked_tool_id` |
|
||||
| `tool_client_error` | The React error boundary catches a crash | `error_name` |
|
||||
| `ai_bundle_prompted` | An AI install prompt is shown | `bundle_id` |
|
||||
| `editor_opened` | The image editor opens | none |
|
||||
| `editor_tool_used` | An editor tool is selected | `editor_tool` |
|
||||
| `editor_exported` | An editor export runs | `output_format` |
|
||||
| `pipeline_opened` | The Automate page opens | none |
|
||||
| `pipeline_step_added` | A step is added to a pipeline | `tool_id` |
|
||||
| `pipeline_saved` | A pipeline is saved | `step_count` |
|
||||
| `pipeline_template_selected` | A pipeline template is picked | `template_id` |
|
||||
| `sponsor_clicked` | The sponsor link is clicked | none |
|
||||
|
||||
### SDK-generated events
|
||||
|
||||
posthog-js also captures `$pageview` and `$pageleave` on route changes, plus `$web_vitals`. These skip the `track()` allowlist, so the `before_send` hook that strips query strings and fragments from URLs is the boundary for them.
|
||||
+42
-14
@@ -31,6 +31,7 @@ import {
|
||||
getBundleForTool,
|
||||
getOptionalBundleForTool,
|
||||
isToolInputError,
|
||||
type PipelineExecutedProperties,
|
||||
TOOLS,
|
||||
} from "@snapotter/shared";
|
||||
import { type Job, UnrecoverableError, Worker } from "bullmq";
|
||||
@@ -750,6 +751,33 @@ function contentTypeForFilename(name: string): string {
|
||||
return map[ext] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the pipeline_executed analytics payload. Shared by the success and
|
||||
* failure paths so is_batch and file_count are derived in one place. file_count
|
||||
* is the batch size for a batch-finalize job, otherwise 1 for a single-file
|
||||
* pipeline run.
|
||||
*/
|
||||
export function pipelineExecutedProps(
|
||||
data: Pick<ToolJobData, "kind" | "totalFiles">,
|
||||
totalSteps: number,
|
||||
toolIds: string[],
|
||||
durationMs: number,
|
||||
status: "completed" | "failed",
|
||||
) {
|
||||
// `satisfies` (not a return-type annotation) validates the shape against
|
||||
// PipelineExecutedProperties while keeping the inferred anonymous type, which
|
||||
// stays assignable to trackEvent's Record<string, unknown> param (a named
|
||||
// interface would not be).
|
||||
return {
|
||||
step_count: totalSteps,
|
||||
tool_ids: toolIds,
|
||||
is_batch: data.kind === "batch-finalize",
|
||||
file_count: data.totalFiles ?? 1,
|
||||
duration_ms: durationMs,
|
||||
status,
|
||||
} satisfies PipelineExecutedProperties;
|
||||
}
|
||||
|
||||
async function processPipelineFinalize(job: Job<ToolJobData>): Promise<ToolJobResult> {
|
||||
const data = job.data;
|
||||
const startTime = Date.now();
|
||||
@@ -818,13 +846,13 @@ async function processPipelineFinalize(job: Job<ToolJobData>): Promise<ToolJobRe
|
||||
if (analyticsEnabled()) {
|
||||
void trackEvent(
|
||||
ANALYTICS_EVENTS.PIPELINE_EXECUTED,
|
||||
{
|
||||
step_count: totalSteps,
|
||||
tool_ids: steps.map((s) => s.toolId),
|
||||
is_batch: data.kind === "batch-finalize",
|
||||
duration_ms: Date.now() - startTime,
|
||||
status: "failed",
|
||||
},
|
||||
pipelineExecutedProps(
|
||||
data,
|
||||
totalSteps,
|
||||
steps.map((s) => s.toolId),
|
||||
Date.now() - startTime,
|
||||
"failed",
|
||||
),
|
||||
data.analyticsDistinctId,
|
||||
);
|
||||
}
|
||||
@@ -905,13 +933,13 @@ async function processPipelineFinalize(job: Job<ToolJobData>): Promise<ToolJobRe
|
||||
if (analyticsEnabled()) {
|
||||
void trackEvent(
|
||||
ANALYTICS_EVENTS.PIPELINE_EXECUTED,
|
||||
{
|
||||
step_count: totalSteps,
|
||||
tool_ids: steps.map((s) => s.toolId),
|
||||
is_batch: data.kind === "batch-finalize",
|
||||
duration_ms: Date.now() - startTime,
|
||||
status: "completed",
|
||||
},
|
||||
pipelineExecutedProps(
|
||||
data,
|
||||
totalSteps,
|
||||
steps.map((s) => s.toolId),
|
||||
Date.now() - startTime,
|
||||
"completed",
|
||||
),
|
||||
data.analyticsDistinctId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -153,9 +153,15 @@ export async function captureFeedback(
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!analyticsEnabled() || !posthogClient) return;
|
||||
// The onboarding usage survey is a profiling questionnaire, not feedback, so
|
||||
// it gets its own event name and feedback_submitted stays genuine feedback.
|
||||
const event =
|
||||
properties.source === "onboarding"
|
||||
? ANALYTICS_EVENTS.ONBOARDING_SURVEY_SUBMITTED
|
||||
: ANALYTICS_EVENTS.FEEDBACK_SUBMITTED;
|
||||
posthogClient.capture({
|
||||
distinctId: distinctId ?? (await getInstanceId()),
|
||||
event: ANALYTICS_EVENTS.FEEDBACK_SUBMITTED,
|
||||
event,
|
||||
properties: cleanFeedbackProperties(properties),
|
||||
});
|
||||
} catch {
|
||||
|
||||
@@ -102,6 +102,16 @@ function redirectToLogin(reply: FastifyReply, errorCode: string): void {
|
||||
reply.redirect(`/login?error=${errorCode}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a failed OIDC login: bump the Prometheus auth counter and emit the
|
||||
* auth_login_failed analytics event, mirroring the password login path so
|
||||
* OIDC failures are visible in analytics too.
|
||||
*/
|
||||
function recordOidcFailure(): void {
|
||||
authAttempts.inc({ method: "oidc", result: "failure" });
|
||||
void trackEvent(ANALYTICS_EVENTS.AUTH_LOGIN_FAILED, { method: "oidc" });
|
||||
}
|
||||
|
||||
// ── OIDC Routes ───────────────────────────────────────────────────
|
||||
|
||||
export async function oidcRoutes(app: FastifyInstance): Promise<void> {
|
||||
@@ -202,7 +212,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
|
||||
{ error: query.error, description: query.error_description },
|
||||
"OIDC IdP returned error",
|
||||
);
|
||||
authAttempts.inc({ method: "oidc", result: "failure" });
|
||||
recordOidcFailure();
|
||||
await audit("OIDC_LOGIN_FAILED", {
|
||||
reason: sanitizeAuditInput(String(query.error)),
|
||||
});
|
||||
@@ -233,7 +243,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
|
||||
});
|
||||
} catch (err) {
|
||||
request.log.error({ err }, "OIDC token exchange failed");
|
||||
authAttempts.inc({ method: "oidc", result: "failure" });
|
||||
recordOidcFailure();
|
||||
await audit("OIDC_LOGIN_FAILED", { reason: "token_exchange_failed" });
|
||||
return redirectToLogin(reply, "oidc_auth_failed");
|
||||
}
|
||||
@@ -242,7 +252,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
|
||||
const claims = tokenResponse.claims();
|
||||
if (!claims) {
|
||||
request.log.error("OIDC callback: no ID token claims");
|
||||
authAttempts.inc({ method: "oidc", result: "failure" });
|
||||
recordOidcFailure();
|
||||
await audit("OIDC_LOGIN_FAILED", { reason: "no_id_token" });
|
||||
return redirectToLogin(reply, "oidc_auth_failed");
|
||||
}
|
||||
@@ -270,7 +280,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
|
||||
});
|
||||
|
||||
if (result.action === "denied" || !result.user) {
|
||||
authAttempts.inc({ method: "oidc", result: "failure" });
|
||||
recordOidcFailure();
|
||||
if (result.deniedReason === "user_limit_reached") {
|
||||
return redirectToLogin(reply, "oidc_user_limit_reached");
|
||||
}
|
||||
@@ -294,7 +304,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
|
||||
{ err, userId: resolvedUser.id },
|
||||
"OIDC callback: failed to read MFA enrollment status",
|
||||
);
|
||||
authAttempts.inc({ method: "oidc", result: "failure" });
|
||||
recordOidcFailure();
|
||||
await audit("OIDC_LOGIN_FAILED", {
|
||||
userId: resolvedUser.id,
|
||||
username: resolvedUser.username,
|
||||
@@ -328,7 +338,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
|
||||
}
|
||||
|
||||
if (mfaOutcome === "enrollment_required") {
|
||||
authAttempts.inc({ method: "oidc", result: "failure" });
|
||||
recordOidcFailure();
|
||||
await audit("OIDC_LOGIN_FAILED", {
|
||||
userId: resolvedUser.id,
|
||||
username: resolvedUser.username,
|
||||
|
||||
@@ -89,8 +89,11 @@ export async function initAnalytics(config: AnalyticsConfig): Promise<void> {
|
||||
// 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.
|
||||
// captureEventName: false suppresses posthog-js's default $opt_in event, which
|
||||
// otherwise fires once per page load here (pure noise: analytics is on by
|
||||
// default with an admin opt-out, so there is no per-user consent to record).
|
||||
try {
|
||||
posthog.opt_in_capturing();
|
||||
posthog.opt_in_capturing({ captureEventName: false });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -194,7 +197,9 @@ export function optOut(): void {
|
||||
export function optIn(): void {
|
||||
enabled = true;
|
||||
try {
|
||||
posthog?.opt_in_capturing();
|
||||
// captureEventName: false: resuming capture is not a per-user consent signal
|
||||
// in this product, so don't emit a noisy $opt_in event (see initAnalytics).
|
||||
posthog?.opt_in_capturing({ captureEventName: false });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ export const ANALYTICS_EVENTS = {
|
||||
AI_BUNDLE_PROMPTED: "ai_bundle_prompted",
|
||||
BATCH_PROCESSED: "batch_processed",
|
||||
FEEDBACK_SUBMITTED: "feedback_submitted",
|
||||
// The onboarding usage survey (source: "onboarding") is a profiling survey,
|
||||
// not feedback. It rides the same /v1/feedback endpoint but is emitted under
|
||||
// its own event name so feedback_submitted stays genuine feedback only.
|
||||
ONBOARDING_SURVEY_SUBMITTED: "onboarding_survey_submitted",
|
||||
SPONSOR_CLICKED: "sponsor_clicked",
|
||||
INSTANCE_STARTED: "instance_started",
|
||||
EDITOR_OPENED: "editor_opened",
|
||||
|
||||
@@ -12,11 +12,19 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { env } from "../../../apps/api/src/config.js";
|
||||
import { db, schema } from "../../../apps/api/src/db/index.js";
|
||||
import { buildTestApp, loginAsAdmin, type TestApp } from "../test-server.js";
|
||||
|
||||
// trackEvent is mocked so OIDC failure analytics can be asserted without a
|
||||
// baked PostHog client; every other analytics export stays real.
|
||||
const trackEventSpy = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
vi.mock("../../../apps/api/src/lib/analytics.js", async (importOriginal) => {
|
||||
const actual: Record<string, unknown> = await importOriginal();
|
||||
return { ...actual, trackEvent: trackEventSpy };
|
||||
});
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
let testApp: TestApp;
|
||||
@@ -616,6 +624,7 @@ describe("OIDC callback edge cases", () => {
|
||||
const state = redirectUrl.searchParams.get("state");
|
||||
|
||||
// Simulate IdP returning an error
|
||||
trackEventSpy.mockClear();
|
||||
const callbackRes = await oidcApp.app.inject({
|
||||
method: "GET",
|
||||
url: `/api/auth/oidc/callback?error=access_denied&error_description=User+denied&state=${state}`,
|
||||
@@ -625,6 +634,9 @@ describe("OIDC callback edge cases", () => {
|
||||
expect(callbackRes.statusCode).toBe(302);
|
||||
const location = callbackRes.headers.location as string;
|
||||
expect(location).toContain("/login?error=oidc_auth_failed");
|
||||
// The failed attempt is recorded as auth_login_failed (parity with the
|
||||
// password path) so OIDC failures aren't invisible in analytics.
|
||||
expect(trackEventSpy).toHaveBeenCalledWith("auth_login_failed", { method: "oidc" });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -295,6 +295,41 @@ describe("captureFeedback", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("routes an onboarding survey to its own event, not feedback_submitted", async () => {
|
||||
bakedConfig.enabled = true;
|
||||
bakedConfig.posthogApiKey = "phc_test_key";
|
||||
await mod.initAnalytics();
|
||||
|
||||
await mod.captureFeedback(
|
||||
{
|
||||
source: "onboarding",
|
||||
survey_id: "onboarding-usage-v1",
|
||||
contact_ok: false,
|
||||
usage_type: "personal",
|
||||
},
|
||||
"distinct-onboarding",
|
||||
);
|
||||
|
||||
expect(mockCapture).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
distinctId: "distinct-onboarding",
|
||||
event: "onboarding_survey_submitted",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps genuine feedback sources on the feedback_submitted event", async () => {
|
||||
bakedConfig.enabled = true;
|
||||
bakedConfig.posthogApiKey = "phc_test_key";
|
||||
await mod.initAnalytics();
|
||||
|
||||
await mod.captureFeedback({ source: "global", contact_ok: false, message: "A message" });
|
||||
|
||||
expect(mockCapture).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ event: "feedback_submitted" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops an empty important_areas array instead of forwarding it", async () => {
|
||||
bakedConfig.enabled = true;
|
||||
bakedConfig.posthogApiKey = "phc_test_key";
|
||||
|
||||
@@ -252,3 +252,40 @@ describe("worker result payload behavior", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("pipelineExecutedProps", () => {
|
||||
it("reports the batch file count for a batch-finalize pipeline", async () => {
|
||||
const { pipelineExecutedProps } = await loadWorker();
|
||||
|
||||
expect(
|
||||
pipelineExecutedProps(
|
||||
{ kind: "batch-finalize", totalFiles: 5 },
|
||||
3,
|
||||
["resize", "compress", "watermark"],
|
||||
1200,
|
||||
"completed",
|
||||
),
|
||||
).toEqual({
|
||||
step_count: 3,
|
||||
tool_ids: ["resize", "compress", "watermark"],
|
||||
is_batch: true,
|
||||
file_count: 5,
|
||||
duration_ms: 1200,
|
||||
status: "completed",
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults file_count to 1 for a single-file pipeline-finalize", async () => {
|
||||
const { pipelineExecutedProps } = await loadWorker();
|
||||
|
||||
expect(
|
||||
pipelineExecutedProps(
|
||||
{ kind: "pipeline-finalize" },
|
||||
2,
|
||||
["grayscale", "resize"],
|
||||
800,
|
||||
"failed",
|
||||
),
|
||||
).toMatchObject({ is_batch: false, file_count: 1, status: "failed" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,8 @@ import { ANALYTICS_EVENTS } from "@snapotter/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("ANALYTICS_EVENTS", () => {
|
||||
it("has exactly 24 event keys", () => {
|
||||
expect(Object.keys(ANALYTICS_EVENTS)).toHaveLength(24);
|
||||
it("has exactly 25 event keys", () => {
|
||||
expect(Object.keys(ANALYTICS_EVENTS)).toHaveLength(25);
|
||||
});
|
||||
|
||||
it("contains the expected keys", () => {
|
||||
@@ -20,6 +20,7 @@ describe("ANALYTICS_EVENTS", () => {
|
||||
expect(ANALYTICS_EVENTS).toHaveProperty("AI_BUNDLE_PROMPTED");
|
||||
expect(ANALYTICS_EVENTS).toHaveProperty("BATCH_PROCESSED");
|
||||
expect(ANALYTICS_EVENTS).toHaveProperty("FEEDBACK_SUBMITTED");
|
||||
expect(ANALYTICS_EVENTS).toHaveProperty("ONBOARDING_SURVEY_SUBMITTED");
|
||||
expect(ANALYTICS_EVENTS).toHaveProperty("SPONSOR_CLICKED");
|
||||
expect(ANALYTICS_EVENTS).toHaveProperty("INSTANCE_STARTED");
|
||||
expect(ANALYTICS_EVENTS).toHaveProperty("EDITOR_OPENED");
|
||||
@@ -59,6 +60,10 @@ describe("ANALYTICS_EVENTS", () => {
|
||||
expect(ANALYTICS_EVENTS.FEEDBACK_SUBMITTED).toBe("feedback_submitted");
|
||||
});
|
||||
|
||||
it("ONBOARDING_SURVEY_SUBMITTED has the correct snake_case value", () => {
|
||||
expect(ANALYTICS_EVENTS.ONBOARDING_SURVEY_SUBMITTED).toBe("onboarding_survey_submitted");
|
||||
});
|
||||
|
||||
it("INSTANCE_STARTED has the correct snake_case value", () => {
|
||||
expect(ANALYTICS_EVENTS.INSTANCE_STARTED).toBe("instance_started");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// @vitest-environment node
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { ANALYTICS_EVENTS } from "@snapotter/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// Keep the contributor-facing event dictionary honest: every analytics event in
|
||||
// ANALYTICS_EVENTS must be documented in TELEMETRY.md as a `code-span`. Adding an
|
||||
// event without documenting it fails here.
|
||||
describe("TELEMETRY.md event dictionary", () => {
|
||||
const doc = readFileSync(
|
||||
fileURLToPath(new URL("../../../TELEMETRY.md", import.meta.url)),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
it("documents every ANALYTICS_EVENTS value", () => {
|
||||
const undocumented = Object.values(ANALYTICS_EVENTS).filter(
|
||||
(event) => !doc.includes(`\`${event}\``),
|
||||
);
|
||||
expect(undocumented).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -4,14 +4,16 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vites
|
||||
const mockInit = vi.fn(() => ({
|
||||
capture: mockCapture,
|
||||
startSessionRecording: vi.fn(),
|
||||
opt_in_capturing: vi.fn(),
|
||||
opt_in_capturing: mockOptInCapturing,
|
||||
opt_out_capturing: vi.fn(),
|
||||
has_opted_out_capturing: vi.fn(() => false),
|
||||
reset: vi.fn(),
|
||||
register: vi.fn(),
|
||||
get_distinct_id: vi.fn(() => "test-distinct-id"),
|
||||
persistence: { disabled: false },
|
||||
}));
|
||||
const mockCapture = vi.fn();
|
||||
const mockOptInCapturing = vi.fn();
|
||||
|
||||
vi.mock("posthog-js", () => ({
|
||||
__esModule: true,
|
||||
@@ -68,6 +70,7 @@ let mod: AnalyticsModule;
|
||||
beforeEach(async () => {
|
||||
mockInit.mockClear();
|
||||
mockCapture.mockClear();
|
||||
mockOptInCapturing.mockClear();
|
||||
mockSentryInit.mockClear();
|
||||
vi.resetModules();
|
||||
mod = await import("../../../apps/web/src/lib/analytics");
|
||||
@@ -179,6 +182,23 @@ describe("analytics lib (baked model)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("opt-in capturing", () => {
|
||||
// opt_in_capturing() clears a stale persisted opt-out flag, but posthog-js
|
||||
// emits a noisy $opt_in event on every call by default. We fire it once per
|
||||
// page load, so it must suppress that event (captureEventName: false).
|
||||
it("suppresses the $opt_in event when clearing a stale opt-out on init", async () => {
|
||||
await mod.initAnalytics(enabledConfig);
|
||||
expect(mockOptInCapturing).toHaveBeenCalledWith({ captureEventName: false });
|
||||
});
|
||||
|
||||
it("suppresses the $opt_in event when resuming capture via optIn()", async () => {
|
||||
await mod.initAnalytics(enabledConfig);
|
||||
mockOptInCapturing.mockClear();
|
||||
mod.optIn();
|
||||
expect(mockOptInCapturing).toHaveBeenCalledWith({ captureEventName: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sentry beforeSend callback", () => {
|
||||
async function getBeforeSend() {
|
||||
mockSentryInit.mockClear();
|
||||
|
||||
Reference in New Issue
Block a user