mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(analytics): build-time bake + telemetry depth (#336)
Bake PostHog + Sentry into the published Docker image (SNAPOTTER_ANALYTICS build arg, codegen script). Delete entire consent system. Move event emission to BullMQ worker. Add cross-tier identity stitching, Sentry performance tracing on both tiers, frontend funnel events. Fix stateful regex bug. 86 files changed, 1593 insertions(+), 3747 deletions(-)
This commit is contained in:
+19
-105
@@ -1,92 +1,32 @@
|
||||
import { ANALYTICS_BAKED } from "@snapotter/shared";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { FastifyRequest } from "fastify";
|
||||
import type { PostHog } from "posthog-node";
|
||||
import { env } from "../config.js";
|
||||
import { db, schema } from "../db/index.js";
|
||||
import { getAuthUser } from "../plugins/auth.js";
|
||||
|
||||
const FILE_EXT_PATTERN =
|
||||
/\.(jpe?g|png|pdf|webp|gif|tiff?|bmp|svg|hei[cf]?|avif|raw|cr2|nef|arw|dng|psd|tga|exr|hdr)\b/gi;
|
||||
const FILE_PATH_PATTERN = /\/(tmp\/workspace|data\/files|data\/ai)\//g;
|
||||
|
||||
let posthogClient: PostHog | null = null;
|
||||
let sentryModule: typeof import("@sentry/node") | null = null;
|
||||
|
||||
export async function initAnalytics(): Promise<void> {
|
||||
if (!env.ANALYTICS_ENABLED || !env.POSTHOG_API_KEY) return;
|
||||
if (!ANALYTICS_BAKED.enabled) return;
|
||||
|
||||
try {
|
||||
const { PostHog } = await import("posthog-node");
|
||||
posthogClient = new PostHog(env.POSTHOG_API_KEY, {
|
||||
host: env.POSTHOG_HOST,
|
||||
flushAt: 20,
|
||||
flushInterval: 30000,
|
||||
});
|
||||
} catch {
|
||||
// posthog-node not available — analytics disabled
|
||||
}
|
||||
|
||||
if (env.SENTRY_DSN) {
|
||||
if (ANALYTICS_BAKED.posthogApiKey) {
|
||||
try {
|
||||
sentryModule = await import("@sentry/node");
|
||||
sentryModule.init({
|
||||
dsn: env.SENTRY_DSN,
|
||||
sendDefaultPii: false,
|
||||
beforeSend(event) {
|
||||
if (event.user) {
|
||||
delete event.user.email;
|
||||
delete event.user.username;
|
||||
}
|
||||
if (event.exception?.values) {
|
||||
for (const ex of event.exception.values) {
|
||||
if (
|
||||
ex.value &&
|
||||
(ex.value.includes("Rate limit exceeded") ||
|
||||
ex.value.includes("Body cannot be empty") ||
|
||||
ex.value.includes("Unsupported Media Type") ||
|
||||
ex.value.includes("Request body size did not match") ||
|
||||
ex.value.includes("Premature close"))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (ex.value) {
|
||||
ex.value = ex.value
|
||||
.replace(FILE_EXT_PATTERN, ".[REDACTED]")
|
||||
.replace(FILE_PATH_PATTERN, "/[REDACTED]/");
|
||||
}
|
||||
if (ex.stacktrace?.frames) {
|
||||
for (const frame of ex.stacktrace.frames) {
|
||||
if (frame.filename) {
|
||||
frame.filename = frame.filename
|
||||
.replace(FILE_EXT_PATTERN, ".[REDACTED]")
|
||||
.replace(FILE_PATH_PATTERN, "/[REDACTED]/");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return event;
|
||||
},
|
||||
beforeBreadcrumb(breadcrumb) {
|
||||
if (breadcrumb.message) {
|
||||
breadcrumb.message = breadcrumb.message
|
||||
.replace(FILE_EXT_PATTERN, ".[REDACTED]")
|
||||
.replace(FILE_PATH_PATTERN, "/[REDACTED]/");
|
||||
}
|
||||
return breadcrumb;
|
||||
},
|
||||
const { PostHog } = await import("posthog-node");
|
||||
posthogClient = new PostHog(ANALYTICS_BAKED.posthogApiKey, {
|
||||
host: ANALYTICS_BAKED.posthogHost,
|
||||
flushAt: 20,
|
||||
flushInterval: 30000,
|
||||
});
|
||||
} catch {
|
||||
// @sentry/node not available
|
||||
// posthog-node not available
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function captureException(error: unknown, request?: FastifyRequest): Promise<void> {
|
||||
export async function captureException(error: unknown): Promise<void> {
|
||||
try {
|
||||
if (!sentryModule) return;
|
||||
if (request && !(await isRequestOptedIn(request))) return;
|
||||
sentryModule.captureException(error);
|
||||
if (!ANALYTICS_BAKED.enabled) return;
|
||||
const Sentry = await import("@sentry/node");
|
||||
Sentry.captureException(error);
|
||||
} catch {
|
||||
// analytics must never throw
|
||||
}
|
||||
@@ -97,11 +37,6 @@ export async function shutdownAnalytics(): Promise<void> {
|
||||
await posthogClient.shutdown();
|
||||
posthogClient = null;
|
||||
}
|
||||
|
||||
if (sentryModule) {
|
||||
await sentryModule.close(2000);
|
||||
sentryModule = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getInstanceId(): Promise<string> {
|
||||
@@ -112,39 +47,18 @@ async function getInstanceId(): Promise<string> {
|
||||
return row?.value ?? "unknown";
|
||||
}
|
||||
|
||||
async function isUserOptedIn(userId: string): Promise<boolean> {
|
||||
if (!env.ANALYTICS_ENABLED) return false;
|
||||
if (userId === "anonymous") return false;
|
||||
const [user] = await db.select().from(schema.users).where(eq(schema.users.id, userId));
|
||||
return user?.analyticsEnabled === true;
|
||||
}
|
||||
|
||||
async function isRequestOptedIn(request: FastifyRequest): Promise<boolean> {
|
||||
if (!env.ANALYTICS_ENABLED) return false;
|
||||
const user = getAuthUser(request);
|
||||
if (!user) return false;
|
||||
if (user.id === "anonymous") {
|
||||
const header = request.headers["x-analytics-consent"];
|
||||
return header === "true";
|
||||
}
|
||||
return await isUserOptedIn(user.id);
|
||||
}
|
||||
|
||||
function shouldSample(): boolean {
|
||||
if (env.ANALYTICS_SAMPLE_RATE >= 1.0) return true;
|
||||
if (env.ANALYTICS_SAMPLE_RATE <= 0.0) return false;
|
||||
return Math.random() < env.ANALYTICS_SAMPLE_RATE;
|
||||
}
|
||||
|
||||
export async function trackEvent(
|
||||
request: FastifyRequest,
|
||||
event: string,
|
||||
properties: Record<string, unknown>,
|
||||
distinctId?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!posthogClient || !(await isRequestOptedIn(request)) || !shouldSample()) return;
|
||||
if (!ANALYTICS_BAKED.enabled || !posthogClient) return;
|
||||
if (ANALYTICS_BAKED.sampleRate < 1.0) {
|
||||
if (ANALYTICS_BAKED.sampleRate <= 0.0 || Math.random() >= ANALYTICS_BAKED.sampleRate) return;
|
||||
}
|
||||
posthogClient.capture({
|
||||
distinctId: await getInstanceId(),
|
||||
distinctId: distinctId ?? (await getInstanceId()),
|
||||
event,
|
||||
properties,
|
||||
});
|
||||
|
||||
@@ -110,14 +110,6 @@ const envSchema = z
|
||||
AUDIT_RETENTION_DAYS: z.coerce.number().default(0),
|
||||
LOG_DIR: z.string().default("./data/logs"),
|
||||
SCRATCH_PATH: z.string().default(""),
|
||||
ANALYTICS_ENABLED: z
|
||||
.enum(["true", "false"])
|
||||
.default("false")
|
||||
.transform((v) => v === "true"),
|
||||
ANALYTICS_SAMPLE_RATE: z.coerce.number().min(0).max(1).default(1.0),
|
||||
POSTHOG_API_KEY: z.string().default(""),
|
||||
POSTHOG_HOST: z.string().default("https://us.i.posthog.com"),
|
||||
SENTRY_DSN: z.string().default(""),
|
||||
DATA_ENCRYPTION_KEY: z.string().default(""),
|
||||
DATA_ENCRYPTION_KEY_PREVIOUS: z.string().default(""),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user