From 35936acd7cafa5f45c5b876ed99be340f3795f5e Mon Sep 17 00:00:00 2001 From: Michael K <35264484+Mkassabov@users.noreply.github.com> Date: Fri, 26 Sep 2025 05:39:20 -0400 Subject: [PATCH 1/6] chore(core): clickhouse based telemetry --- alchemy/bin/trpc.ts | 13 +- alchemy/src/alchemy.ts | 25 +- alchemy/src/apply.ts | 40 ++- alchemy/src/resource.ts | 14 +- alchemy/src/scope.ts | 67 ++-- alchemy/src/state/instrumented-state-store.ts | 53 +-- alchemy/src/test/bun.ts | 3 +- alchemy/src/test/vitest.ts | 3 +- alchemy/src/util/telemetry/constants.ts | 10 +- alchemy/src/util/telemetry/v2.ts | 334 ++++++++++++++++++ alchemy/test/aws/credentials.test.ts | 17 +- .../test/scope-provider-credentials.test.ts | 41 +-- 12 files changed, 443 insertions(+), 177 deletions(-) create mode 100644 alchemy/src/util/telemetry/v2.ts diff --git a/alchemy/bin/trpc.ts b/alchemy/bin/trpc.ts index b74db1f71..5fcbecae4 100644 --- a/alchemy/bin/trpc.ts +++ b/alchemy/bin/trpc.ts @@ -1,7 +1,7 @@ import { cancel, log } from "@clack/prompts"; import pc from "picocolors"; import { trpcServer, type TrpcCliMeta } from "trpc-cli"; -import { TelemetryClient } from "../src/util/telemetry/client.ts"; +import { createAndSendEvent } from "../src/util/telemetry/v2.ts"; export const t = trpcServer.initTRPC.meta().create(); @@ -15,11 +15,7 @@ export class ExitSignal extends Error { export class CancelSignal extends Error {} const loggingMiddleware = t.middleware(async ({ path, next }) => { - const telemetry = TelemetryClient.create({ - enabled: true, - quiet: true, - }); - telemetry.record({ + createAndSendEvent({ event: "cli.start", command: path, }); @@ -27,13 +23,13 @@ const loggingMiddleware = t.middleware(async ({ path, next }) => { try { const result = await next(); - telemetry.record({ + createAndSendEvent({ event: "cli.success", command: path, }); return result; } catch (error) { - telemetry.record({ + createAndSendEvent({ event: error instanceof ExitSignal && error.code === 0 ? "cli.success" @@ -46,7 +42,6 @@ const loggingMiddleware = t.middleware(async ({ path, next }) => { throw error; } } finally { - await telemetry.finalize(); //* this is a node issue https://github.com/nodejs/node/issues/56645 await new Promise((resolve) => setTimeout(resolve, 100)); process.exit(exitCode); diff --git a/alchemy/src/alchemy.ts b/alchemy/src/alchemy.ts index eab4effd3..3c801827e 100644 --- a/alchemy/src/alchemy.ts +++ b/alchemy/src/alchemy.ts @@ -17,7 +17,6 @@ import { secret } from "./secret.ts"; import type { StateStoreType } from "./state.ts"; import type { LoggerApi } from "./util/cli.ts"; import { ALCHEMY_ROOT } from "./util/root-dir.ts"; -import { TelemetryClient } from "./util/telemetry/client.ts"; /** * Type alias for semantic highlighting of `alchemy` as a type keyword @@ -162,20 +161,13 @@ If this is a mistake, you can disable this check by setting the ALCHEMY_CI_STATE } const phase = mergedOptions?.phase ?? "up"; - const telemetryClient = - mergedOptions?.parent?.telemetryClient ?? - TelemetryClient.create({ - phase, - enabled: mergedOptions?.telemetry ?? true, - quiet: mergedOptions?.quiet ?? false, - }); const root = new Scope({ ...mergedOptions, parent: undefined, scopeName: appName, phase, password: mergedOptions?.password ?? process.env.ALCHEMY_PASSWORD, - telemetryClient, + noTrack: mergedOptions?.noTrack ?? false, isSelected: app === undefined ? undefined : app === appName, }); onExit((code) => { @@ -280,12 +272,12 @@ export interface AlchemyOptions { */ password?: string; /** - * Whether to send anonymous telemetry data to the Alchemy team. + * Whether to stop sending anonymous telemetry data to the Alchemy team. * You can also opt out by setting the `DO_NOT_TRACK` or `ALCHEMY_TELEMETRY_DISABLED` environment variables to a truthy value. * - * @default true + * @default false */ - telemetry?: boolean; + noTrack?: boolean; /** * A custom logger instance to use for this scope. * If not provided, the default fallback logger will be used. @@ -353,18 +345,11 @@ async function run( RunOptions, (this: Scope, scope: Scope) => Promise, ]); - const telemetryClient = - options?.parent?.telemetryClient ?? - TelemetryClient.create({ - phase: options?.phase ?? "up", - enabled: options?.telemetry ?? true, - quiet: options?.quiet ?? false, - }); const _scope = new Scope({ ...options, parent: options?.parent, scopeName: id, - telemetryClient, + noTrack: options?.noTrack ?? false, }); let noop = options?.noop ?? false; try { diff --git a/alchemy/src/apply.ts b/alchemy/src/apply.ts index d4430298b..1800e2f24 100644 --- a/alchemy/src/apply.ts +++ b/alchemy/src/apply.ts @@ -19,7 +19,7 @@ import { serialize } from "./serde.ts"; import type { State } from "./state.ts"; import { formatFQN } from "./util/cli.ts"; import { logger } from "./util/logger.ts"; -import type { Telemetry } from "./util/telemetry/index.ts"; +import { createAndSendEvent } from "./util/telemetry/v2.ts"; export interface ApplyOptions { quiet?: boolean; @@ -83,9 +83,13 @@ async function _apply( // we are running in a monorepo and are not the selected app, so we need to wait for the process to be consistent state = await waitForConsistentState(); } - scope.telemetryClient.record({ + createAndSendEvent({ event: "resource.read", + phase: scope.phase, + duration: performance.now() - start, + status: state.status, resource: resource[ResourceKind], + replaced: false, }); return state.output as Awaited & Resource; @@ -179,10 +183,13 @@ async function _apply( status: "success", }); } - scope.telemetryClient.record({ + createAndSendEvent({ event: "resource.skip", resource: resource[ResourceKind], status: state.status, + phase: scope.phase, + duration: performance.now() - start, + replaced: false, }); return state.output as Awaited & Resource; } @@ -202,10 +209,13 @@ async function _apply( }); } - scope.telemetryClient.record({ + createAndSendEvent({ event: "resource.start", resource: resource[ResourceKind], status: state.status, + phase: scope.phase, + duration: performance.now() - start, + replaced: false, }); await scope.state.set(resource[ResourceID], state); @@ -326,11 +336,12 @@ async function _apply( } const status = phase === "create" ? "created" : "updated"; - scope.telemetryClient.record({ + createAndSendEvent({ event: "resource.success", resource: resource[ResourceKind], status, - elapsed: performance.now() - start, + phase: scope.phase, + duration: performance.now() - start, replaced: isReplaced, }); @@ -347,12 +358,17 @@ async function _apply( }); return output as Awaited & Resource; } catch (error) { - scope.telemetryClient.record({ - event: "resource.error", - resource: resource[ResourceKind], - error: error as Telemetry.ErrorInput, - elapsed: performance.now() - start, - }); + createAndSendEvent( + { + event: "resource.error", + resource: resource[ResourceKind], + duration: performance.now() - start, + phase: scope.phase, + status: "unknown", + replaced: false, + }, + error as Error | undefined, + ); scope.fail(); throw error; } diff --git a/alchemy/src/resource.ts b/alchemy/src/resource.ts index e15563d91..6b60103c8 100644 --- a/alchemy/src/resource.ts +++ b/alchemy/src/resource.ts @@ -2,6 +2,7 @@ import { apply } from "./apply.ts"; import type { Context } from "./context.ts"; import { DestroyStrategy } from "./destroy.ts"; import { Scope as _Scope, type Scope } from "./scope.ts"; +import { createAndSendEvent } from "./util/telemetry/v2.ts"; declare global { var ALCHEMY_PROVIDERS: Map>; @@ -178,11 +179,16 @@ export function Resource< const error = new Error( `Resource ${resourceID} already exists in the stack and is of a different type: '${otherResource?.[ResourceKind]}' !== '${type}'`, ); - scope.telemetryClient.record({ - event: "resource.error", - resource: type, + createAndSendEvent( + { + event: "resource.error", + resource: type, + phase: scope.phase, + status: "unknown", + duration: 0, + }, error, - }); + ); throw error; } } diff --git a/alchemy/src/scope.ts b/alchemy/src/scope.ts index 340b2f9b7..6ef473451 100644 --- a/alchemy/src/scope.ts +++ b/alchemy/src/scope.ts @@ -29,7 +29,7 @@ import { import { logger } from "./util/logger.ts"; import { AsyncMutex } from "./util/mutex.ts"; import { ALCHEMY_ROOT } from "./util/root-dir.ts"; -import type { ITelemetryClient } from "./util/telemetry/client.ts"; +import { createAndSendEvent } from "./util/telemetry/v2.ts"; export class RootScopeStateAttemptError extends Error { constructor() { @@ -76,10 +76,11 @@ export interface ScopeOptions extends ProviderCredentials { */ destroyStrategy?: DestroyStrategy; /** - * The telemetry client to use for the scope. + * Whether to disable telemetry for the scope. * + * @default false */ - telemetryClient?: ITelemetryClient; + noTrack?: boolean; /** * The logger to use for the scope. */ @@ -197,7 +198,7 @@ export class Scope { public readonly adopt: boolean; public readonly destroyStrategy: DestroyStrategy; public readonly logger: LoggerApi; - public readonly telemetryClient: ITelemetryClient; + public readonly noTrack: boolean; public readonly dataMutex: AsyncMutex; public readonly rootDir: string; public readonly dotAlchemy: string; @@ -235,12 +236,12 @@ export class Scope { tunnel, force, destroyStrategy, - telemetryClient, logger, adopt, dotAlchemy, rootDir, isSelected, + noTrack, ...providerCredentials } = options; @@ -273,6 +274,7 @@ export class Scope { throw new Error("Scope name is required when creating a child scope"); } this.password = password ?? this.parent?.password; + this.noTrack = noTrack ?? this.parent?.noTrack ?? false; const resolvedPhase = phase ?? this.parent?.phase; if (resolvedPhase === undefined) { throw new Error("Phase is required"); @@ -307,14 +309,7 @@ export class Scope { stateStore ?? this.parent?.stateStore ?? ((scope) => new FileSystemStateStore(scope)); - this.telemetryClient = telemetryClient ?? this.parent?.telemetryClient!; - this.state = new InstrumentedStateStore( - this.stateStore(this), - this.telemetryClient, - ); - if (!telemetryClient && !this.parent?.telemetryClient) { - throw new Error("Telemetry client is required"); - } + this.state = new InstrumentedStateStore(this.stateStore(this)); this.dataMutex = new AsyncMutex(); } @@ -413,12 +408,7 @@ export class Scope { } public async init() { - await Promise.all([ - this.state.init?.(), - this.telemetryClient.ready.catch((error) => { - this.logger.warn("Telemetry initialization failed:", error); - }), - ]); + await Promise.all([this.state.init?.()]); } public async deinit() { @@ -515,26 +505,15 @@ export class Scope { return this.finalize(); } - /** - * The telemetry client for the root scope. - * This is used so that app-level hooks are only called once. - */ - private get rootTelemetryClient(): ITelemetryClient | null { - if (!this.parent) { - return this.telemetryClient; - } - return null; - } - public async finalize(options?: { force?: boolean; noop?: boolean }) { const shouldForce = options?.force || this.parent === undefined || this?.parent?.scopeName === this.root.scopeName; if (this.phase === "read") { - this.rootTelemetryClient?.record({ - event: "app.success", - elapsed: performance.now() - this.startedAt, + createAndSendEvent({ + event: "alchemy.success", + duration: performance.now() - this.startedAt, }); return; } @@ -583,23 +562,21 @@ export class Scope { force: shouldForce, noop: options?.noop, }); - this.rootTelemetryClient?.record({ - event: "app.success", - elapsed: performance.now() - this.startedAt, + createAndSendEvent({ + event: "alchemy.success", + duration: performance.now() - this.startedAt, }); } else if (this.isErrored) { this.logger.warn("Scope is in error, skipping finalize"); - this.rootTelemetryClient?.record({ - event: "app.error", - error: new Error("Scope failed"), - elapsed: performance.now() - this.startedAt, - }); + createAndSendEvent( + { + event: "alchemy.error", + duration: performance.now() - this.startedAt, + }, + new Error("Scope failed"), + ); } - await this.rootTelemetryClient?.finalize()?.catch((error) => { - this.logger.warn("Telemetry finalization failed:", error); - }); - if (!this.parent && process.env.ALCHEMY_TEST_KILL_ON_FINALIZE) { await this.cleanup(); process.exit(0); diff --git a/alchemy/src/state/instrumented-state-store.ts b/alchemy/src/state/instrumented-state-store.ts index c2802c1c1..13b4e88b6 100644 --- a/alchemy/src/state/instrumented-state-store.ts +++ b/alchemy/src/state/instrumented-state-store.ts @@ -1,6 +1,8 @@ import type { State, StateStore } from "../state.ts"; -import type { ITelemetryClient } from "../util/telemetry/client.ts"; -import type { Telemetry } from "../util/telemetry/types.ts"; +import { + createAndSendEvent, + type StateStoreTelemetryData, +} from "../util/telemetry/v2.ts"; //todo(michael): we should also handle serde here export class InstrumentedStateStore @@ -9,17 +11,15 @@ export class InstrumentedStateStore /** @internal */ __phantom?: T; private readonly stateStore: StateStore; - private readonly telemetryClient: ITelemetryClient; private readonly stateStoreClass: string; - constructor(stateStore: StateStore, telemetryClient: ITelemetryClient) { + constructor(stateStore: StateStore) { this.stateStore = stateStore; - this.telemetryClient = telemetryClient; this.stateStoreClass = stateStore.constructor.name; } private async callWithTelemetry( - event: Telemetry.StateStoreEvent["event"], + event: StateStoreTelemetryData["event"], fn: () => Promise, ): Promise { const start = performance.now(); @@ -30,17 +30,18 @@ export class InstrumentedStateStore error = err; throw err; } finally { - this.telemetryClient.record({ - event, - stateStoreClass: this.stateStoreClass, - elapsed: performance.now() - start, - error: - error instanceof Error - ? error - : error - ? new Error(String(error)) - : undefined, - }); + createAndSendEvent( + { + event, + stateStore: this.stateStoreClass, + duration: performance.now() - start, + }, + error instanceof Error + ? error + : error + ? new Error(String(error)) + : undefined, + ); } } @@ -49,7 +50,7 @@ export class InstrumentedStateStore return; } await this.callWithTelemetry( - "stateStore.init", + "statestore.init", this.stateStore.init.bind(this.stateStore), ); } @@ -58,49 +59,49 @@ export class InstrumentedStateStore return; } await this.callWithTelemetry( - "stateStore.deinit", + "statestore.deinit", this.stateStore.deinit.bind(this.stateStore), ); } async list() { return await this.callWithTelemetry( - "stateStore.list", + "statestore.list", this.stateStore.list.bind(this.stateStore), ); } async count() { return await this.callWithTelemetry( - "stateStore.count", + "statestore.count", this.stateStore.count.bind(this.stateStore), ); } async get(key: string) { return await this.callWithTelemetry( - "stateStore.get", + "statestore.get", this.stateStore.get.bind(this.stateStore, key), ); } async getBatch(ids: string[]) { return await this.callWithTelemetry( - "stateStore.getBatch", + "statestore.getBatch", this.stateStore.getBatch.bind(this.stateStore, ids), ); } async all() { return await this.callWithTelemetry( - "stateStore.all", + "statestore.all", this.stateStore.all.bind(this.stateStore), ); } async set(key: string, value: State) { await this.callWithTelemetry( - "stateStore.set", + "statestore.set", this.stateStore.set.bind(this.stateStore, key, value), ); } async delete(key: string) { await this.callWithTelemetry( - "stateStore.delete", + "statestore.delete", this.stateStore.delete.bind(this.stateStore, key), ); } diff --git a/alchemy/src/test/bun.ts b/alchemy/src/test/bun.ts index bb9b849fa..3bea38d62 100644 --- a/alchemy/src/test/bun.ts +++ b/alchemy/src/test/bun.ts @@ -4,7 +4,6 @@ import { afterAll, beforeAll, it } from "bun:test"; import path from "node:path"; import { alchemy } from "../alchemy.ts"; import { Scope } from "../scope.ts"; -import { NoopTelemetryClient } from "../util/telemetry/index.ts"; import type { TestOptions } from "./options.ts"; /** @@ -103,7 +102,7 @@ export function test(meta: ImportMeta, defaultOptions?: TestOptions): test { // parent: globalTestScope, stateStore: defaultOptions?.stateStore, phase: "up", - telemetryClient: new NoopTelemetryClient(), + noTrack: true, local: defaultOptions.local, }); diff --git a/alchemy/src/test/vitest.ts b/alchemy/src/test/vitest.ts index 648e7da0f..c61ec6817 100644 --- a/alchemy/src/test/vitest.ts +++ b/alchemy/src/test/vitest.ts @@ -8,7 +8,6 @@ import { FileSystemStateStore, SQLiteStateStore, } from "../state/index.ts"; -import { NoopTelemetryClient } from "../util/telemetry/client.ts"; import type { TestOptions } from "./options.ts"; /** @@ -132,7 +131,7 @@ export function test( scopeName: `${defaultOptions.prefix ? `${defaultOptions.prefix}-` : ""}${path.basename(meta.filename)}`, stateStore: defaultOptions?.stateStore, phase: "up", - telemetryClient: new NoopTelemetryClient(), + noTrack: true, quiet: defaultOptions.quiet, password: process.env.ALCHEMY_PASSWORD, local: defaultOptions.local, diff --git a/alchemy/src/util/telemetry/constants.ts b/alchemy/src/util/telemetry/constants.ts index 98f0d5d37..2cebc681f 100644 --- a/alchemy/src/util/telemetry/constants.ts +++ b/alchemy/src/util/telemetry/constants.ts @@ -5,9 +5,7 @@ export const CONFIG_DIR = envPaths("alchemy", { suffix: "" }).config; export const TELEMETRY_DISABLED = !!process.env.ALCHEMY_TELEMETRY_DISABLED || !!process.env.DO_NOT_TRACK; -// TODO(sam): replace with permanent URL -export const POSTHOG_CLIENT_API_HOST = - process.env.ALCHEMY_POSTHOG_CLIENT_API_HOST ?? "https://ph.alchemy.run"; -export const POSTHOG_PROJECT_ID = - process.env.ALCHEMY_POSTHOG_PROJECT_ID ?? - "phc_A51Mi7Q63TvnNrvRMgvBxE1il0DAL66rVg4LdWPRsfK"; +export const TELEMETRY_API_URL = + process.env.ALCHEMY_TELEMETRY_API_URL ?? "https://telemetry.alchemy.run"; +export const SUPPRESS_TELEMETRY_ERRORS = + !!process.env.ALCHEMY_TELEMETRY_SUPPRESS_ERRORS; diff --git a/alchemy/src/util/telemetry/v2.ts b/alchemy/src/util/telemetry/v2.ts new file mode 100644 index 000000000..c61b5ac7a --- /dev/null +++ b/alchemy/src/util/telemetry/v2.ts @@ -0,0 +1,334 @@ +import { exec } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import os from "node:os"; +import { join } from "node:path"; +import pkg from "../../../package.json" with { type: "json" }; +import type { Phase } from "../../alchemy.ts"; +import { Scope } from "../../scope.ts"; +import { logger } from "../logger.ts"; +import { + CONFIG_DIR, + SUPPRESS_TELEMETRY_ERRORS, + TELEMETRY_API_URL, + TELEMETRY_DISABLED, +} from "./constants.ts"; + +async function getOrCreateUserId() { + const path = join(CONFIG_DIR, "id"); + + try { + return (await readFile(path, "utf-8")).trim(); + } catch {} + + try { + await mkdir(CONFIG_DIR, { recursive: true }); + } catch {} + + const id = crypto.randomUUID(); + try { + await writeFile(path, id); + console.warn( + [ + "Attention: To help improve Alchemy, we now collect anonymous usage, performance, and error data.", + "You can opt out by setting the ALCHEMY_TELEMETRY_DISABLED or DO_NOT_TRACK environment variable to a truthy value.", + ].join("\n"), + ); + } catch { + return null; + } + + return id; +} + +async function getRootCommitHash() { + return new Promise((resolve) => { + exec("git rev-list --max-parents=0 HEAD", (err, stdout) => { + if (err) { + resolve(null); + return; + } + resolve(stdout.trim()); + }); + }); +} + +async function getGitOriginUrl() { + return new Promise((resolve) => { + exec("git config --get remote.origin.url", (err, stdout) => { + if (err) { + resolve(null); + return; + } + resolve(stdout.trim()); + }); + }); +} + +async function getBranchName() { + return new Promise((resolve) => { + exec("git rev-parse --abbrev-ref HEAD", (err, stdout) => { + if (err) { + resolve(null); + return; + } + resolve(stdout.trim()); + }); + }); +} + +const RUNTIMES = [ + { + name: "bun", + detect: () => !!globalThis.Bun, + version: () => globalThis.Bun?.version, + }, + { + name: "deno", + detect: () => !!globalThis.Deno, + version: () => globalThis.Deno?.version?.deno, + }, + { + name: "workerd", + detect: () => !!globalThis.EdgeRuntime, + version: () => null, + }, + { + name: "node", + detect: () => !!globalThis.process?.versions?.node, + version: () => process.versions.node, + }, +] as const; + +function getRuntime() { + for (const runtime of RUNTIMES) { + if (runtime.detect()) { + return { + name: runtime.name, + version: runtime.version() ?? null, + }; + } + } + return { + name: null, + version: null, + }; +} + +const PROVIDERS = [ + { env: "GITHUB_ACTIONS", provider: "GitHub Actions", isCI: true }, + { env: "GITLAB_CI", provider: "GitLab CI", isCI: true }, + { env: "CIRCLECI", provider: "CircleCI", isCI: true }, + { env: "JENKINS_URL", provider: "Jenkins", isCI: true }, + { env: "TRAVIS", provider: "Travis CI", isCI: true }, + { env: "NOW_BUILDER", provider: "Vercel", isCI: true }, + { env: "VERCEL", provider: "Vercel", isCI: false }, +]; + +function getEnvironment() { + for (const provider of PROVIDERS) { + if (process.env[provider.env]) { + return { + provider: provider.provider, + isCI: provider.isCI, + }; + } + } + return { + provider: null, + isCI: !!process.env.CI, + }; +} + +let cachedTelemetryData: GenericTelemetryData | null = null; + +export async function collectData(): Promise { + if (cachedTelemetryData) { + return cachedTelemetryData; + } + const [ + userId, + rootCommitHash, + gitOriginUrl, + branchHash, + runtime, + environment, + ] = await Promise.all([ + getOrCreateUserId(), + getRootCommitHash(), + getGitOriginUrl(), + getBranchName().then(hashString), + getRuntime(), + getEnvironment(), + ]); + cachedTelemetryData = { + userId: userId ?? "", + sessionId: crypto.randomUUID(), + platform: os.platform(), + osVersion: os.release(), + arch: os.arch(), + cpus: os.cpus().length, + memory: Math.round(os.totalmem() / 1024 / 1024), + rootCommitHash: rootCommitHash ?? "", + gitOriginUrl: gitOriginUrl ?? "", + gitBranchHash: branchHash ?? "", + runtime: runtime.name ?? "", + runtimeVersion: runtime.version ?? "", + ciProvider: environment.provider ?? "", + isCI: environment.isCI, + alchemyVersion: pkg.version, + }; + return cachedTelemetryData; +} + +export type GenericTelemetryData = { + userId: string; + sessionId: string; + platform: string; + osVersion: string; + arch: string; + cpus: number; + memory: number; + rootCommitHash: string; + gitOriginUrl: string; + gitBranchHash: string; + runtime: string; + runtimeVersion: string; + ciProvider: string; + isCI: boolean; + alchemyVersion: string; +}; + +export type ErrorData = { + errorTag: string; + errorMessage: string; + errorStack: string; +}; + +export type CliTelemetryData = { + command: string; + event: "cli.start" | "cli.success" | "cli.error"; +}; + +export type ResourceTelemetryData = { + phase: Phase; + event: + | "resource.start" + | "resource.success" + | "resource.error" + | "resource.skip" + | "resource.read"; + resource: string; + status: + | "creating" + | "created" + | "updating" + | "updated" + | "deleting" + | "deleted" + | "unknown"; + duration: number; + replaced: boolean; +}; + +export type StateStoreTelemetryData = { + event: + | "statestore.init" + | "statestore.deinit" + | "statestore.list" + | "statestore.count" + | "statestore.get" + | "statestore.getBatch" + | "statestore.all" + | "statestore.set" + | "statestore.delete"; + stateStore: string; + duration: number; +}; + +export type AlchemyTelemetryData = { + event: "alchemy.start" | "alchemy.success" | "alchemy.error"; + duration: number; +}; + +export async function createEventData( + data: + | CliTelemetryData + | ResourceTelemetryData + | StateStoreTelemetryData + | AlchemyTelemetryData, + error?: Error, +) { + return { + ...data, + ...("duration" in data + ? { duration: Math.round(data.duration * 1000) } + : {}), + ...(await collectData()), + ...serializeError(error), + }; +} + +export async function sendEvent( + data: ( + | CliTelemetryData + | ResourceTelemetryData + | StateStoreTelemetryData + | AlchemyTelemetryData + ) & + ErrorData & + GenericTelemetryData, +) { + if (!TELEMETRY_DISABLED) { + return fetch(TELEMETRY_API_URL, { + method: "POST", + body: JSON.stringify(data), + }); + } +} + +export async function createAndSendEvent( + data: + | CliTelemetryData + | ResourceTelemetryData + | StateStoreTelemetryData + | AlchemyTelemetryData, + error?: Error, +) { + if (Scope.current.noTrack) { + return; + } + try { + const eventData = await createEventData(data, error); + await sendEvent(eventData); + } catch (error) { + if (!SUPPRESS_TELEMETRY_ERRORS) { + logger.warn("Failed to send telemetry event:", error); + } + } +} + +async function hashString(input: string | null): Promise { + if (input == null) { + return null; + } + const data = new TextEncoder().encode(input); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + return Array.from(new Uint8Array(hashBuffer), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); +} + +function serializeError(error: Error | undefined) { + if (error instanceof Error) { + return { + errorTag: error.name ?? "", + errorMessage: error.message?.replaceAll(os.homedir(), "~") ?? "", // redact home directory + errorStack: error.stack?.replaceAll(os.homedir(), "~") ?? "", + }; + } + return { + errorTag: "", + errorMessage: "", + errorStack: "", + }; +} diff --git a/alchemy/test/aws/credentials.test.ts b/alchemy/test/aws/credentials.test.ts index 1e4f1403f..788f8dc17 100644 --- a/alchemy/test/aws/credentials.test.ts +++ b/alchemy/test/aws/credentials.test.ts @@ -6,7 +6,6 @@ import { } from "../../src/aws/credentials.ts"; import { Scope } from "../../src/scope.ts"; import { Secret } from "../../src/secret.ts"; -import { TelemetryClient } from "../../src/util/telemetry/client.ts"; // Helper function to temporarily set environment variables for a test async function withEnv( @@ -147,17 +146,11 @@ describe("AWS Credential Resolution", () => { AWS_ROLE_SESSION_NAME: undefined, }, async () => { - const telemetryClient = TelemetryClient.create({ - phase: "up", - enabled: false, - quiet: true, - }); - const scope = new Scope({ scopeName: "test-scope", parent: undefined, phase: "up", - telemetryClient, + noTrack: true, // Scope-level AWS credential overrides aws: { region: "eu-west-1", // Should override global @@ -184,17 +177,11 @@ describe("AWS Credential Resolution", () => { AWS_SECRET_ACCESS_KEY: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYGLOBAL", }, async () => { - const telemetryClient = TelemetryClient.create({ - phase: "up", - enabled: false, - quiet: true, - }); - const scope = new Scope({ scopeName: "test-scope", parent: undefined, phase: "up", - telemetryClient, + noTrack: true, // Scope-level AWS credential overrides aws: { region: "eu-central-1", diff --git a/alchemy/test/scope-provider-credentials.test.ts b/alchemy/test/scope-provider-credentials.test.ts index 853b44e89..a9e6a2b80 100644 --- a/alchemy/test/scope-provider-credentials.test.ts +++ b/alchemy/test/scope-provider-credentials.test.ts @@ -1,21 +1,14 @@ import { describe, expect, test } from "vitest"; import { alchemy } from "../src/alchemy.ts"; import { Scope } from "../src/scope.ts"; -import { TelemetryClient } from "../src/util/telemetry/client.ts"; describe("Scope Provider Credentials", () => { test("should support AWS credentials at scope level", () => { - const telemetryClient = TelemetryClient.create({ - phase: "up", - enabled: false, - quiet: true, - }); - const scope = new Scope({ scopeName: "test-scope", parent: undefined, phase: "up", - telemetryClient, + noTrack: true, // AWS credentials using the extensible pattern aws: { region: "us-west-2", @@ -31,17 +24,11 @@ describe("Scope Provider Credentials", () => { }); test("should support Cloudflare credentials at scope level", () => { - const telemetryClient = TelemetryClient.create({ - phase: "up", - enabled: false, - quiet: true, - }); - const scope = new Scope({ scopeName: "test-scope", parent: undefined, phase: "up", - telemetryClient, + noTrack: true, // Cloudflare credentials using the extensible pattern cloudflare: { accountId: "abc123", @@ -56,17 +43,11 @@ describe("Scope Provider Credentials", () => { }); test("should support both AWS and Cloudflare credentials at scope level", () => { - const telemetryClient = TelemetryClient.create({ - phase: "up", - enabled: false, - quiet: true, - }); - const scope = new Scope({ scopeName: "test-scope", parent: undefined, phase: "up", - telemetryClient, + noTrack: true, // Both providers can be configured simultaneously aws: { region: "us-west-2", @@ -88,34 +69,22 @@ describe("Scope Provider Credentials", () => { }); test("should handle scope with no provider credentials", () => { - const telemetryClient = TelemetryClient.create({ - phase: "up", - enabled: false, - quiet: true, - }); - const scope = new Scope({ scopeName: "test-scope", parent: undefined, phase: "up", - telemetryClient, + noTrack: true, }); expect(scope.providerCredentials).toEqual({}); }); test("should ignore unknown provider credentials", () => { - const telemetryClient = TelemetryClient.create({ - phase: "up", - enabled: false, - quiet: true, - }); - const scope = new Scope({ scopeName: "test-scope", parent: undefined, phase: "up", - telemetryClient, + noTrack: true, aws: { region: "us-west-2", }, From 36aaca2afd0f724bcb4000e8fd48cb112f50aa99 Mon Sep 17 00:00:00 2001 From: Michael K <35264484+Mkassabov@users.noreply.github.com> Date: Tue, 30 Sep 2025 05:43:53 -0400 Subject: [PATCH 2/6] p1 --- alchemy-web/alchemy.run.ts | 37 +----------- .../.cursor/rules/alchemy_cloudflare.mdc | 4 +- bun.lock | 16 +++++ examples/posthog/alchemy.run.ts | 28 +++++++++ examples/posthog/package.json | 18 ++++++ examples/posthog/src/proxy.ts | 59 +++++++++++++++++++ 6 files changed, 124 insertions(+), 38 deletions(-) create mode 100644 examples/posthog/alchemy.run.ts create mode 100644 examples/posthog/package.json create mode 100644 examples/posthog/src/proxy.ts diff --git a/alchemy-web/alchemy.run.ts b/alchemy-web/alchemy.run.ts index 4d31f5da5..99e71971e 100644 --- a/alchemy-web/alchemy.run.ts +++ b/alchemy-web/alchemy.run.ts @@ -1,6 +1,5 @@ import alchemy from "alchemy"; -import { Astro, Worker, Zone } from "alchemy/cloudflare"; -import { GitHubComment } from "alchemy/github"; +import { Worker, Zone } from "alchemy/cloudflare"; import { CloudflareStateStore } from "alchemy/state"; const POSTHOG_DESTINATION_HOST = @@ -44,39 +43,5 @@ if (stage === "prod") { }); } -export const website = await Astro("website", { - name: "alchemy-website", - adopt: true, - version: stage === "prod" ? undefined : stage, - domains: domain ? [domain] : undefined, - env: { - POSTHOG_CLIENT_API_HOST: `https://${POSTHOG_PROXY_HOST}`, - POSTHOG_PROJECT_ID: POSTHOG_PROJECT_ID, - ENABLE_POSTHOG: stage === "prod" ? "true" : "false", - }, -}); - -const url = domain ? `https://${domain}` : website.url; - -console.log(url); - -if (process.env.PULL_REQUEST) { - await GitHubComment("comment", { - owner: "sam-goodwin", - repository: "alchemy", - issueNumber: Number(process.env.PULL_REQUEST), - body: ` -## 🚀 Website Preview Deployed - -Your website preview is ready! - -**Preview URL:** ${url} - -This preview was built from commit ${process.env.GITHUB_SHA} - ---- -🤖 This comment will be updated automatically when you push new commits to this PR.`, - }); -} await app.finalize(); diff --git a/alchemy/templates/rwsdk/.cursor/rules/alchemy_cloudflare.mdc b/alchemy/templates/rwsdk/.cursor/rules/alchemy_cloudflare.mdc index 5163086b1..e5a15c147 100644 --- a/alchemy/templates/rwsdk/.cursor/rules/alchemy_cloudflare.mdc +++ b/alchemy/templates/rwsdk/.cursor/rules/alchemy_cloudflare.mdc @@ -306,7 +306,7 @@ const app = await alchemy("my-app", { ### Framework Integration (Vite/React/etc.) ```typescript const website = await Vite("website", { - entrypoint: "./src/worker.ts", + main: "./src/worker.ts", command: "bun run build", // Build command bindings: { API: await Worker("api", { entrypoint: "./api/worker.ts" }), @@ -324,7 +324,7 @@ await alchemy.run("backend", async () => { }); await alchemy.run("frontend", async () => { - await Vite("website", { entrypoint: "./src/worker.ts" }); + await Vite("website", { main: "./src/worker.ts" }); }); ``` diff --git a/bun.lock b/bun.lock index 993fcf482..9cbc5a36f 100644 --- a/bun.lock +++ b/bun.lock @@ -676,6 +676,16 @@ "typescript": "catalog:", }, }, + "examples/posthog": { + "name": "cloudflare-posthog", + "version": "0.0.0", + "devDependencies": { + "@cloudflare/workers-types": "catalog:", + "@types/node": "^24.0.1", + "alchemy": "workspace:*", + "typescript": "catalog:", + }, + }, "stacks": { "name": "alchemy-stacks", "version": "0.0.1", @@ -2667,6 +2677,8 @@ "cloudflare-orange": ["cloudflare-orange@workspace:examples/cloudflare-orange"], + "cloudflare-posthog": ["cloudflare-posthog@workspace:examples/posthog"], + "cloudflare-prisma": ["cloudflare-prisma@workspace:examples/cloudflare-prisma"], "cloudflare-react-router": ["cloudflare-react-router@workspace:examples/cloudflare-react-router"], @@ -5567,6 +5579,8 @@ "cloudflare-orange/esbuild": ["esbuild@0.24.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.24.2", "@esbuild/android-arm": "0.24.2", "@esbuild/android-arm64": "0.24.2", "@esbuild/android-x64": "0.24.2", "@esbuild/darwin-arm64": "0.24.2", "@esbuild/darwin-x64": "0.24.2", "@esbuild/freebsd-arm64": "0.24.2", "@esbuild/freebsd-x64": "0.24.2", "@esbuild/linux-arm": "0.24.2", "@esbuild/linux-arm64": "0.24.2", "@esbuild/linux-ia32": "0.24.2", "@esbuild/linux-loong64": "0.24.2", "@esbuild/linux-mips64el": "0.24.2", "@esbuild/linux-ppc64": "0.24.2", "@esbuild/linux-riscv64": "0.24.2", "@esbuild/linux-s390x": "0.24.2", "@esbuild/linux-x64": "0.24.2", "@esbuild/netbsd-arm64": "0.24.2", "@esbuild/netbsd-x64": "0.24.2", "@esbuild/openbsd-arm64": "0.24.2", "@esbuild/openbsd-x64": "0.24.2", "@esbuild/sunos-x64": "0.24.2", "@esbuild/win32-arm64": "0.24.2", "@esbuild/win32-ia32": "0.24.2", "@esbuild/win32-x64": "0.24.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA=="], + "cloudflare-posthog/@types/node": ["@types/node@24.6.0", "", { "dependencies": { "undici-types": "~7.13.0" } }, "sha512-F1CBxgqwOMc4GKJ7eY22hWhBVQuMYTtqI8L0FcszYcpYX0fzfDGpez22Xau8Mgm7O9fI+zA/TYIdq3tGWfweBA=="], + "cloudflare-react-router/@types/node": ["@types/node@20.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ=="], "compress-commons/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], @@ -6513,6 +6527,8 @@ "cloudflare-orange/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.24.2", "", { "os": "win32", "cpu": "x64" }, "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg=="], + "cloudflare-posthog/@types/node/undici-types": ["undici-types@7.13.0", "", {}, "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ=="], + "cloudflare-react-router/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "cloudflare/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], diff --git a/examples/posthog/alchemy.run.ts b/examples/posthog/alchemy.run.ts new file mode 100644 index 000000000..7140f3a97 --- /dev/null +++ b/examples/posthog/alchemy.run.ts @@ -0,0 +1,28 @@ +import alchemy from "alchemy"; +import { Worker, Zone } from "alchemy/cloudflare"; + +const POSTHOG_DESTINATION_HOST = + process.env.POSTHOG_DESTINATION_HOST ?? "us.i.posthog.com"; +const POSTHOT_ASSET_DESTINATION_HOST = + process.env.POSTHOG_ASSET_DESTINATION_HOST ?? "us.i.posthog.com"; +const ZONE = alchemy.env.ZONE; +const POSTHOG_PROXY_HOST = `ph.${ZONE}`; + +const app = await alchemy("cloudflare-posthog"); + +await Zone("alchemy-run", { + name: "alchemy.run", +}); + +await Worker("posthog-proxy", { + adopt: true, + name: "alchemy-posthog-proxy", + entrypoint: "src/proxy.ts", + domains: [POSTHOG_PROXY_HOST], + bindings: { + POSTHOG_DESTINATION_HOST: POSTHOG_DESTINATION_HOST, + POSTHOT_ASSET_DESTINATION_HOST: POSTHOT_ASSET_DESTINATION_HOST, + }, +}); + +await app.finalize(); diff --git a/examples/posthog/package.json b/examples/posthog/package.json new file mode 100644 index 000000000..89a884b11 --- /dev/null +++ b/examples/posthog/package.json @@ -0,0 +1,18 @@ +{ + "name": "cloudflare-posthog", + "version": "0.0.0", + "description": "Alchemy Typescript Project", + "type": "module", + "scripts": { + "build": "tsc -b", + "deploy": "alchemy deploy --env-file ../../.env", + "destroy": "alchemy destroy --env-file ../../.env", + "dev": "alchemy dev --env-file ../../.env" + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:", + "@types/node": "^24.0.1", + "alchemy": "workspace:*", + "typescript": "catalog:" + } +} diff --git a/examples/posthog/src/proxy.ts b/examples/posthog/src/proxy.ts new file mode 100644 index 000000000..823b9e6f9 --- /dev/null +++ b/examples/posthog/src/proxy.ts @@ -0,0 +1,59 @@ +// Sourced from: https://posthog.com/docs/advanced/proxy/cloudflare +// We have modified it to use typescript and some alchemy goodies + +import type { PosthogProxy } from "../alchemy.run.ts"; + +type Env = PosthogProxy["Env"]; + +async function handleRequest( + request: Request, + env: Env, + ctx: ExecutionContext, +) { + const url = new URL(request.url); + const pathname = url.pathname; + const search = url.search; + const pathWithParams = pathname + search; + + if (pathname.startsWith("/static/")) { + return retrieveStatic(request, pathWithParams, env, ctx); + } else { + return forwardRequest(request, pathWithParams, env); + } +} + +async function retrieveStatic( + request: Request, + pathname: string, + env: Env, + ctx: ExecutionContext, +) { + const assetCache = await caches.open("asset-cache"); + let response = await assetCache.match(request); + if (!response) { + response = await fetch( + `https://${env.POSTHOT_ASSET_DESTINATION_HOST}${pathname}`, + ); + ctx.waitUntil(assetCache.put(request, response.clone())); + } + return response; +} + +async function forwardRequest( + request: Request, + pathWithSearch: string, + env: Env, +) { + const originRequest = new Request(request); + originRequest.headers.delete("cookie"); + return await fetch( + `https://${env.POSTHOG_DESTINATION_HOST}${pathWithSearch}`, + originRequest, + ); +} + +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext) { + return handleRequest(request, env, ctx); + }, +} as ExportedHandler; From 89eb995c09b94ff59e66db4ae4c36fa7a3a87d99 Mon Sep 17 00:00:00 2001 From: Michael K <35264484+Mkassabov@users.noreply.github.com> Date: Tue, 30 Sep 2025 08:01:28 -0400 Subject: [PATCH 3/6] Create 2025-09-30-reworking-alchemys-telemetry.md --- ...2025-09-30-reworking-alchemys-telemetry.md | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 alchemy-web/src/content/docs/blog/2025-09-30-reworking-alchemys-telemetry.md diff --git a/alchemy-web/src/content/docs/blog/2025-09-30-reworking-alchemys-telemetry.md b/alchemy-web/src/content/docs/blog/2025-09-30-reworking-alchemys-telemetry.md new file mode 100644 index 000000000..f75e6e92a --- /dev/null +++ b/alchemy-web/src/content/docs/blog/2025-09-30-reworking-alchemys-telemetry.md @@ -0,0 +1,179 @@ +# Reworking Alchemy's Telemetry + +## Why we left posthog + +Alchemy used to use posthog for our platform analytics. While posthot was really easy to set up (see our example here) it simply didn't meet our needs. One of our most important metrics is the number of projects that use alchemy. In order to get quality analytics from posthog we would need to give each alchemy project a dedicated project id and maintain that id as a project grows; this proves to be quite a challenging issue. We can't just use a UUID and store it somewhere as alchemy doesn't have a config file, and we don't expect the `.alchemy` directory to be committed. We explored using various other solutions such as the root commit hash (unavailable for partial clone) or the git upstream url (breaks if origin changes) but none of these solutions were reliably enough. Ultimately we decided to identify projects based on multiple factors instead of having a single id. + +Between not having a consistent project id, and multiple of our team members having experience with datalakes and large scale analytics solutions, we decided to switch to a more developer-oriented solutions. Just having all of our own data in-house so we can do whatever we want with it as we please. + +That being said posthog is great at what it does, and we will continue to use posthog for our web analytics where its still a great fit! + +## Why We Chose Clickhouse Cloud + +We started with a few criteria +- we wanted an OLAP database since they are great for analytics +- we wanted something SQL based so we didn't have to learn a new query language +- we wanted something to avoid the big cloud providers as we don't support GCP or Azure yet, and we are in the middle of revamping our AWS resources as part of our [effect](https://effect.website/) based rewrite. +- we wanted something quick as the team was getting frustrated with our current analytics solution. +- preferrably a nice controlplane api + +After looking at the options we decided to go with Clickhouse Cloud. It had a great controlplane api so making a resource was easy. First we generate a typescript api from Clickhouse's OpenAPI spec, then we write our alchemy resource. + +This is a simplified example, but we're omitting clickhouse's plethora of customization options for brevity, but the full resource is available [here](https://github.com/alchemy-framework/alchemy/blob/main/alchemy/src/clickhouse/service.ts). + +```ts +export const Service = Resource( + "clickhouse::Service", + async function ( + this: Context, + id: string, + props: ServiceProps, + ): Promise { + const api = createClickhouseApi(); + const minReplicaMemoryGb = props.minReplicaMemoryGb ?? 8; + const maxReplicaMemoryGb = props.maxReplicaMemoryGb ?? 356; + const numReplicas = props.numReplicas ?? 3; + const name = this.scope.createPhysicalName(id); + + if (this.phase === "delete") { + await api.deleteService({ + path: { + organizationId: props.organization.id, + serviceId: this.output.clickhouseId, + }, + }); + return this.destroy(); + } + if (this.phase === "update") { + const resourceDiff = diff(props,this.output,); + const updates: Partial = {}; + + if (resourceDiff.some((prop) => prop === "name" && prop === "minReplicaMemoryGb" && prop === "maxReplicaMemoryGb" && prop === "numReplicas")) { return this.replace(); } + + if (resourceDiff.some((prop) => prop === "name")) { + const response = ( + await api.updateServiceBasicDetails({ + path: { + organizationId: props.organization.id, + serviceId: this.output.clickhouseId, + }, + body: { name }, + }) + ).data.result; + + updates.name = response.name; + updates.mysqlEndpoint = response.endpoints.find( + (endpoint) => endpoint.protocol === "mysql", + ) as any; + updates.httpsEndpoint = response.endpoints.find( + (endpoint) => endpoint.protocol === "https", + ) as any; + } + + if ( + resourceDiff.some( + (prop) => prop === "minReplicaMemoryGb" || prop === "maxReplicaMemoryGb" || prop === "numReplicas", + ) + ) { + const response = ( + await api.updateServiceAutoScalingSettings2({ + path: { + organizationId: props.organization.id, + serviceId: this.output.clickhouseId, + }, + body: { + minReplicaMemoryGb: props.minReplicaMemoryGb, + maxReplicaMemoryGb: props.maxReplicaMemoryGb, + numReplicas: props.numReplicas, + }, + }) + ).data.result; + + updates.minReplicaMemoryGb = response.minReplicaMemoryGb; + updates.maxReplicaMemoryGb = response.maxReplicaMemoryGb; + updates.numReplicas = response.numReplicas; + } + + return { + ...this.output, + ...updates, + }; + } + + const response = ( + await api.createNewService({ + path: { + organizationId: props.organization.id, + }, + body: { + name, + provider: props.provider, + region: props.region, + minReplicaMemoryGb: minReplicaMemoryGb, + maxReplicaMemoryGb: maxReplicaMemoryGb, + numReplicas: numReplicas, + }, + }) + ).data.result; + + return { + organizationId: props.organization.id, + name: response.service.name, + clickhouseId: response.service.id, + password: secret(response.password), + provider: response.service.provider, + region: response.service.region, + minReplicaMemoryGb: response.service.minReplicaMemoryGb, + maxReplicaMemoryGb: response.service.maxReplicaMemoryGb, + numReplicas: response.service.numReplicas, + mysqlEndpoint: response.service.endpoints.find( + (endpoint) => endpoint.protocol === "mysql", + ) as any, + httpsEndpoint: response.service.endpoints.find( + (endpoint) => endpoint.protocol === "https", + ) as any, + }; + }, +); +``` + +Its only about 100 lines of code and now we have an alchemy resource for clickhouse. + +## Using the resource + +While our telemetry backend isn't open source, the `alchemy.run.ts` file is less than 25 lines. + +```ts +// imports +export const app = await alchemy("alchemy-telemetry"); +const organization = await getOrganizationByName(alchemy.env.CLICKHOUSE_ORG); + +const clickhouse = await Service("clickhouse", { + organization, + provider: "aws", + region: "us-east-1", + minReplicaMemoryGb: 8, + maxReplicaMemoryGb: 356, + numReplicas: 3, +}); + +await Exec("migrations", { + command: `bunx clickhouse-migrations migrate --db default --host https://${clickhouse.httpsEndpoint?.host}:${clickhouse.httpsEndpoint?.port} --user ${clickhouse.mysqlEndpoint?.username} --password ${clickhouse.password.unencrypted} --migrations-home ${join(import.meta.dirname, "migrations")}`, +}); + +export const ingestWorker = await Worker("ingest-worker", { + adopt: true, + entrypoint: "./deployments/telemetry.ts", + bindings: { + CLICKHOUSE_URL: `https://${clickhouse.httpsEndpoint?.host}:${clickhouse.httpsEndpoint?.port}`, + CLICKHOUSE_PASSWORD: clickhouse.password, + }, + domains: ["telemetry.alchemy.run"], +}); + +await app.finalize(); +``` + +## Future Improvements + +Just dumping data straight to clickhouse is by no means the best solution, we understand that! Our goal here was to quickly spin up some data storage and fix our analytics; We'll share more in depth technical details in the future on how to bring down costs and build a more enterprise level data solution on top of alchemy. \ No newline at end of file From beb2a0852718c17080e6b54047a13609006fc048 Mon Sep 17 00:00:00 2001 From: Michael K <35264484+Mkassabov@users.noreply.github.com> Date: Tue, 30 Sep 2025 08:03:56 -0400 Subject: [PATCH 4/6] remove --- alchemy/src/util/telemetry/client.ts | 156 -------------------------- alchemy/src/util/telemetry/context.ts | 154 ------------------------- alchemy/src/util/telemetry/index.ts | 2 - alchemy/src/util/telemetry/types.ts | 105 ----------------- 4 files changed, 417 deletions(-) delete mode 100644 alchemy/src/util/telemetry/client.ts delete mode 100644 alchemy/src/util/telemetry/context.ts delete mode 100644 alchemy/src/util/telemetry/index.ts delete mode 100644 alchemy/src/util/telemetry/types.ts diff --git a/alchemy/src/util/telemetry/client.ts b/alchemy/src/util/telemetry/client.ts deleted file mode 100644 index 3466ba20b..000000000 --- a/alchemy/src/util/telemetry/client.ts +++ /dev/null @@ -1,156 +0,0 @@ -import os from "node:os"; -import type { Phase } from "../../alchemy.ts"; -import { - POSTHOG_CLIENT_API_HOST, - POSTHOG_PROJECT_ID, - TELEMETRY_DISABLED, -} from "./constants.ts"; -import { context } from "./context.ts"; -import type { Telemetry } from "./types.ts"; - -export interface TelemetryClientOptions { - sessionId: string; - phase?: Phase; - enabled: boolean; - quiet: boolean; -} - -export interface ITelemetryClient { - ready: Promise; - record(event: Telemetry.EventInput): void; - finalize(): Promise; -} - -export class NoopTelemetryClient implements ITelemetryClient { - ready = Promise.resolve(); - record(_: Telemetry.EventInput) {} - finalize() { - return Promise.resolve(); - } -} - -export class TelemetryClient implements ITelemetryClient { - private context: Promise; - private events: Telemetry.Event[] = []; - - constructor(readonly options: TelemetryClientOptions) { - this.context = context({ - sessionId: this.options.sessionId, - phase: this.options.phase, - }); - this.record({ - event: "app.start", - }); - } - - get ready() { - return this.context; - } - - record(event: Telemetry.EventInput, timestamp = Date.now()) { - const payload = { - ...event, - error: this.serializeError(event.error), - timestamp, - } as Telemetry.Event; - this.events.push(payload); - } - - private serializeError( - error: Telemetry.ErrorInput | undefined, - ): Telemetry.SerializedError | undefined { - if (!error) { - return undefined; - } - if (error instanceof Error) { - return { - ...error, // include additional properties from error object - name: error.name, - message: error.message?.replaceAll(os.homedir(), "~"), // redact home directory - stack: error.stack?.replaceAll(os.homedir(), "~"), - }; - } - return error; - } - - async finalize() { - await this.send(this.events).catch((error) => { - if (!this.options.quiet) { - console.warn(error); - } - }); - } - - private async send(events: Telemetry.Event[]) { - if (events.length === 0) { - return; - } - const { userId, projectId, ...context } = await this.context; - const safeProjectId = projectId ?? `temp_${userId}`; - const groupResponse = await fetch(`${POSTHOG_CLIENT_API_HOST}/i/v0/e/`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - api_key: POSTHOG_PROJECT_ID, - event: "$groupidentify", - distinct_id: "project-identify", - properties: { - $group_type: "project", - $group_key: safeProjectId, - }, - }), - }); - if (!groupResponse.ok) { - throw new Error( - `Failed to send group identify: ${groupResponse.status} ${groupResponse.statusText} - ${await groupResponse.text()}`, - ); - } - const response = await fetch(`${POSTHOG_CLIENT_API_HOST}/batch`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - api_key: POSTHOG_PROJECT_ID, - historical_migration: false, - batch: events.map(({ event, timestamp, ...properties }) => ({ - event, - properties: { - distinct_id: userId, - ...context, - ...properties, - projectId: safeProjectId, - $groups: { project: safeProjectId }, - }, - timestamp: new Date(timestamp).toISOString(), - })), - }), - }); - if (!response.ok) { - throw new Error( - `Failed to send telemetry: ${response.status} ${response.statusText} - ${await response.text()}`, - ); - } - } - - static create({ - phase, - enabled, - quiet, - }: Omit): ITelemetryClient { - if (!enabled || TELEMETRY_DISABLED) { - if (!quiet) { - console.warn("[Alchemy] Telemetry is disabled."); - } - return new NoopTelemetryClient(); - } - return new TelemetryClient({ - sessionId: crypto.randomUUID(), - phase, - enabled, - quiet, - }); - } -} diff --git a/alchemy/src/util/telemetry/context.ts b/alchemy/src/util/telemetry/context.ts deleted file mode 100644 index 39caebf9e..000000000 --- a/alchemy/src/util/telemetry/context.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { exec } from "node:child_process"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import os from "node:os"; -import { join } from "node:path"; -import pkg from "../../../package.json" with { type: "json" }; -import type { Phase } from "../../alchemy.ts"; -import { CONFIG_DIR } from "./constants.ts"; -import type { Telemetry } from "./types.ts"; - -async function userId() { - const path = join(CONFIG_DIR, "id"); - - try { - return (await readFile(path, "utf-8")).trim(); - } catch {} - - try { - await mkdir(CONFIG_DIR, { recursive: true }); - } catch {} - - const id = crypto.randomUUID(); - try { - await writeFile(path, id); - console.warn( - [ - "Attention: To help improve Alchemy, we now collect anonymous usage, performance, and error data.", - "You can opt out by setting the ALCHEMY_TELEMETRY_DISABLED or DO_NOT_TRACK environment variable to a truthy value.", - ].join("\n"), - ); - } catch { - return null; - } - - return id; -} - -function projectId() { - return new Promise((resolve) => { - exec("git rev-list --max-parents=0 HEAD", (err, stdout) => { - if (err) { - resolve(null); - return; - } - resolve(stdout.trim()); - }); - }); -} - -function system(): Telemetry.Context["system"] { - return { - platform: os.platform(), - osVersion: os.release(), - arch: os.arch(), - cpus: os.cpus().length, - memory: os.totalmem() / 1024 / 1024, - }; -} - -declare global { - var Deno: - | { - version: { - deno: string; - }; - } - | undefined; - var EdgeRuntime: Record | undefined; -} - -const RUNTIMES = [ - { - name: "bun", - detect: () => !!globalThis.Bun, - version: () => globalThis.Bun?.version, - }, - { - name: "deno", - detect: () => !!globalThis.Deno, - version: () => globalThis.Deno?.version?.deno, - }, - { - name: "workerd", - detect: () => !!globalThis.EdgeRuntime, - version: () => null, - }, - { - name: "node", - detect: () => !!globalThis.process?.versions?.node, - version: () => process.versions.node, - }, -] as const; - -function runtime(): Telemetry.Context["runtime"] { - for (const runtime of RUNTIMES) { - if (runtime.detect()) { - return { - name: runtime.name, - version: runtime.version() ?? null, - }; - } - } - return { - name: null, - version: null, - }; -} - -const PROVIDERS = [ - { env: "GITHUB_ACTIONS", provider: "GitHub Actions", isCI: true }, - { env: "GITLAB_CI", provider: "GitLab CI", isCI: true }, - { env: "CIRCLECI", provider: "CircleCI", isCI: true }, - { env: "JENKINS_URL", provider: "Jenkins", isCI: true }, - { env: "TRAVIS", provider: "Travis CI", isCI: true }, - { env: "NOW_BUILDER", provider: "Vercel", isCI: true }, - { env: "VERCEL", provider: "Vercel", isCI: false }, -]; - -function environment(): Telemetry.Context["environment"] { - for (const provider of PROVIDERS) { - if (process.env[provider.env]) { - return { - provider: provider.provider, - isCI: provider.isCI, - }; - } - } - return { - provider: null, - isCI: !!process.env.CI, - }; -} - -export async function context(input: { - sessionId: string; - phase?: Phase; -}): Promise { - const env = environment(); - const [uid, pid] = await Promise.all([ - env.isCI ? null : userId(), - projectId(), - ]); - return { - userId: uid, - projectId: pid, - sessionId: input.sessionId, - system: system(), - runtime: runtime(), - environment: env, - alchemy: { - version: pkg.version, - phase: input.phase, - }, - }; -} diff --git a/alchemy/src/util/telemetry/index.ts b/alchemy/src/util/telemetry/index.ts deleted file mode 100644 index bd12951d7..000000000 --- a/alchemy/src/util/telemetry/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./client.ts"; -export * from "./types.ts"; diff --git a/alchemy/src/util/telemetry/types.ts b/alchemy/src/util/telemetry/types.ts deleted file mode 100644 index c4ad609e3..000000000 --- a/alchemy/src/util/telemetry/types.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type { Phase } from "../../alchemy.ts"; - -export namespace Telemetry { - export interface Context { - /** Random UUID generated once per machine and stored in XDG config directory (e.g. `~/Library/Preferences/alchemy` on Mac). Null for CI environments. */ - userId: string | null; - /** Root commit hash for the git repository. Anonymous, stable identifier for anyone using git. */ - projectId: string | null; - /** UUID generated once per application run. */ - sessionId: string; - - system: { - platform: string; - osVersion: string; - arch: string; - cpus: number; - memory: number; - }; - - runtime: { - name: string | null; - version: string | null; - }; - - environment: { - provider: string | null; - isCI: boolean; - }; - - alchemy: { - version: string; - phase?: Phase; - }; - } - - export interface BaseEvent { - event: string; - timestamp: number; - } - - export interface SerializedError { - name?: string; - message: string; - stack?: string; - } - - export type ErrorInput = Error | SerializedError; - - export interface AppEvent extends BaseEvent { - event: "app.start" | "app.success" | "app.error"; - elapsed?: number; - error?: SerializedError; - } - - export interface ResourceEvent extends BaseEvent { - event: - | "resource.start" - | "resource.success" - | "resource.error" - | "resource.skip" - | "resource.read"; - resource: string; - status?: - | "creating" - | "created" - | "updating" - | "updated" - | "deleting" - | "deleted"; - elapsed?: number; - error?: SerializedError; - replaced?: boolean; - } - - export interface CliEvent extends BaseEvent { - event: "cli.start" | "cli.success" | "cli.error"; - command: string; - } - - export interface StateStoreEvent extends BaseEvent { - event: - | "stateStore.init" - | "stateStore.deinit" - | "stateStore.list" - | "stateStore.count" - | "stateStore.get" - | "stateStore.getBatch" - | "stateStore.all" - | "stateStore.set" - | "stateStore.delete"; - stateStoreClass: string; - elapsed?: number; - error?: SerializedError; - } - - export type Event = AppEvent | ResourceEvent | StateStoreEvent | CliEvent; - export type EventInput = ( - | Omit - | Omit - | Omit - | Omit - ) & { - error?: ErrorInput; - }; -} From 7f65aca27fac9e3855a5d9441bafcd8ed1d6907c Mon Sep 17 00:00:00 2001 From: Michael K <35264484+Mkassabov@users.noreply.github.com> Date: Tue, 30 Sep 2025 14:01:29 -0400 Subject: [PATCH 5/6] Update alchemy.run.ts --- alchemy-web/alchemy.run.ts | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/alchemy-web/alchemy.run.ts b/alchemy-web/alchemy.run.ts index 99e71971e..4d31f5da5 100644 --- a/alchemy-web/alchemy.run.ts +++ b/alchemy-web/alchemy.run.ts @@ -1,5 +1,6 @@ import alchemy from "alchemy"; -import { Worker, Zone } from "alchemy/cloudflare"; +import { Astro, Worker, Zone } from "alchemy/cloudflare"; +import { GitHubComment } from "alchemy/github"; import { CloudflareStateStore } from "alchemy/state"; const POSTHOG_DESTINATION_HOST = @@ -43,5 +44,39 @@ if (stage === "prod") { }); } +export const website = await Astro("website", { + name: "alchemy-website", + adopt: true, + version: stage === "prod" ? undefined : stage, + domains: domain ? [domain] : undefined, + env: { + POSTHOG_CLIENT_API_HOST: `https://${POSTHOG_PROXY_HOST}`, + POSTHOG_PROJECT_ID: POSTHOG_PROJECT_ID, + ENABLE_POSTHOG: stage === "prod" ? "true" : "false", + }, +}); + +const url = domain ? `https://${domain}` : website.url; + +console.log(url); + +if (process.env.PULL_REQUEST) { + await GitHubComment("comment", { + owner: "sam-goodwin", + repository: "alchemy", + issueNumber: Number(process.env.PULL_REQUEST), + body: ` +## 🚀 Website Preview Deployed + +Your website preview is ready! + +**Preview URL:** ${url} + +This preview was built from commit ${process.env.GITHUB_SHA} + +--- +🤖 This comment will be updated automatically when you push new commits to this PR.`, + }); +} await app.finalize(); From 7eb96f9a69963bc9e208f6482419ee611e32f6dd Mon Sep 17 00:00:00 2001 From: Michael K <35264484+Mkassabov@users.noreply.github.com> Date: Tue, 30 Sep 2025 14:02:21 -0400 Subject: [PATCH 6/6] Update alchemy_cloudflare.mdc --- alchemy/templates/rwsdk/.cursor/rules/alchemy_cloudflare.mdc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/alchemy/templates/rwsdk/.cursor/rules/alchemy_cloudflare.mdc b/alchemy/templates/rwsdk/.cursor/rules/alchemy_cloudflare.mdc index e5a15c147..2b9ced882 100644 --- a/alchemy/templates/rwsdk/.cursor/rules/alchemy_cloudflare.mdc +++ b/alchemy/templates/rwsdk/.cursor/rules/alchemy_cloudflare.mdc @@ -25,7 +25,7 @@ import { Worker, KVNamespace } from "alchemy/cloudflare"; const app = await alchemy("my-app"); export const worker = await Worker("api", { - entrypoint: "./src/worker.ts", + main: "./src/worker.ts", bindings: { CACHE: await KVNamespace("cache", { title: "cache-store" }) } @@ -324,7 +324,7 @@ await alchemy.run("backend", async () => { }); await alchemy.run("frontend", async () => { - await Vite("website", { main: "./src/worker.ts" }); + await Vite("website", { entrypoint: "./src/worker.ts" }); }); ```