diff --git a/create-db-worker/src/analytics.ts b/create-db-worker/src/analytics.ts new file mode 100644 index 0000000..1f64146 --- /dev/null +++ b/create-db-worker/src/analytics.ts @@ -0,0 +1,60 @@ +class EventCaptureError extends Error { + constructor( + public readonly event: string, + public readonly statusCode: number, + public readonly statusText: string, + ) { + super(`Failed to submit PostHog event '${event}': ${statusCode} ${statusText}`); + } +} + +interface AnalyticsProperties { + [key: string]: any; +} + +class PosthogEventCapture { + constructor(private env: { POSTHOG_API_HOST?: string; POSTHOG_API_KEY?: string }) {} + + async capture(eventName: string, properties: AnalyticsProperties = {}) { + const host = this.env.POSTHOG_API_HOST?.replace(/\/+$/, ''); + const key = this.env.POSTHOG_API_KEY; + + if (!host || !key) { + return; + } + + const POSTHOG_CAPTURE_URL = `${host}/capture`; + const POSTHOG_KEY = key; + + const payload = { + api_key: POSTHOG_KEY, + event: eventName, + distinct_id: crypto.randomUUID(), + properties: { + $process_person_profile: false, + ...properties, + }, + }; + + try { + const response = await fetch(POSTHOG_CAPTURE_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new EventCaptureError(eventName, response.status, response.statusText); + } + + console.log(`${eventName}: Success`); + } catch (error) { + console.error(`${eventName}: Failed - ${error instanceof Error ? error.message : String(error)}`); + throw error; + } + } +} + +export { PosthogEventCapture, EventCaptureError }; diff --git a/create-db-worker/src/index.ts b/create-db-worker/src/index.ts index 60f14fb..a4c7066 100644 --- a/create-db-worker/src/index.ts +++ b/create-db-worker/src/index.ts @@ -1,16 +1,20 @@ import DeleteDbWorkflow from './delete-workflow'; - +import { PosthogEventCapture } from './analytics'; interface Env { INTEGRATION_TOKEN: string; DELETE_DB_WORKFLOW: Workflow; CREATE_DB_RATE_LIMITER: RateLimit; CREATE_DB_DATASET: AnalyticsEngineDataset; + POSTHOG_API_KEY?: string; + POSTHOG_API_HOST?: string; } export { DeleteDbWorkflow }; export default { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + const analytics = new PosthogEventCapture(env); + // --- Rate limiting --- const { success } = await env.CREATE_DB_RATE_LIMITER.limit({ key: request.url }); @@ -56,16 +60,51 @@ export default { }); } + // --- Analytics endpoint --- + if (url.pathname === '/analytics' && request.method === 'POST') { + type AnalyticsBody = { eventName?: string; properties?: Record }; + + let body: AnalyticsBody = {}; + + try { + body = await request.json(); + } catch { + return new Response('Invalid JSON body', { status: 400 }); + } + + const { eventName, properties } = body; + if (!eventName) { + return new Response('Missing eventName in request body', { status: 400 }); + } + + if (!env.POSTHOG_API_HOST || !env.POSTHOG_API_KEY) { + return new Response(null, { status: 204 }); + } + + ctx.waitUntil(analytics.capture(eventName, properties || {})); + return new Response(JSON.stringify({ status: 'queued', event: eventName }), { + status: 202, + headers: { 'Content-Type': 'application/json' }, + }); + } + // --- Create new project --- if (url.pathname === '/create' && request.method === 'POST') { - let body: { region?: string; name?: string } = {}; + type CreateDbBody = { + region?: string; + name?: string; + analytics?: { eventName?: string; properties?: Record }; + }; + + let body: CreateDbBody = {}; + try { body = await request.json(); } catch { return new Response('Invalid JSON body', { status: 400 }); } - const { region, name } = body; + const { region, name, analytics: analyticsData } = body; if (!region || !name) { return new Response('Missing region or name in request body', { status: 400 }); } @@ -96,7 +135,13 @@ export default { indexes: ['create_db'], }); - await Promise.all([workflowPromise, analyticsPromise]); + const posthogPromise = analyticsData?.eventName + ? analytics + .capture(analyticsData.eventName, analyticsData.properties || {}) + .catch((e) => console.error('Error sending PostHog analytics:', e)) + : Promise.resolve(); + + await Promise.all([workflowPromise, analyticsPromise, posthogPromise]); } catch (e) { console.error('Error in background tasks:', e); } diff --git a/create-db-worker/wrangler.jsonc b/create-db-worker/wrangler.jsonc index e7eee5d..5081aab 100644 --- a/create-db-worker/wrangler.jsonc +++ b/create-db-worker/wrangler.jsonc @@ -4,6 +4,7 @@ "main": "src/index.ts", "account_id": "16b32bbb36161aca01a6357a37bc453e", "compatibility_date": "2025-06-27", + "keep_vars": true, "observability": { "enabled": true, }, diff --git a/create-db/analytics.js b/create-db/analytics.js deleted file mode 100644 index 5aae42b..0000000 --- a/create-db/analytics.js +++ /dev/null @@ -1,63 +0,0 @@ -import { randomUUID } from "crypto"; - -class EventCaptureError extends Error { - constructor(event, status) { - super(`Failed to submit PostHog event '${event}': ${status}`); - } -} - -class PosthogEventCapture { - async capture(eventName, properties = {}) { - const POSTHOG_API_HOST = process.env.POSTHOG_API_HOST; - const POSTHOG_KEY = process.env.POSTHOG_API_KEY; - - if ( - !POSTHOG_API_HOST || - !POSTHOG_KEY || - POSTHOG_API_HOST.trim() === "" || - POSTHOG_KEY.trim() === "" - ) { - if (process.env.NODE_ENV === "development") { - console.warn( - "Analytics disabled: missing POSTHOG_API_HOST or POSTHOG_API_KEY." - ); - } - return; - } - - const POSTHOG_CAPTURE_URL = `${POSTHOG_API_HOST.replace(/\/+$/, "")}/capture`; - - const payload = { - api_key: POSTHOG_KEY, - event: eventName, - distinct_id: randomUUID(), - properties: { - $process_person_profile: false, - ...properties, - }, - }; - - try { - const response = await fetch(POSTHOG_CAPTURE_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - throw new EventCaptureError(eventName, response.statusText); - } - } catch (error) { - if (process.env.NODE_ENV === "development") { - console.error("Analytics error:", error.message); - } - } - } -} - -// Create a singleton instance -const analytics = new PosthogEventCapture(); - -export { analytics, EventCaptureError }; diff --git a/create-db/index.js b/create-db/index.js index abfad9c..027ef20 100755 --- a/create-db/index.js +++ b/create-db/index.js @@ -1,20 +1,43 @@ #!/usr/bin/env node +import { select, spinner, intro, outro, log, cancel } from "@clack/prompts"; +import { randomUUID } from "crypto"; import dotenv from "dotenv"; import fs from "fs"; import path from "path"; +import terminalLink from "terminal-link"; +import chalk from "chalk"; dotenv.config(); -import { select, spinner, intro, outro, log, cancel } from "@clack/prompts"; -import chalk from "chalk"; -import terminalLink from "terminal-link"; -import { analytics } from "./analytics.js"; +const CLI_RUN_ID = randomUUID(); const CREATE_DB_WORKER_URL = - process.env.CREATE_DB_WORKER_URL || "https://create-db-temp.prisma.io"; + process.env.CREATE_DB_WORKER_URL.replace(/\/+$/, "") || + "https://create-db-temp.prisma.io"; const CLAIM_DB_WORKER_URL = - process.env.CLAIM_DB_WORKER_URL || "https://create-db.prisma.io"; + process.env.CLAIM_DB_WORKER_URL.replace(/\/+$/, "") || + "https://create-db.prisma.io"; + +async function sendAnalyticsToWorker(eventName, properties) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 2000); + try { + const payload = { + eventName, + properties: { distinct_id: CLI_RUN_ID, ...(properties || {}) }, + }; + await fetch(`${CREATE_DB_WORKER_URL}/analytics`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + } catch (error) { + } finally { + clearTimeout(timer); + } +} async function detectUserLocation() { try { @@ -43,7 +66,6 @@ async function detectUserLocation() { } } -// Region coordinates (latitude, longitude) const REGION_COORDINATES = { "ap-southeast-1": { lat: 1.3521, lng: 103.8198 }, // Singapore "ap-northeast-1": { lat: 35.6762, lng: 139.6503 }, // Tokyo @@ -359,16 +381,12 @@ async function promptForRegion(defaultRegion, userAgent) { process.exit(0); } - try { - const analyticsProps = { - command: CLI_NAME, - region: region, - "selection-method": "interactive", - "user-agent": userAgent, - }; - - await analytics.capture("create_db:region_selected", analyticsProps); - } catch (error) {} + void sendAnalyticsToWorker("create_db:region_selected", { + command: CLI_NAME, + region: region, + "selection-method": "interactive", + "user-agent": userAgent, + }); return region; } @@ -406,20 +424,13 @@ async function createDatabase(name, region, userAgent, returnJson = false) { ); } - try { - const analyticsProps = { - command: CLI_NAME, - region: region, - "error-type": "rate_limit", - "status-code": 429, - "user-agent": userAgent, - }; - - await analytics.capture( - "create_db:database_creation_failed", - analyticsProps - ); - } catch (error) {} + void sendAnalyticsToWorker("create_db:database_creation_failed", { + command: CLI_NAME, + region: region, + "error-type": "rate_limit", + "status-code": 429, + "user-agent": userAgent, + }); process.exit(1); } @@ -441,20 +452,15 @@ async function createDatabase(name, region, userAgent, returnJson = false) { if (s) { s.stop("Unexpected response from create service."); } - try { - const analyticsProps = { - command: CLI_NAME, - region, - "error-type": "invalid_json", - "status-code": resp.status, - "user-agent": userAgent, - }; - await analytics.capture( - "create_db:database_creation_failed", - analyticsProps - ); - } catch (error) {} + void sendAnalyticsToWorker("create_db:database_creation_failed", { + command: CLI_NAME, + region, + "error-type": "invalid_json", + "status-code": resp.status, + "user-agent": userAgent, + }); + process.exit(1); } @@ -517,20 +523,14 @@ async function createDatabase(name, region, userAgent, returnJson = false) { ); } - try { - const analyticsProps = { - command: CLI_NAME, - region: region, - "error-type": "api_error", - "error-message": result.error.message, - "user-agent": userAgent, - }; + void sendAnalyticsToWorker("create_db:database_creation_failed", { + command: CLI_NAME, + region: region, + "error-type": "api_error", + "error-message": result.error.message, + "user-agent": userAgent, + }); - await analytics.capture( - "create_db:database_creation_failed", - analyticsProps - ); - } catch (error) {} process.exit(1); } @@ -581,6 +581,12 @@ async function createDatabase(name, region, userAgent, returnJson = false) { ) ) ); + + void sendAnalyticsToWorker("create_db:database_created", { + command: CLI_NAME, + region, + utm_source: CLI_NAME, + }); } async function main() { @@ -595,28 +601,21 @@ async function main() { userAgent = `${userEnvVars.PRISMA_ACTOR_NAME}/${userEnvVars.PRISMA_ACTOR_PROJECT}`; } - try { - const analyticsProps = { - command: CLI_NAME, - "full-command": `${CLI_NAME} ${rawArgs.join(" ")}`.trim(), - "has-region-flag": - rawArgs.includes("--region") || rawArgs.includes("-r"), - "has-interactive-flag": - rawArgs.includes("--interactive") || rawArgs.includes("-i"), - "has-help-flag": rawArgs.includes("--help") || rawArgs.includes("-h"), - "has-list-regions-flag": rawArgs.includes("--list-regions"), - "has-json-flag": rawArgs.includes("--json") || rawArgs.includes("-j"), - "has-user-agent-from-env": !!userAgent, - "node-version": process.version, - platform: process.platform, - arch: process.arch, - "user-agent": userAgent, - }; - - await analytics.capture("create_db:cli_command_ran", analyticsProps); - } catch (error) { - console.error("Error:", error.message); - } + void sendAnalyticsToWorker("create_db:cli_command_ran", { + command: CLI_NAME, + "full-command": `${CLI_NAME} ${rawArgs.join(" ")}`.trim(), + "has-region-flag": rawArgs.includes("--region") || rawArgs.includes("-r"), + "has-interactive-flag": + rawArgs.includes("--interactive") || rawArgs.includes("-i"), + "has-help-flag": rawArgs.includes("--help") || rawArgs.includes("-h"), + "has-list-regions-flag": rawArgs.includes("--list-regions"), + "has-json-flag": rawArgs.includes("--json") || rawArgs.includes("-j"), + "has-user-agent-from-env": !!userAgent, + "node-version": process.version, + platform: process.platform, + arch: process.arch, + "user-agent": userAgent, + }); if (!flags.help && !flags.json) { await isOffline(); @@ -639,16 +638,12 @@ async function main() { if (flags.region) { region = flags.region; - try { - const analyticsProps = { - command: CLI_NAME, - region: region, - "selection-method": "flag", - "user-agent": userAgent, - }; - - await analytics.capture("create_db:region_selected", analyticsProps); - } catch (error) {} + void sendAnalyticsToWorker("create_db:region_selected", { + command: CLI_NAME, + region: region, + "selection-method": "flag", + "user-agent": userAgent, + }); } if (flags.interactive) {