1import { rootDir } from "../Auth/Paths.ts";
2import { exec } from "node:child_process";
3import * as crypto from "node:crypto";
4import * as fs from "node:fs/promises";
5import * as os from "node:os";
6import * as nodePath from "node:path";
7
8import * as Effect from "effect/Effect";
9
10import packageJson from "../../package.json" with { type: "json" };
11
12const idPath = () => nodePath.join(rootDir(), "id");
13const disabledPath = () => nodePath.join(rootDir(), "telemetry-disabled");
14
15/**
16 * OTel resource attributes describing the user, project, runtime, and
17 * environment running the alchemy CLI. Computed once per process and
18 * attached to every span and metric exported by `TelemetryLive`.
19 */
20export interface TelemetryAttributes {
21 readonly "alchemy.user.id": string;
22 readonly "alchemy.session.id": string;
23 readonly "alchemy.version": string;
24 readonly "alchemy.git.root_commit": string;
25 readonly "alchemy.git.origin_hash": string;
26 readonly "alchemy.git.branch_hash": string;
27 readonly "alchemy.runtime.name": string;
28 readonly "alchemy.runtime.version": string;
29 readonly "alchemy.ci.provider": string;
30 readonly "alchemy.ci": boolean;
31 readonly "host.arch": string;
32 readonly "os.type": string;
33 readonly "os.version": string;
34 readonly "host.cpus": number;
35 readonly "host.memory_mb": number;
36}
37
38const tryRead = (path: string): Effect.Effect<string | null> =>
39 Effect.tryPromise(() =>
40 fs.readFile(path, "utf-8").then((s) => s.trim()),
41 ).pipe(Effect.orElseSucceed(() => null));
42
43const sha256Hex = (input: string): string =>
44 crypto.createHash("sha256").update(input).digest("hex");
45
46const hashStringOrEmpty = (input: string | null): string =>
47 input == null || input === "" ? "" : sha256Hex(input);
48
49const execCapture = (cmd: string): Effect.Effect<string | null> =>
50 Effect.callback<string | null>((resume) => {
51 exec(cmd, { timeout: 1500 }, (err, stdout) => {
52 if (err) {
53 resume(Effect.succeed(null));
54 return;
55 }
56 resume(Effect.succeed(stdout.toString().trim()));
57 });
58 });
59
60const getOrCreateUserId: Effect.Effect<string> = Effect.gen(function* () {
61 const existing = yield* tryRead(idPath());
62 if (existing) return existing;
63
64 const id = crypto.randomUUID();
65 yield* Effect.tryPromise({
66 try: async () => {
67 await fs.mkdir(rootDir(), { recursive: true });
68 await fs.writeFile(idPath(), id);
69 },
70 catch: () => null as never,
71 }).pipe(Effect.catch(() => Effect.void));
72 return id;
73});
74
75const getRootCommitHash: Effect.Effect<string | null> = execCapture(
76 process.platform === "win32"
77 ? `git rev-list --max-parents=0 HEAD | ForEach-Object { if (-not (git cat-file -p $_ | Select-String "^parent ")) { $_ } }`
78 : `git rev-list --max-parents=0 HEAD | xargs -r -I{} sh -c 'git cat-file -p {} | grep -q "^parent " || echo {}'`,
79);
80
81const getGitOriginUrl: Effect.Effect<string | null> = execCapture(
82 "git config --get remote.origin.url",
83);
84
85const getBranchName: Effect.Effect<string | null> = execCapture(
86 "git rev-parse --abbrev-ref HEAD",
87);
88
89const getRuntime = (): { name: string; version: string } => {
90 const g = globalThis as unknown as {
91 Bun?: { version: string };
92 Deno?: { version?: { deno?: string } };
93 };
94 if (typeof g.Bun !== "undefined") {
95 return { name: "bun", version: g.Bun.version };
96 }
97 if (typeof g.Deno !== "undefined") {
98 return { name: "deno", version: g.Deno.version?.deno ?? "" };
99 }
100 if (globalThis.process?.versions?.node) {
101 return { name: "node", version: process.versions.node };
102 }
103 return { name: "", version: "" };
104};
105
106const CI_PROVIDERS = [
107 { env: "GITHUB_ACTIONS", provider: "GitHub Actions", isCI: true },
108 { env: "GITLAB_CI", provider: "GitLab CI", isCI: true },
109 { env: "CIRCLECI", provider: "CircleCI", isCI: true },
110 { env: "JENKINS_URL", provider: "Jenkins", isCI: true },
111 { env: "TRAVIS", provider: "Travis CI", isCI: true },
112 { env: "BUILDKITE", provider: "Buildkite", isCI: true },
113 { env: "NOW_BUILDER", provider: "Vercel", isCI: true },
114 { env: "VERCEL", provider: "Vercel", isCI: false },
115] as const;
116
117const getCIEnvironment = (): { provider: string; isCI: boolean } => {
118 for (const p of CI_PROVIDERS) {
119 if (process.env[p.env]) {
120 return { provider: p.provider, isCI: p.isCI };
121 }
122 }
123 return { provider: "", isCI: !!process.env.CI };
124};
125
126const TELEMETRY_DISABLED_ENV = (): boolean =>
127 !!process.env.ALCHEMY_TELEMETRY_DISABLED ||
128 !!process.env.DO_NOT_TRACK ||
129 !!process.env.NO_TRACK;
130
131/**
132 * `true` if telemetry is disabled via environment variable
133 * (`DO_NOT_TRACK`, `NO_TRACK`, `ALCHEMY_TELEMETRY_DISABLED`) or via a
134 * persisted opt-out file at `~/.alchemy/telemetry-disabled`.
135 */
136export const isTelemetryDisabled: Effect.Effect<boolean> = Effect.gen(
137 function* () {
138 if (TELEMETRY_DISABLED_ENV()) return true;
139 const persisted = yield* tryRead(disabledPath());
140 return persisted === "true";
141 },
142);
143
144/**
145 * Persists an opt-out so future invocations skip telemetry without needing
146 * an env var.
147 */
148export const setTelemetryDisabled: Effect.Effect<void> = Effect.tryPromise(
149 async () => {
150 await fs.mkdir(rootDir(), { recursive: true });
151 await fs.writeFile(disabledPath(), "true");
152 },
153).pipe(Effect.ignore);
154
155export const setTelemetryEnabled: Effect.Effect<void> = Effect.tryPromise(() =>
156 fs.rm(disabledPath(), { force: true }),
157).pipe(Effect.ignore);
158
159const collectAttributesUncached: Effect.Effect<TelemetryAttributes> =
160 Effect.gen(function* () {
161 const [userId, rootCommit, originUrl, branch] = yield* Effect.all(
162 [
163 getOrCreateUserId,
164 getRootCommitHash,
165 getGitOriginUrl,
166 getBranchName,
167 ] as const,
168 { concurrency: "unbounded" },
169 );
170
171 const runtime = getRuntime();
172 const ci = getCIEnvironment();
173
174 return {
175 "alchemy.user.id": userId,
176 "alchemy.session.id":
177 process.env.ALCHEMY_TELEMETRY_SESSION_ID ?? crypto.randomUUID(),
178 "alchemy.version": packageJson.version,
179 "alchemy.git.root_commit": rootCommit ?? "",
180 "alchemy.git.origin_hash": hashStringOrEmpty(originUrl),
181 "alchemy.git.branch_hash": hashStringOrEmpty(branch),
182 "alchemy.runtime.name": runtime.name,
183 "alchemy.runtime.version": runtime.version,
184 "alchemy.ci.provider": ci.provider,
185 "alchemy.ci": ci.isCI,
186 "host.arch": os.arch(),
187 "os.type": os.platform(),
188 "os.version": os.release(),
189 "host.cpus": os.cpus().length,
190 "host.memory_mb": Math.round(os.totalmem() / 1024 / 1024),
191 } satisfies TelemetryAttributes;
192 });
193
194let cachedAttrs: TelemetryAttributes | undefined;
195
196/**
197 * Resolves the {@link TelemetryAttributes} for the current process. Cached
198 * so file I/O and `git` invocations only run once per CLI invocation; every
199 * subsequent caller gets the same record (and therefore the same session
200 * id).
201 */
202export const collectAttributes: Effect.Effect<TelemetryAttributes> =
203 Effect.suspend(() =>
204 cachedAttrs !== undefined
205 ? Effect.succeed(cachedAttrs)
206 : collectAttributesUncached.pipe(
207 Effect.tap((a) =>
208 Effect.sync(() => {
209 cachedAttrs = a;
210 }),
211 ),
212 ),
213 );