alchemy 2.0.0-beta.17 → 2.0.0-beta.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/bin/alchemy.js +428 -80
  2. package/bin/alchemy.js.map +1 -1
  3. package/bin/alchemy.ts +2 -0
  4. package/bin/commands/_shared.ts +34 -0
  5. package/bin/commands/bootstrap.ts +10 -3
  6. package/bin/commands/deploy.ts +23 -3
  7. package/bin/commands/login.ts +18 -3
  8. package/bin/commands/logs.ts +11 -2
  9. package/bin/commands/profile.ts +11 -5
  10. package/bin/commands/state.ts +21 -13
  11. package/bin/commands/tail.ts +10 -2
  12. package/lib/AWS/CloudFront/CachePolicy.d.ts +106 -0
  13. package/lib/AWS/CloudFront/CachePolicy.d.ts.map +1 -0
  14. package/lib/AWS/CloudFront/CachePolicy.js +147 -0
  15. package/lib/AWS/CloudFront/CachePolicy.js.map +1 -0
  16. package/lib/AWS/CloudFront/KeyGroup.d.ts +72 -0
  17. package/lib/AWS/CloudFront/KeyGroup.d.ts.map +1 -0
  18. package/lib/AWS/CloudFront/KeyGroup.js +136 -0
  19. package/lib/AWS/CloudFront/KeyGroup.js.map +1 -0
  20. package/lib/AWS/CloudFront/OriginRequestPolicy.d.ts +84 -0
  21. package/lib/AWS/CloudFront/OriginRequestPolicy.d.ts.map +1 -0
  22. package/lib/AWS/CloudFront/OriginRequestPolicy.js +139 -0
  23. package/lib/AWS/CloudFront/OriginRequestPolicy.js.map +1 -0
  24. package/lib/AWS/CloudFront/PublicKey.d.ts +72 -0
  25. package/lib/AWS/CloudFront/PublicKey.d.ts.map +1 -0
  26. package/lib/AWS/CloudFront/PublicKey.js +137 -0
  27. package/lib/AWS/CloudFront/PublicKey.js.map +1 -0
  28. package/lib/AWS/CloudFront/ResponseHeadersPolicy.d.ts +114 -0
  29. package/lib/AWS/CloudFront/ResponseHeadersPolicy.d.ts.map +1 -0
  30. package/lib/AWS/CloudFront/ResponseHeadersPolicy.js +162 -0
  31. package/lib/AWS/CloudFront/ResponseHeadersPolicy.js.map +1 -0
  32. package/lib/AWS/CloudFront/index.d.ts +5 -0
  33. package/lib/AWS/CloudFront/index.d.ts.map +1 -1
  34. package/lib/AWS/CloudFront/index.js +5 -0
  35. package/lib/AWS/CloudFront/index.js.map +1 -1
  36. package/lib/AWS/ECS/CapacityProvider.d.ts +102 -0
  37. package/lib/AWS/ECS/CapacityProvider.d.ts.map +1 -0
  38. package/lib/AWS/ECS/CapacityProvider.js +217 -0
  39. package/lib/AWS/ECS/CapacityProvider.js.map +1 -0
  40. package/lib/AWS/ECS/index.d.ts +1 -0
  41. package/lib/AWS/ECS/index.d.ts.map +1 -1
  42. package/lib/AWS/ECS/index.js +1 -0
  43. package/lib/AWS/ECS/index.js.map +1 -1
  44. package/lib/AWS/Providers.d.ts.map +1 -1
  45. package/lib/AWS/Providers.js +7 -1
  46. package/lib/AWS/Providers.js.map +1 -1
  47. package/lib/Agent/lsp/client.d.ts +2 -2
  48. package/lib/Apply.d.ts.map +1 -1
  49. package/lib/Apply.js +41 -9
  50. package/lib/Apply.js.map +1 -1
  51. package/lib/Axiom/Annotation.d.ts +49 -0
  52. package/lib/Axiom/Annotation.d.ts.map +1 -0
  53. package/lib/Axiom/Annotation.js +74 -0
  54. package/lib/Axiom/Annotation.js.map +1 -0
  55. package/lib/Axiom/ApiToken.d.ts +62 -0
  56. package/lib/Axiom/ApiToken.d.ts.map +1 -0
  57. package/lib/Axiom/ApiToken.js +98 -0
  58. package/lib/Axiom/ApiToken.js.map +1 -0
  59. package/lib/Axiom/AuthProvider.d.ts +48 -0
  60. package/lib/Axiom/AuthProvider.d.ts.map +1 -0
  61. package/lib/Axiom/AuthProvider.js +177 -0
  62. package/lib/Axiom/AuthProvider.js.map +1 -0
  63. package/lib/Axiom/Chart.d.ts +72 -0
  64. package/lib/Axiom/Chart.d.ts.map +1 -0
  65. package/lib/Axiom/Chart.js +14 -0
  66. package/lib/Axiom/Chart.js.map +1 -0
  67. package/lib/Axiom/Credentials.d.ts +12 -0
  68. package/lib/Axiom/Credentials.d.ts.map +1 -0
  69. package/lib/Axiom/Credentials.js +34 -0
  70. package/lib/Axiom/Credentials.js.map +1 -0
  71. package/lib/Axiom/Dashboard.d.ts +124 -0
  72. package/lib/Axiom/Dashboard.d.ts.map +1 -0
  73. package/lib/Axiom/Dashboard.js +140 -0
  74. package/lib/Axiom/Dashboard.js.map +1 -0
  75. package/lib/Axiom/Dataset.d.ts +101 -0
  76. package/lib/Axiom/Dataset.d.ts.map +1 -0
  77. package/lib/Axiom/Dataset.js +183 -0
  78. package/lib/Axiom/Dataset.js.map +1 -0
  79. package/lib/Axiom/Monitor.d.ts +74 -0
  80. package/lib/Axiom/Monitor.d.ts.map +1 -0
  81. package/lib/Axiom/Monitor.js +103 -0
  82. package/lib/Axiom/Monitor.js.map +1 -0
  83. package/lib/Axiom/Notifier.d.ts +63 -0
  84. package/lib/Axiom/Notifier.d.ts.map +1 -0
  85. package/lib/Axiom/Notifier.js +83 -0
  86. package/lib/Axiom/Notifier.js.map +1 -0
  87. package/lib/Axiom/Providers.d.ts +15 -0
  88. package/lib/Axiom/Providers.d.ts.map +1 -0
  89. package/lib/Axiom/Providers.js +32 -0
  90. package/lib/Axiom/Providers.js.map +1 -0
  91. package/lib/Axiom/View.d.ts +55 -0
  92. package/lib/Axiom/View.d.ts.map +1 -0
  93. package/lib/Axiom/View.js +79 -0
  94. package/lib/Axiom/View.js.map +1 -0
  95. package/lib/Axiom/VirtualField.d.ts +42 -0
  96. package/lib/Axiom/VirtualField.d.ts.map +1 -0
  97. package/lib/Axiom/VirtualField.js +71 -0
  98. package/lib/Axiom/VirtualField.js.map +1 -0
  99. package/lib/Axiom/index.d.ts +13 -0
  100. package/lib/Axiom/index.d.ts.map +1 -0
  101. package/lib/Axiom/index.js +13 -0
  102. package/lib/Axiom/index.js.map +1 -0
  103. package/lib/Bundle/Bundle.d.ts +20 -2
  104. package/lib/Bundle/Bundle.d.ts.map +1 -1
  105. package/lib/Bundle/Bundle.js +18 -3
  106. package/lib/Bundle/Bundle.js.map +1 -1
  107. package/lib/Bundle/PurePlugin.d.ts +109 -0
  108. package/lib/Bundle/PurePlugin.d.ts.map +1 -0
  109. package/lib/Bundle/PurePlugin.js +469 -0
  110. package/lib/Bundle/PurePlugin.js.map +1 -0
  111. package/lib/Bundle/index.d.ts +1 -0
  112. package/lib/Bundle/index.d.ts.map +1 -1
  113. package/lib/Bundle/index.js +1 -0
  114. package/lib/Bundle/index.js.map +1 -1
  115. package/lib/Cloudflare/Workers/Worker.d.ts +12 -0
  116. package/lib/Cloudflare/Workers/Worker.d.ts.map +1 -1
  117. package/lib/Cloudflare/Workers/Worker.js +19 -4
  118. package/lib/Cloudflare/Workers/Worker.js.map +1 -1
  119. package/lib/GitHub/Secrets.d.ts +45 -0
  120. package/lib/GitHub/Secrets.d.ts.map +1 -0
  121. package/lib/GitHub/Secrets.js +30 -0
  122. package/lib/GitHub/Secrets.js.map +1 -0
  123. package/lib/GitHub/index.d.ts +1 -0
  124. package/lib/GitHub/index.d.ts.map +1 -1
  125. package/lib/GitHub/index.js +1 -0
  126. package/lib/GitHub/index.js.map +1 -1
  127. package/lib/Output.d.ts.map +1 -1
  128. package/lib/Output.js +1 -1
  129. package/lib/Output.js.map +1 -1
  130. package/lib/Plan.d.ts.map +1 -1
  131. package/lib/Plan.js +10 -4
  132. package/lib/Plan.js.map +1 -1
  133. package/lib/Resource.d.ts +2 -2
  134. package/lib/Resource.d.ts.map +1 -1
  135. package/lib/Telemetry/Attributes.d.ts +43 -0
  136. package/lib/Telemetry/Attributes.d.ts.map +1 -0
  137. package/lib/Telemetry/Attributes.js +144 -0
  138. package/lib/Telemetry/Attributes.js.map +1 -0
  139. package/lib/Telemetry/Layer.d.ts +14 -0
  140. package/lib/Telemetry/Layer.d.ts.map +1 -0
  141. package/lib/Telemetry/Layer.js +50 -0
  142. package/lib/Telemetry/Layer.js.map +1 -0
  143. package/lib/Telemetry/Metrics.d.ts +34 -0
  144. package/lib/Telemetry/Metrics.d.ts.map +1 -0
  145. package/lib/Telemetry/Metrics.js +57 -0
  146. package/lib/Telemetry/Metrics.js.map +1 -0
  147. package/package.json +12 -1
  148. package/src/AWS/CloudFront/CachePolicy.ts +271 -0
  149. package/src/AWS/CloudFront/KeyGroup.ts +233 -0
  150. package/src/AWS/CloudFront/OriginRequestPolicy.ts +256 -0
  151. package/src/AWS/CloudFront/PublicKey.ts +245 -0
  152. package/src/AWS/CloudFront/ResponseHeadersPolicy.ts +308 -0
  153. package/src/AWS/CloudFront/index.ts +11 -0
  154. package/src/AWS/ECS/CapacityProvider.ts +336 -0
  155. package/src/AWS/ECS/index.ts +4 -0
  156. package/src/AWS/Providers.ts +12 -0
  157. package/src/Apply.ts +118 -9
  158. package/src/Axiom/Annotation.ts +95 -0
  159. package/src/Axiom/ApiToken.ts +125 -0
  160. package/src/Axiom/AuthProvider.ts +341 -0
  161. package/src/Axiom/Chart.ts +94 -0
  162. package/src/Axiom/Credentials.ts +70 -0
  163. package/src/Axiom/Dashboard.ts +192 -0
  164. package/src/Axiom/Dataset.ts +291 -0
  165. package/src/Axiom/Monitor.ts +122 -0
  166. package/src/Axiom/Notifier.ts +104 -0
  167. package/src/Axiom/Providers.ts +58 -0
  168. package/src/Axiom/View.ts +106 -0
  169. package/src/Axiom/VirtualField.ts +90 -0
  170. package/src/Axiom/index.ts +12 -0
  171. package/src/Bundle/Bundle.ts +39 -1
  172. package/src/Bundle/PurePlugin.ts +556 -0
  173. package/src/Bundle/index.ts +1 -0
  174. package/src/Cloudflare/Workers/Worker.ts +37 -6
  175. package/src/GitHub/Secrets.ts +66 -0
  176. package/src/GitHub/index.ts +1 -0
  177. package/src/Output.ts +2 -1
  178. package/src/Plan.ts +13 -4
  179. package/src/Resource.ts +2 -2
  180. package/src/Telemetry/Attributes.ts +216 -0
  181. package/src/Telemetry/Layer.ts +65 -0
  182. package/src/Telemetry/Metrics.ts +101 -0
