Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions hocuspocus-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>();
const oversizedReportByDoc = new Map<string, { bytes: number; reportedAt: number }>();
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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'}`
: '';
Expand Down
14 changes: 14 additions & 0 deletions web/src/app/(main)/api/anon-share/create/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions web/src/app/(main)/api/auth/github/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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');

Expand Down
16 changes: 16 additions & 0 deletions web/src/app/(main)/api/github/push/route.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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';
Expand Down
30 changes: 30 additions & 0 deletions web/src/app/(main)/api/github/sync/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions web/src/app/(main)/api/repo-share/create/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions web/src/app/(main)/api/rpc/route.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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);
Expand Down
15 changes: 15 additions & 0 deletions web/src/app/api/public/repo-share/[token]/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions web/src/app/api/public/share/[shareId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 17 additions & 0 deletions web/src/components/with-md/anon-share-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -120,6 +122,7 @@ export default function AnonShareShell({ shareId }: Props) {
const [editorHydrationSlow, setEditorHydrationSlow] = useState(false);
const shareMenuRef = useRef<HTMLDivElement | null>(null);
const shareRef = useRef<SharePayload | null>(null);
const trackedEditOpenRef = useRef<string | null>(null);
const lastSourceSaveRef = useRef('');
const { ref: sourceScrollRef, scrollbarWidth: sourceScrollbarWidth } = useScrollbarWidth<HTMLPreElement>();
const { ref: markdownScrollRef, scrollbarWidth: markdownScrollbarWidth } = useScrollbarWidth<HTMLDivElement>();
Expand Down Expand Up @@ -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;
Expand Down
Loading