From 57967fc00a6a34466f9b5e6a5285b89d71d3216e Mon Sep 17 00:00:00 2001 From: egeozin <14356218+egeozin@users.noreply.github.com> Date: Sun, 7 Jun 2026 14:17:46 +0000 Subject: [PATCH] Add first-use measurement --- hocuspocus-server/src/index.ts | 64 +++++++++++++++++++ .../app/(main)/api/anon-share/create/route.ts | 14 ++++ .../(main)/api/auth/github/callback/route.ts | 13 ++++ web/src/app/(main)/api/github/push/route.ts | 16 +++++ web/src/app/(main)/api/github/sync/route.ts | 30 +++++++++ .../app/(main)/api/repo-share/create/route.ts | 14 ++++ web/src/app/(main)/api/rpc/route.ts | 20 ++++++ .../api/public/repo-share/[token]/route.ts | 15 +++++ .../app/api/public/share/[shareId]/route.ts | 14 ++++ .../components/with-md/anon-share-shell.tsx | 17 +++++ .../components/with-md/repo-share-shell.tsx | 18 ++++++ .../__tests__/first-use-events.test.ts | 37 +++++++++++ web/src/lib/with-md/first-use-client.ts | 35 ++++++++++ web/src/lib/with-md/first-use-events.ts | 30 +++++++++ web/src/lib/with-md/first-use-server.ts | 64 +++++++++++++++++++ 15 files changed, 401 insertions(+) create mode 100644 web/src/lib/with-md/__tests__/first-use-events.test.ts create mode 100644 web/src/lib/with-md/first-use-client.ts create mode 100644 web/src/lib/with-md/first-use-events.ts create mode 100644 web/src/lib/with-md/first-use-server.ts diff --git a/hocuspocus-server/src/index.ts b/hocuspocus-server/src/index.ts index 90327b8..b37f999 100644 --- a/hocuspocus-server/src/index.ts +++ b/hocuspocus-server/src/index.ts @@ -51,6 +51,8 @@ const INLINE_REALTIME_MAX_BYTES = (() => { const OVERSIZE_REPORT_INTERVAL_MS = 15_000; const OVERSIZE_REPORT_DELTA_BYTES = 8 * 1024; const LOG_THROTTLE_MS = 10_000; +const FIRST_USE_CAPTURE_TIMEOUT_MS = 1500; +const FIRST_USE_SAVE_COMPLETED_EVENT = 'withmd_first_use_save_completed'; const textEncoder = new TextEncoder(); const lastLogAtByKey = new Map(); const oversizedReportByDoc = new Map(); @@ -79,6 +81,67 @@ interface PersistNormalizationMetadata { strippedLeadingPlaceholders: boolean; } +function envFlagEnabled(value: string | undefined): boolean { + if (!value) return false; + const normalized = value.trim().toLowerCase(); + return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'; +} + +function posthogConfig() { + const enabled = envFlagEnabled(process.env.POSTHOG_ENABLED) + || envFlagEnabled(process.env.NEXT_PUBLIC_POSTHOG_ENABLED); + const token = (process.env.POSTHOG_TOKEN ?? process.env.NEXT_PUBLIC_POSTHOG_TOKEN ?? '').trim(); + const host = (process.env.POSTHOG_HOST ?? process.env.NEXT_PUBLIC_POSTHOG_HOST ?? 'https://us.i.posthog.com').trim(); + if (!enabled || !token) return null; + return { token, host: host.replace(/\/+$/, '') }; +} + +function firstUseFlowForDocument(documentName: string): 'anonymous_share' | 'github_workspace' { + return documentName.startsWith('share:') ? 'anonymous_share' : 'github_workspace'; +} + +function firstUseDistinctIdForDocument(documentName: string): string { + if (documentName.startsWith('share:')) { + return `anon:${documentName.slice('share:'.length)}`; + } + return `md-file:${documentName}`; +} + +async function captureRealtimeSave(documentName: string, persistPath: string, markdownBytes: number, yjsBytes: number) { + if (persistPath !== 'normal') return; + const config = posthogConfig(); + if (!config) return; + + try { + const flow = firstUseFlowForDocument(documentName); + await fetch(`${config.host}/capture/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + api_key: config.token, + event: FIRST_USE_SAVE_COMPLETED_EVENT, + distinct_id: firstUseDistinctIdForDocument(documentName), + properties: { + flow, + product: 'with.md', + measurement_area: 'first_use', + save_surface: 'realtime_editor', + document_kind: flow === 'anonymous_share' ? 'anonymous_share' : 'workspace_file', + persist_path: persistPath, + size_bytes: markdownBytes, + yjs_bytes: yjsBytes, + }, + }), + signal: AbortSignal.timeout(FIRST_USE_CAPTURE_TIMEOUT_MS), + }); + } catch (error) { + console.warn( + '[with-md:first-use-analytics] realtime save capture failed', + error instanceof Error ? error.message : 'unknown error', + ); + } +} + if (!CONVEX_HTTP || !INTERNAL_SECRET) { // Keep startup explicit to avoid silent misconfiguration. console.warn( @@ -901,6 +964,7 @@ const server = Server.configure({ if (typeof response?.documentVersion === 'string') { loadedVersionByDoc.set(documentName, response.documentVersion); } + void captureRealtimeSave(documentName, persistPath, markdownBytes, persistedYjsBytes); const normalizedTag = payload.normalized ? ` normalized=true repeats=${payload.repeats} strippedPlaceholders=${payload.strippedLeadingPlaceholders ? 'true' : 'false'}` : ''; diff --git a/web/src/app/(main)/api/anon-share/create/route.ts b/web/src/app/(main)/api/anon-share/create/route.ts index 41fe020..a2aa684 100644 --- a/web/src/app/(main)/api/anon-share/create/route.ts +++ b/web/src/app/(main)/api/anon-share/create/route.ts @@ -3,6 +3,8 @@ import { randomBytes, createHash } from 'node:crypto'; import { NextRequest, NextResponse } from 'next/server'; import { F, mutateConvex } from '@/lib/with-md/convex-client'; +import { FIRST_USE_EVENTS } from '@/lib/with-md/first-use-events'; +import { captureFirstUseServerEvent } from '@/lib/with-md/first-use-server'; const MAX_UPLOAD_BYTES = 1024 * 1024; const MAX_CREATES_PER_DAY_PER_IP = 20; @@ -126,6 +128,18 @@ export async function POST(request: NextRequest) { const viewUrl = `${origin}/s/${encodeURIComponent(shareId)}`; const editUrl = `${viewUrl}?edit=${encodeURIComponent(editSecret)}`; + await captureFirstUseServerEvent({ + event: FIRST_USE_EVENTS.shareCreated, + flow: 'anonymous_share', + distinctId: `anon:${ipHash}`, + properties: { + share_id: shareId, + size_bytes: sizeBytes, + has_initial_content: normalizedContent.trim().length > 0, + file_extension: fileName.toLowerCase().endsWith('.markdown') ? 'markdown' : 'md', + }, + }); + return NextResponse.json({ ok: true, shareId, diff --git a/web/src/app/(main)/api/auth/github/callback/route.ts b/web/src/app/(main)/api/auth/github/callback/route.ts index 66e69e5..edfe1cd 100644 --- a/web/src/app/(main)/api/auth/github/callback/route.ts +++ b/web/src/app/(main)/api/auth/github/callback/route.ts @@ -2,6 +2,8 @@ import { cookies } from 'next/headers'; import { NextRequest, NextResponse } from 'next/server'; import { F, mutateConvex } from '@/lib/with-md/convex-client'; +import { FIRST_USE_EVENTS } from '@/lib/with-md/first-use-events'; +import { captureFirstUseServerEvent } from '@/lib/with-md/first-use-server'; import { getSession } from '@/lib/with-md/session'; export async function GET(req: NextRequest) { @@ -78,6 +80,17 @@ export async function GET(req: NextRequest) { session.avatarUrl = ghUser.avatar_url; await session.save(); + await captureFirstUseServerEvent({ + event: FIRST_USE_EVENTS.githubConnected, + flow: 'github_workspace', + distinctId: userId, + properties: { + github_user_id: ghUser.id, + github_login_present: Boolean(ghUser.login), + auth_surface: state.endsWith(':popup') ? 'popup' : 'redirect', + }, + }); + // Check if this was a popup OAuth flow (state ends with ":popup") const isPopup = state.endsWith(':popup'); diff --git a/web/src/app/(main)/api/github/push/route.ts b/web/src/app/(main)/api/github/push/route.ts index c0cb3fd..1ac8bfb 100644 --- a/web/src/app/(main)/api/github/push/route.ts +++ b/web/src/app/(main)/api/github/push/route.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { F, mutateConvex, queryConvex } from '@/lib/with-md/convex-client'; +import { FIRST_USE_EVENTS } from '@/lib/with-md/first-use-events'; +import { captureFirstUseServerEvent } from '@/lib/with-md/first-use-server'; import { canAccessRepoInInstallation } from '@/lib/with-md/github-access'; import { createCommitWithFiles, fetchMdTree, getInstallationToken, getRepoInstallationId } from '@/lib/with-md/github'; import { getSessionOrNull } from '@/lib/with-md/session'; @@ -175,6 +177,20 @@ export async function POST(req: NextRequest) { summary: pushSummary, }); + await captureFirstUseServerEvent({ + event: FIRST_USE_EVENTS.pushBackCompleted, + flow: 'github_workspace', + distinctId: session.userId, + properties: { + repo_id: body.repoId, + files_count: files.length, + updates_count: updates.length, + deletions_count: deletions.length, + branch: effectiveBranch, + pushed: files.length > 0, + }, + }); + return NextResponse.json({ pushed: files.length, commitSha }); } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error'; diff --git a/web/src/app/(main)/api/github/sync/route.ts b/web/src/app/(main)/api/github/sync/route.ts index ea4eb98..8ec4620 100644 --- a/web/src/app/(main)/api/github/sync/route.ts +++ b/web/src/app/(main)/api/github/sync/route.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { F, mutateConvex } from '@/lib/with-md/convex-client'; +import { FIRST_USE_EVENTS } from '@/lib/with-md/first-use-events'; +import { captureFirstUseServerEvent } from '@/lib/with-md/first-use-server'; import { canAccessRepoInInstallation } from '@/lib/with-md/github-access'; import { fetchBlobContent, @@ -128,6 +130,18 @@ export async function POST(req: NextRequest) { } } + await captureFirstUseServerEvent({ + event: FIRST_USE_EVENTS.repoSynced, + flow: 'github_workspace', + distinctId: session.userId, + properties: { + repo_id: repoId, + files_count: forceFiles.length, + sync_surface: 'github_force_sync', + active_branch: effectiveBranch, + }, + }); + return NextResponse.json({ repoId, filesCount: forceFiles.length, @@ -204,6 +218,22 @@ export async function POST(req: NextRequest) { ].join(' '), }); + await captureFirstUseServerEvent({ + event: FIRST_USE_EVENTS.repoSynced, + flow: 'github_workspace', + distinctId: session.userId, + properties: { + repo_id: repoId, + files_count: filesCount, + deleted_count: missingResult.deletedCount ?? 0, + cancelled_queue_count: missingResult.cancelledQueueCount ?? 0, + preserved_local_only_count: missingResult.preservedLocalOnlyCount ?? 0, + skipped_count: skippedPaths.length, + sync_surface: 'github_sync', + active_branch: effectiveBranch, + }, + }); + return NextResponse.json({ repoId, filesCount, diff --git a/web/src/app/(main)/api/repo-share/create/route.ts b/web/src/app/(main)/api/repo-share/create/route.ts index aef586f..7d15691 100644 --- a/web/src/app/(main)/api/repo-share/create/route.ts +++ b/web/src/app/(main)/api/repo-share/create/route.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { F, mutateConvex, queryConvex } from '@/lib/with-md/convex-client'; +import { FIRST_USE_EVENTS } from '@/lib/with-md/first-use-events'; +import { captureFirstUseServerEvent } from '@/lib/with-md/first-use-server'; import { getSessionOrNull } from '@/lib/with-md/session'; import { generateRepoShareEditSecret, @@ -94,6 +96,18 @@ export async function POST(request: NextRequest) { const viewUrl = repoShareViewUrl(origin, shortId); const editUrl = repoShareEditUrl(origin, shortId, editSecret); + await captureFirstUseServerEvent({ + event: FIRST_USE_EVENTS.shareCreated, + flow: 'github_workspace', + distinctId: session.userId, + properties: { + share_id: shortId, + md_file_id: mdFileId, + repo_id: file.repoId, + share_surface: 'workspace_file', + }, + }); + return NextResponse.json({ ok: true, viewUrl, diff --git a/web/src/app/(main)/api/rpc/route.ts b/web/src/app/(main)/api/rpc/route.ts index 7c42129..ff41d6a 100644 --- a/web/src/app/(main)/api/rpc/route.ts +++ b/web/src/app/(main)/api/rpc/route.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { F, mutateConvex, queryConvex } from '@/lib/with-md/convex-client'; +import { FIRST_USE_EVENTS } from '@/lib/with-md/first-use-events'; +import { captureFirstUseServerEvent } from '@/lib/with-md/first-use-server'; import { getSessionOrNull, type SessionData } from '@/lib/with-md/session'; // Only expose the subset of functions the browser client actually uses. @@ -292,6 +294,24 @@ export async function POST(request: NextRequest) { result = await mutateConvex(body.fn, authorized.args); } + if ( + body.fn === F.mutations.mdFilesSaveSource + && result + && typeof result === 'object' + && (result as { changed?: unknown }).changed === true + ) { + await captureFirstUseServerEvent({ + event: FIRST_USE_EVENTS.saveCompleted, + flow: 'github_workspace', + distinctId: session.userId, + properties: { + save_surface: 'workspace_source', + md_file_id: String(authorized.args.mdFileId ?? ''), + github_login_present: Boolean(session.githubLogin), + }, + }); + } + return NextResponse.json({ ok: true, result }); } catch (error) { console.error('[rpc]', body.fn, error); diff --git a/web/src/app/api/public/repo-share/[token]/route.ts b/web/src/app/api/public/repo-share/[token]/route.ts index 1cfcb7e..9551261 100644 --- a/web/src/app/api/public/repo-share/[token]/route.ts +++ b/web/src/app/api/public/repo-share/[token]/route.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { F, mutateConvex, queryConvex } from '@/lib/with-md/convex-client'; +import { FIRST_USE_EVENTS } from '@/lib/with-md/first-use-events'; +import { captureFirstUseServerEvent } from '@/lib/with-md/first-use-server'; import { MAX_PUBLIC_SHARE_BYTES, markdownByteLength, @@ -204,6 +206,19 @@ export async function PUT(request: NextRequest, { params }: Params) { const origin = request.nextUrl.origin; const viewUrl = repoShareViewUrl(origin, shortId); + await captureFirstUseServerEvent({ + event: FIRST_USE_EVENTS.saveCompleted, + flow: 'github_workspace', + distinctId: `repo-share:${shortId}`, + properties: { + share_id: shortId, + md_file_id: result.mdFileId ?? null, + save_surface: 'repo_share_api', + size_bytes: sizeBytes, + optimistic_match_used: Boolean(expectedContentHash), + }, + }); + return NextResponse.json( { ok: true, diff --git a/web/src/app/api/public/share/[shareId]/route.ts b/web/src/app/api/public/share/[shareId]/route.ts index cab6c5f..77d6fee 100644 --- a/web/src/app/api/public/share/[shareId]/route.ts +++ b/web/src/app/api/public/share/[shareId]/route.ts @@ -15,6 +15,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { F, mutateConvex, queryConvex } from '@/lib/with-md/convex-client'; +import { FIRST_USE_EVENTS } from '@/lib/with-md/first-use-events'; +import { captureFirstUseServerEvent } from '@/lib/with-md/first-use-server'; import { generateClientId, checkRateLimit, MAX_REQUESTS_PER_WINDOW } from '@/lib/with-md/rate-limit'; import { MAX_PUBLIC_SHARE_BYTES, @@ -237,6 +239,18 @@ export async function PUT(request: NextRequest, { params }: Params) { const origin = request.nextUrl.origin; const shareUrl = `${origin}/s/${encodeURIComponent(shortId)}`; + await captureFirstUseServerEvent({ + event: FIRST_USE_EVENTS.saveCompleted, + flow: 'anonymous_share', + distinctId: `anon:${shortId}`, + properties: { + share_id: shortId, + save_surface: 'public_share_api', + size_bytes: sizeBytes, + optimistic_match_used: Boolean(expectedContentHash), + }, + }); + return NextResponse.json( { ok: true, diff --git a/web/src/components/with-md/anon-share-shell.tsx b/web/src/components/with-md/anon-share-shell.tsx index 7700c6b..c6cb3da 100644 --- a/web/src/components/with-md/anon-share-shell.tsx +++ b/web/src/components/with-md/anon-share-shell.tsx @@ -13,6 +13,8 @@ import { proseMarkdownComponents } from '@/components/with-md/prose-markdown-com import SourceEditor from '@/components/with-md/source-editor'; import { useScrollbarWidth } from '@/hooks/with-md/use-scrollbar-width'; import { cursorColorForUser } from '@/lib/with-md/cursor-colors'; +import { captureFirstUseClientEvent } from '@/lib/with-md/first-use-client'; +import { FIRST_USE_EVENTS } from '@/lib/with-md/first-use-events'; import { protectMarkdownSave, type ProtectedMarkdownLoss } from '@/lib/with-md/markdown-format-guard'; import { hasMeaningfulDiff } from '@/lib/with-md/markdown-diff'; import { @@ -120,6 +122,7 @@ export default function AnonShareShell({ shareId }: Props) { const [editorHydrationSlow, setEditorHydrationSlow] = useState(false); const shareMenuRef = useRef(null); const shareRef = useRef(null); + const trackedEditOpenRef = useRef(null); const lastSourceSaveRef = useRef(''); const { ref: sourceScrollRef, scrollbarWidth: sourceScrollbarWidth } = useScrollbarWidth(); const { ref: markdownScrollRef, scrollbarWidth: markdownScrollbarWidth } = useScrollbarWidth(); @@ -178,6 +181,20 @@ export default function AnonShareShell({ shareId }: Props) { const editable = Boolean(data.canEdit); setCanEdit(editable); + if (editable && editSecret && trackedEditOpenRef.current !== nextShare.shortId) { + trackedEditOpenRef.current = nextShare.shortId; + captureFirstUseClientEvent({ + event: FIRST_USE_EVENTS.editLinkOpened, + flow: 'anonymous_share', + properties: { + share_id: nextShare.shortId, + can_edit: true, + syntax_supported: nextShare.syntaxSupportStatus !== 'unsupported', + size_bytes: nextShare.sizeBytes, + }, + }); + } + if (!editable && editSecret) { setStatusMessage('Edit key is invalid for this share. Opened in read-only mode.'); return; diff --git a/web/src/components/with-md/repo-share-shell.tsx b/web/src/components/with-md/repo-share-shell.tsx index aded489..fcd11c6 100644 --- a/web/src/components/with-md/repo-share-shell.tsx +++ b/web/src/components/with-md/repo-share-shell.tsx @@ -12,6 +12,8 @@ import NoticeStack from '@/components/with-md/notice-stack'; import { proseMarkdownComponents } from '@/components/with-md/prose-markdown-components'; import { useScrollbarWidth } from '@/hooks/with-md/use-scrollbar-width'; import { cursorColorForUser } from '@/lib/with-md/cursor-colors'; +import { captureFirstUseClientEvent } from '@/lib/with-md/first-use-client'; +import { FIRST_USE_EVENTS } from '@/lib/with-md/first-use-events'; interface SharePayload { mdFileId: string; @@ -94,6 +96,7 @@ export default function RepoShareShell({ token }: Props) { const [shareMenuOpen, setShareMenuOpen] = useState(false); const [collabName, setCollabName] = useState('guest'); const shareMenuRef = useRef(null); + const trackedEditOpenRef = useRef(null); const { ref: sourceScrollRef, scrollbarWidth: sourceScrollbarWidth } = useScrollbarWidth(); const { ref: markdownScrollRef, scrollbarWidth: markdownScrollbarWidth } = useScrollbarWidth(); @@ -142,6 +145,21 @@ export default function RepoShareShell({ token }: Props) { setCanEdit(Boolean(data.canEdit)); setContent(data.share.content); + if (data.canEdit && editSecret && trackedEditOpenRef.current !== token) { + trackedEditOpenRef.current = token; + captureFirstUseClientEvent({ + event: FIRST_USE_EVENTS.editLinkOpened, + flow: 'github_workspace', + properties: { + share_id: token, + md_file_id: data.share.mdFileId, + repo_id: data.share.repoId, + can_edit: true, + syntax_supported: data.share.syntaxSupportStatus !== 'unsupported', + }, + }); + } + if (data.editRejected) { setStatusMessage('Edit key is invalid for this share. Opened in read-only mode.'); } diff --git a/web/src/lib/with-md/__tests__/first-use-events.test.ts b/web/src/lib/with-md/__tests__/first-use-events.test.ts new file mode 100644 index 0000000..757a838 --- /dev/null +++ b/web/src/lib/with-md/__tests__/first-use-events.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import { FIRST_USE_EVENTS, sanitizeFirstUseProperties } from '../first-use-events'; + +describe('first-use analytics events', () => { + it('keeps the named first-use events stable', () => { + expect(FIRST_USE_EVENTS).toEqual({ + shareCreated: 'withmd_first_use_share_created', + editLinkOpened: 'withmd_first_use_edit_link_opened', + saveCompleted: 'withmd_first_use_save_completed', + githubConnected: 'withmd_first_use_github_connected', + repoSynced: 'withmd_first_use_repo_synced', + pushBackCompleted: 'withmd_first_use_push_back_completed', + }); + }); + + it('removes private document and credential fields from payload properties', () => { + expect(sanitizeFirstUseProperties({ + flow: 'anonymous_share', + content: '# private', + markdownContent: '# private', + sourceContent: '# private', + editSecret: 'secret', + githubToken: 'token', + body: 'private', + size_bytes: 123, + changed: true, + missing: undefined, + nullish: null, + })).toEqual({ + flow: 'anonymous_share', + size_bytes: 123, + changed: true, + nullish: null, + }); + }); +}); diff --git a/web/src/lib/with-md/first-use-client.ts b/web/src/lib/with-md/first-use-client.ts new file mode 100644 index 0000000..4f22895 --- /dev/null +++ b/web/src/lib/with-md/first-use-client.ts @@ -0,0 +1,35 @@ +import { + sanitizeFirstUseProperties, + type FirstUseEventName, + type FirstUseFlow, + type FirstUseProperties, +} from './first-use-events'; + +function envFlagEnabled(value: string | undefined): boolean { + if (!value) return false; + const normalized = value.trim().toLowerCase(); + return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'; +} + +export function captureFirstUseClientEvent(input: { + event: FirstUseEventName; + flow: FirstUseFlow; + distinctId?: string; + properties?: FirstUseProperties; +}) { + if (typeof window === 'undefined') return; + if (!envFlagEnabled(process.env.NEXT_PUBLIC_POSTHOG_ENABLED)) return; + + const properties = sanitizeFirstUseProperties({ + ...input.properties, + flow: input.flow, + product: 'with.md', + measurement_area: 'first_use', + }); + + void import('posthog-js').then(({ default: posthog }) => { + posthog.capture(input.event, properties); + }).catch(() => { + /* Analytics must never block first use. */ + }); +} diff --git a/web/src/lib/with-md/first-use-events.ts b/web/src/lib/with-md/first-use-events.ts new file mode 100644 index 0000000..657cd20 --- /dev/null +++ b/web/src/lib/with-md/first-use-events.ts @@ -0,0 +1,30 @@ +export const FIRST_USE_EVENTS = { + shareCreated: 'withmd_first_use_share_created', + editLinkOpened: 'withmd_first_use_edit_link_opened', + saveCompleted: 'withmd_first_use_save_completed', + githubConnected: 'withmd_first_use_github_connected', + repoSynced: 'withmd_first_use_repo_synced', + pushBackCompleted: 'withmd_first_use_push_back_completed', +} as const; + +export type FirstUseEventName = (typeof FIRST_USE_EVENTS)[keyof typeof FIRST_USE_EVENTS]; +export type FirstUseFlow = 'anonymous_share' | 'github_workspace'; + +export type FirstUsePropertyValue = string | number | boolean | null | undefined; +export type FirstUseProperties = Record; + +const PRIVATE_KEY_PATTERN = /(content|markdown|source|secret|token|body)/i; + +export function sanitizeFirstUseProperties(properties: FirstUseProperties = {}): Record { + const sanitized: Record = {}; + + for (const [key, value] of Object.entries(properties)) { + if (!key || PRIVATE_KEY_PATTERN.test(key)) continue; + if (value === undefined) continue; + if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + sanitized[key] = value; + } + } + + return sanitized; +} diff --git a/web/src/lib/with-md/first-use-server.ts b/web/src/lib/with-md/first-use-server.ts new file mode 100644 index 0000000..d096f1c --- /dev/null +++ b/web/src/lib/with-md/first-use-server.ts @@ -0,0 +1,64 @@ +import { + sanitizeFirstUseProperties, + type FirstUseEventName, + type FirstUseFlow, + type FirstUseProperties, +} from './first-use-events'; + +const CAPTURE_TIMEOUT_MS = 1500; + +function envFlagEnabled(value: string | undefined): boolean { + if (!value) return false; + const normalized = value.trim().toLowerCase(); + return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'; +} + +function posthogConfig() { + const enabled = envFlagEnabled(process.env.POSTHOG_ENABLED) + || envFlagEnabled(process.env.NEXT_PUBLIC_POSTHOG_ENABLED); + const token = (process.env.POSTHOG_TOKEN ?? process.env.NEXT_PUBLIC_POSTHOG_TOKEN ?? '').trim(); + const host = (process.env.POSTHOG_HOST ?? process.env.NEXT_PUBLIC_POSTHOG_HOST ?? 'https://us.i.posthog.com').trim(); + + if (!enabled || !token) return null; + return { + token, + host: host.replace(/\/+$/, ''), + }; +} + +export async function captureFirstUseServerEvent(input: { + event: FirstUseEventName; + flow: FirstUseFlow; + distinctId: string; + properties?: FirstUseProperties; +}) { + const config = posthogConfig(); + if (!config || !input.distinctId) return; + + const properties = sanitizeFirstUseProperties({ + ...input.properties, + flow: input.flow, + product: 'with.md', + measurement_area: 'first_use', + }); + + try { + await fetch(`${config.host}/capture/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + api_key: config.token, + event: input.event, + distinct_id: input.distinctId, + properties, + }), + signal: AbortSignal.timeout(CAPTURE_TIMEOUT_MS), + }); + } catch (error) { + console.warn( + '[with-md:first-use-analytics] capture failed', + input.event, + error instanceof Error ? error.message : 'unknown error', + ); + } +}