@@ -3,4 +3,5 @@ export * from "./Comment.ts";
3
3
  export { GitHubCredentials, fromEnv, fromToken } from "./Credentials.ts";
4
4
  export * from "./Providers.ts";
5
5
  export * from "./Secret.ts";
6
+ export * from "./Secrets.ts";
6
7
  export * from "./Variable.ts";
package/src/Output.ts CHANGED
@@ -353,7 +353,8 @@ function proxy(self: any): any {
353
353
  prop === ExprSymbol || prop === inspect ? true : prop in self,
354
354
  get: (target, prop) =>
355
355
  prop === Symbol.toPrimitive
356
- ? (hint: string) => (hint === "string" ? self.toString() : self)
356
+ ? (hint: string) =>
357
+ hint === "number" ? Number.NaN : self.toString()
357
358
  : prop === ExprSymbol
358
359
  ? self
359
360
  : prop === inspect
package/src/Plan.ts CHANGED
@@ -397,7 +397,7 @@ export const make = <A>(
397
397
  const resourceGraph = Object.fromEntries(
398
398
  (yield* Effect.all(
399
399
  resources.map(
400
- Effect.fn(function* (resource) {
400
+ Effect.fn("plan.diff.resource")(function* (resource) {
401
401
  const provider = yield* findProviderByType(resource.Type);
402
402
  const id = resource.LogicalId;
403
403
  const fqn = resource.FQN;
@@ -723,8 +723,7 @@ export const make = <A>(
723
723
  const deletions = Object.fromEntries(
724
724
  (yield* Effect.all(
725
725
  (yield* state.list({ stack: stackName, stage: stage })).map(
726
- Effect.fn(function* (fqn) {
727
- // Check if this FQN is in the new resources
726
+ Effect.fn("plan.diff.deletion")(function* (fqn) {
728
727
  if (newResourceFqns.has(fqn)) {
729
728
  return;
730
729
  }
@@ -805,7 +804,17 @@ export const make = <A>(
805
804
  deletions,
806
805
  output: stack.output,
807
806
  } satisfies Plan<A> as Plan<A>;
808
- }).pipe(ensureArtifactStore);
807
+ }).pipe(
808
+ ensureArtifactStore,
809
+ Effect.withSpan("plan.make", {
810
+ attributes: {
811
+ "alchemy.stack": stack.name,
812
+ "alchemy.stage": stack.stage,
813
+ "alchemy.resources.count": Object.keys(stack.resources).length,
814
+ "alchemy.force": !!options.force,
815
+ },
816
+ }),
817
+ );
809
818
 
810
819
  const providePlanScope =
811
820
  (fqn: string, instanceId: string) =>
package/src/Resource.ts CHANGED
@@ -3,7 +3,7 @@ import * as Option from "effect/Option";
3
3
  import { pipeArguments, type Pipeable } from "effect/Pipeable";
4
4
  import { SingleShotGen } from "effect/Utils";
5
5
  import { toFqn } from "./FQN.ts";
6
- import type { Input } from "./Input.ts";
6
+ import type { Input, InputProps } from "./Input.ts";
7
7
  import { CurrentNamespace, type NamespaceNode } from "./Namespace.ts";
8
8
  import * as Output from "./Output.ts";
9
9
  import { Provider } from "./Provider.ts";
@@ -33,7 +33,7 @@ export type ResourceConstructor<R extends ResourceLike, Req = never> = {
33
33
  ): Effect.Effect<R, never, Req>;
34
34
  <PropsReq = never>(
35
35
  id: string,
36
- props: Effect.Effect<Input<R["Props"]>, never, PropsReq>,
36
+ props: Effect.Effect<InputProps<R["Props"]>, never, PropsReq>,
37
37
  ): Effect.Effect<R, never, PropsReq | Req>;
38
38
  };
39
39
 
@@ -0,0 +1,216 @@
1
+ import { exec } from "node:child_process";
2
+ import * as crypto from "node:crypto";
3
+ import * as fs from "node:fs/promises";
4
+ import * as os from "node:os";
5
+ import * as nodePath from "node:path";
6
+
7
+ import * as Effect from "effect/Effect";
8
+
9
+ import packageJson from "../../package.json" with { type: "json" };
10
+
11
+ const ALCHEMY_DIR = nodePath.join(os.homedir(), ".alchemy");
12
+ const ID_PATH = nodePath.join(ALCHEMY_DIR, "id");
13
+ const DISABLED_PATH = nodePath.join(ALCHEMY_DIR, "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
+ */
20
+ export 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
+
38
+ const tryRead = (path: string): Effect.Effect<string | null> =>
39
+ Effect.tryPromise({
40
+ try: () => fs.readFile(path, "utf-8").then((s) => s.trim()),
41
+ catch: () => null as never,
42
+ }).pipe(Effect.catch(() => Effect.succeed(null)));
43
+
44
+ const sha256Hex = (input: string): string =>
45
+ crypto.createHash("sha256").update(input).digest("hex");
46
+
47
+ const hashStringOrEmpty = (input: string | null): string =>
48
+ input == null || input === "" ? "" : sha256Hex(input);
49
+
50
+ const execCapture = (cmd: string): Effect.Effect<string | null> =>
51
+ Effect.callback<string | null>((resume) => {
52
+ exec(cmd, { timeout: 1500 }, (err, stdout) => {
53
+ if (err) {
54
+ resume(Effect.succeed(null));
55
+ return;
56
+ }
57
+ resume(Effect.succeed(stdout.toString().trim()));
58
+ });
59
+ });
60
+
61
+ const getOrCreateUserId: Effect.Effect<string> = Effect.gen(function* () {
62
+ const existing = yield* tryRead(ID_PATH);
63
+ if (existing) return existing;
64
+
65
+ const id = crypto.randomUUID();
66
+ yield* Effect.tryPromise({
67
+ try: async () => {
68
+ await fs.mkdir(ALCHEMY_DIR, { recursive: true });
69
+ await fs.writeFile(ID_PATH, id);
70
+ },
71
+ catch: () => null as never,
72
+ }).pipe(Effect.catch(() => Effect.void));
73
+ return id;
74
+ });
75
+
76
+ const getRootCommitHash: Effect.Effect<string | null> = execCapture(
77
+ process.platform === "win32"
78
+ ? `git rev-list --max-parents=0 HEAD | ForEach-Object { if (-not (git cat-file -p $_ | Select-String "^parent ")) { $_ } }`
79
+ : `git rev-list --max-parents=0 HEAD | xargs -r -I{} sh -c 'git cat-file -p {} | grep -q "^parent " || echo {}'`,
80
+ );
81
+
82
+ const getGitOriginUrl: Effect.Effect<string | null> = execCapture(
83
+ "git config --get remote.origin.url",
84
+ );
85
+
86
+ const getBranchName: Effect.Effect<string | null> = execCapture(
87
+ "git rev-parse --abbrev-ref HEAD",
88
+ );
89
+
90
+ const getRuntime = (): { name: string; version: string } => {
91
+ const g = globalThis as unknown as {
92
+ Bun?: { version: string };
93
+ Deno?: { version?: { deno?: string } };
94
+ };
95
+ if (typeof g.Bun !== "undefined") {
96
+ return { name: "bun", version: g.Bun.version };
97
+ }
98
+ if (typeof g.Deno !== "undefined") {
99
+ return { name: "deno", version: g.Deno.version?.deno ?? "" };
100
+ }
101
+ if (globalThis.process?.versions?.node) {
102
+ return { name: "node", version: process.versions.node };
103
+ }
104
+ return { name: "", version: "" };
105
+ };
106
+
107
+ const CI_PROVIDERS = [
108
+ { env: "GITHUB_ACTIONS", provider: "GitHub Actions", isCI: true },
109
+ { env: "GITLAB_CI", provider: "GitLab CI", isCI: true },
110
+ { env: "CIRCLECI", provider: "CircleCI", isCI: true },
111
+ { env: "JENKINS_URL", provider: "Jenkins", isCI: true },
112
+ { env: "TRAVIS", provider: "Travis CI", isCI: true },
113
+ { env: "BUILDKITE", provider: "Buildkite", isCI: true },
114
+ { env: "NOW_BUILDER", provider: "Vercel", isCI: true },
115
+ { env: "VERCEL", provider: "Vercel", isCI: false },
116
+ ] as const;
117
+
118
+ const getCIEnvironment = (): { provider: string; isCI: boolean } => {
119
+ for (const p of CI_PROVIDERS) {
120
+ if (process.env[p.env]) {
121
+ return { provider: p.provider, isCI: p.isCI };
122
+ }
123
+ }
124
+ return { provider: "", isCI: !!process.env.CI };
125
+ };
126
+
127
+ const TELEMETRY_DISABLED_ENV = (): boolean =>
128
+ !!process.env.ALCHEMY_TELEMETRY_DISABLED ||
129
+ !!process.env.DO_NOT_TRACK ||
130
+ !!process.env.NO_TRACK;
131
+
132
+ /**
133
+ * `true` if telemetry is disabled via environment variable
134
+ * (`DO_NOT_TRACK`, `NO_TRACK`, `ALCHEMY_TELEMETRY_DISABLED`) or via a
135
+ * persisted opt-out file at `~/.alchemy/telemetry-disabled`.
136
+ */
137
+ export const isTelemetryDisabled: Effect.Effect<boolean> = Effect.gen(
138
+ function* () {
139
+ if (TELEMETRY_DISABLED_ENV()) return true;
140
+ const persisted = yield* tryRead(DISABLED_PATH);
141
+ return persisted === "true";
142
+ },
143
+ );
144
+
145
+ /**
146
+ * Persists an opt-out so future invocations skip telemetry without needing
147
+ * an env var.
148
+ */
149
+ export const setTelemetryDisabled: Effect.Effect<void> = Effect.tryPromise({
150
+ try: async () => {
151
+ await fs.mkdir(ALCHEMY_DIR, { recursive: true });
152
+ await fs.writeFile(DISABLED_PATH, "true");
153
+ },
154
+ catch: () => null as never,
155
+ }).pipe(Effect.catch(() => Effect.void));
156
+
157
+ export const setTelemetryEnabled: Effect.Effect<void> = Effect.tryPromise({
158
+ try: () => fs.rm(DISABLED_PATH, { force: true }),
159
+ catch: () => null as never,
160
+ }).pipe(Effect.catch(() => Effect.void));
161
+
162
+ const collectAttributesUncached: Effect.Effect<TelemetryAttributes> =
163
+ Effect.gen(function* () {
164
+ const [userId, rootCommit, originUrl, branch] = yield* Effect.all(
165
+ [
166
+ getOrCreateUserId,
167
+ getRootCommitHash,
168
+ getGitOriginUrl,
169
+ getBranchName,
170
+ ] as const,
171
+ { concurrency: "unbounded" },
172
+ );
173
+
174
+ const runtime = getRuntime();
175
+ const ci = getCIEnvironment();
176
+
177
+ return {
178
+ "alchemy.user.id": userId,
179
+ "alchemy.session.id":
180
+ process.env.ALCHEMY_TELEMETRY_SESSION_ID ?? crypto.randomUUID(),
181
+ "alchemy.version": packageJson.version,
182
+ "alchemy.git.root_commit": rootCommit ?? "",
183
+ "alchemy.git.origin_hash": hashStringOrEmpty(originUrl),
184
+ "alchemy.git.branch_hash": hashStringOrEmpty(branch),
185
+ "alchemy.runtime.name": runtime.name,
186
+ "alchemy.runtime.version": runtime.version,
187
+ "alchemy.ci.provider": ci.provider,
188
+ "alchemy.ci": ci.isCI,
189
+ "host.arch": os.arch(),
190
+ "os.type": os.platform(),
191
+ "os.version": os.release(),
192
+ "host.cpus": os.cpus().length,
193
+ "host.memory_mb": Math.round(os.totalmem() / 1024 / 1024),
194
+ } satisfies TelemetryAttributes;
195
+ });
196
+
197
+ let cachedAttrs: TelemetryAttributes | undefined;
198
+
199
+ /**
200
+ * Resolves the {@link TelemetryAttributes} for the current process. Cached
201
+ * so file I/O and `git` invocations only run once per CLI invocation; every
202
+ * subsequent caller gets the same record (and therefore the same session
203
+ * id).
204
+ */
205
+ export const collectAttributes: Effect.Effect<TelemetryAttributes> =
206
+ Effect.suspend(() =>
207
+ cachedAttrs !== undefined
208
+ ? Effect.succeed(cachedAttrs)
209
+ : collectAttributesUncached.pipe(
210
+ Effect.tap((a) =>
211
+ Effect.sync(() => {
212
+ cachedAttrs = a;
213
+ }),
214
+ ),
215
+ ),
216
+ );
@@ -0,0 +1,65 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Layer from "effect/Layer";
3
+ import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
4
+ import * as OtlpMetrics from "effect/unstable/observability/OtlpMetrics";
5
+ import * as OtlpSerialization from "effect/unstable/observability/OtlpSerialization";
6
+ import * as OtlpTracer from "effect/unstable/observability/OtlpTracer";
7
+
8
+ import packageJson from "../../package.json" with { type: "json" };
9
+
10
+ import { collectAttributes, isTelemetryDisabled } from "./Attributes.ts";
11
+
12
+ const TRACES_URL = "https://otel.alchemy.run/v1/traces";
13
+ const METRICS_URL = "https://otel.alchemy.run/v1/metrics";
14
+
15
+ const SERVICE_NAME = "alchemy-cli";
16
+
17
+ const buildOtlpLayer = (
18
+ attrs: Record<string, unknown>,
19
+ ): Layer.Layer<never, never, never> => {
20
+ const resource = {
21
+ serviceName: SERVICE_NAME,
22
+ serviceVersion: packageJson.version,
23
+ attributes: attrs,
24
+ };
25
+
26
+ // Short export intervals so even sub-second CLI invocations flush at
27
+ // least one batch before the process exits.
28
+ const tracer = OtlpTracer.layer({
29
+ url: TRACES_URL,
30
+ resource,
31
+ exportInterval: "1 second",
32
+ });
33
+ const metrics = OtlpMetrics.layer({
34
+ url: METRICS_URL,
35
+ resource,
36
+ exportInterval: "1 second",
37
+ });
38
+
39
+ return Layer.mergeAll(tracer, metrics).pipe(
40
+ Layer.provide(OtlpSerialization.layerJson),
41
+ Layer.provide(FetchHttpClient.layer),
42
+ );
43
+ };
44
+
45
+ /**
46
+ * The CLI's telemetry layer. Builds an OTLP HTTP exporter that ships spans
47
+ * to {@link TRACES_URL} and metrics to {@link METRICS_URL}, attaching
48
+ * {@link collectAttributes} as resource-level attributes so every signal
49
+ * carries user/project/runtime context.
50
+ *
51
+ * If the user has opted out (via `DO_NOT_TRACK`, `NO_TRACK`,
52
+ * `ALCHEMY_TELEMETRY_DISABLED`, or `~/.alchemy/telemetry-disabled`), this
53
+ * resolves to {@link Layer.empty}. Effect's default `Tracer` is a no-op,
54
+ * so all `withSpan`/`Effect.fn` instrumentation in core stays free.
55
+ */
56
+ export const TelemetryLive: Layer.Layer<never, never, never> =
57
+ Layer.unwrap(
58
+ Effect.gen(function* () {
59
+ if (yield* isTelemetryDisabled) {
60
+ return Layer.empty;
61
+ }
62
+ const attrs = yield* collectAttributes;
63
+ return buildOtlpLayer(attrs as unknown as Record<string, unknown>);
64
+ }),
65
+ );
@@ -0,0 +1,101 @@
1
+ import * as Duration from "effect/Duration";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Exit from "effect/Exit";
4
+ import * as Metric from "effect/Metric";
5
+
6
+ /**
7
+ * Counter for resource lifecycle operations. Tagged per call with
8
+ * `resource_type`, `op` (`precreate`/`create`/`update`/`delete`/`read`),
9
+ * and `status` (`success`/`error`).
10
+ */
11
+ export const resourceCounter = Metric.counter("alchemy.resource.operations", {
12
+ description:
13
+ "Number of resource lifecycle operations dispatched by alchemy apply.",
14
+ incremental: true,
15
+ });
16
+
17
+ /**
18
+ * Histogram of how long each lifecycle operation takes.
19
+ */
20
+ export const resourceDuration = Metric.timer("alchemy.resource.duration", {
21
+ description:
22
+ "Wall-clock duration of resource lifecycle operations dispatched by alchemy apply.",
23
+ });
24
+
25
+ /**
26
+ * Counter for CLI command invocations.
27
+ */
28
+ export const cliCounter = Metric.counter("alchemy.cli.invocations", {
29
+ description: "Number of alchemy CLI command invocations.",
30
+ incremental: true,
31
+ });
32
+
33
+ /**
34
+ * Wraps a resource lifecycle Effect to record a counter + timer entry,
35
+ * tagged with `resource_type`, `op`, and `status` (`success`/`error`).
36
+ *
37
+ * Usage:
38
+ * ```ts
39
+ * provider.create(input).pipe(recordResourceOp(node.resource.Type, "create"))
40
+ * ```
41
+ */
42
+ export const recordResourceOp =
43
+ (resourceType: string, op: ResourceOp) =>
44
+ <A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
45
+ Effect.suspend(() => {
46
+ const startNs = process.hrtime.bigint();
47
+ const baseAttrs = { resource_type: resourceType, op } as const;
48
+ return self.pipe(
49
+ Effect.onExit((exit) =>
50
+ recordOutcome(
51
+ baseAttrs,
52
+ Exit.isSuccess(exit) ? "success" : "error",
53
+ elapsed(startNs),
54
+ ),
55
+ ),
56
+ );
57
+ });
58
+
59
+ /**
60
+ * Wraps a CLI command Effect to bump {@link cliCounter} with the
61
+ * outcome (`success`/`error`).
62
+ */
63
+ export const recordCli =
64
+ (command: string) =>
65
+ <A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
66
+ self.pipe(
67
+ Effect.onExit((exit) =>
68
+ Metric.update(
69
+ Metric.withAttributes(cliCounter, {
70
+ command,
71
+ status: Exit.isSuccess(exit) ? "success" : "error",
72
+ }),
73
+ 1,
74
+ ),
75
+ ),
76
+ );
77
+
78
+ export type ResourceOp =
79
+ | "precreate"
80
+ | "create"
81
+ | "update"
82
+ | "delete"
83
+ | "read";
84
+
85
+ const elapsed = (startNs: bigint): Duration.Duration =>
86
+ Duration.nanos(process.hrtime.bigint() - startNs);
87
+
88
+ const recordOutcome = (
89
+ baseAttrs: { resource_type: string; op: ResourceOp },
90
+ status: "success" | "error",
91
+ duration: Duration.Duration,
92
+ ): Effect.Effect<void> => {
93
+ const attrs = { ...baseAttrs, status };
94
+ return Effect.all(
95
+ [
96
+ Metric.update(Metric.withAttributes(resourceCounter, attrs), 1),
97
+ Metric.update(Metric.withAttributes(resourceDuration, attrs), duration),
98
+ ],
99
+ { discard: true },
100
+ );
101
+ };