@intentius/chant-lexicon-fly 0.102.0 → 0.104.0
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.
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/components/fly-release.d.ts +17 -1
- package/dist/components/fly-release.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/serializer.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/codegen/docs.ts +16 -0
- package/src/components/fly-release.test.ts +36 -0
- package/src/components/fly-release.ts +37 -3
- package/src/serializer-telemetry.test.ts +80 -0
- package/src/serializer.ts +42 -1
- package/src/telemetry-build.test.ts +75 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"docs.d.ts","sourceRoot":"","sources":["../../src/codegen/docs.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAQH;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAiB5D;
|
|
1
|
+
{"version":3,"file":"docs.d.ts","sourceRoot":"","sources":["../../src/codegen/docs.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAQH;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAiB5D;AA0FD;;GAEG;AACH,wBAAsB,YAAY,CAAC,OAAO,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAsBjF"}
|
|
@@ -18,7 +18,10 @@
|
|
|
18
18
|
* `APP_REVISION` env var (#2834, as chud's fly-site.mjs set it), so the
|
|
19
19
|
* app's own `/health` can report it. A caller's `env.APP_REVISION`
|
|
20
20
|
* overrides it. `fly-rollback` restores it for free: it puts back the
|
|
21
|
-
* whole recorded config, env included
|
|
21
|
+
* whole recorded config, env included. A Machine the build stamped with
|
|
22
|
+
* telemetry attribution (#3060) also gets the release's `service.version`
|
|
23
|
+
* and `vcs.ref.head.revision` in its `OTEL_RESOURCE_ATTRIBUTES` (#3061,
|
|
24
|
+
* ws-081; see {@link releaseResourceAttributes});
|
|
22
25
|
* 2. migrate: each migration runs inside the Machine once per environment,
|
|
23
26
|
* witnessed by a receipt in chant's lifecycle receipt store
|
|
24
27
|
* (`@intentius/chant/op/lifecycle-receipt-store`), then the Machine restarts
|
|
@@ -45,6 +48,7 @@ import type { ReceiptStore, EffectReceiptRef } from "@intentius/chant/op/receipt
|
|
|
45
48
|
import { type MachineFile, type MachineRelease } from "../op/activities/machine-release.js";
|
|
46
49
|
import { type FlyHttp, type WaitOpts } from "../op/activities/fly-apply.js";
|
|
47
50
|
import type { MachineConfigStore } from "../release-store.js";
|
|
51
|
+
import { type ReleaseIdentity } from "@intentius/chant/telemetry-attribution";
|
|
48
52
|
/** One migration, run inside the Machine once per environment. */
|
|
49
53
|
export interface FlyMigration {
|
|
50
54
|
/** Its name: the receipt is named after it, so a name fires once. */
|
|
@@ -179,6 +183,18 @@ export declare function migrationReceipt(app: string, migration: FlyMigration):
|
|
|
179
183
|
ref: EffectReceiptRef;
|
|
180
184
|
expectation: string;
|
|
181
185
|
};
|
|
186
|
+
/**
|
|
187
|
+
* A stamped Machine's `OTEL_RESOURCE_ATTRIBUTES` with the release's attributes
|
|
188
|
+
* (#3061, ws-081), or undefined when the build did not stamp it (no
|
|
189
|
+
* `chant.decl` key) or the release knows neither attribute.
|
|
190
|
+
*
|
|
191
|
+
* A Machine's env values are literal, so the deploy writes the full value in
|
|
192
|
+
* place of a `CHANT_RELEASE_ATTRIBUTES` reference the platform would expand.
|
|
193
|
+
* The release's keys replace the same keys in the declared value, as the
|
|
194
|
+
* suffix appended last does on Compose and Kubernetes: a digest-pinned image
|
|
195
|
+
* gave the build a `service.version`, and the release may run another image.
|
|
196
|
+
*/
|
|
197
|
+
export declare function releaseResourceAttributes(declared: unknown, release: ReleaseIdentity): string | undefined;
|
|
182
198
|
/** Build the `fly-release` capability. */
|
|
183
199
|
export declare function createFlyReleaseCapability(deps?: FlyReleaseDeps): Capability<FlyReleaseInput, FlyReleaseOutput>;
|
|
184
200
|
/** Build the `fly-rollback` capability. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fly-release.d.ts","sourceRoot":"","sources":["../../src/components/fly-release.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"fly-release.d.ts","sourceRoot":"","sources":["../../src/components/fly-release.ts"],"names":[],"mappings":"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"}
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "ef9f817ff0937f9a6c5c361f2c5e576dfc98ba16aa041c931c19dc8bfa54f03d",
|
|
5
5
|
"meta.json": "273f2a523b66f08ca91642dba2592c44765ab59ddf7329998884a0b08c6c3f24",
|
|
6
6
|
"types/index.d.ts": "2f01714e1aa5f5695d68416e143f281584efb84457138c93825fea34e2f3fa0a",
|
|
7
7
|
"rules/guest-sizing.ts": "bc749d218df8f3cc3d8fbe388205ae96e6b204ae0d6d77e8bf82cb936cc53ff6",
|
|
@@ -15,5 +15,5 @@
|
|
|
15
15
|
"skills/chant-fly-ops.md": "44102f1fdf611b9262c04457258990e44026e83f9e5b19ae86b69322f3a37620",
|
|
16
16
|
"skills/chant-fly-sprites.md": "3d100680422151b58d082c3a7b30a286edfc52faa4887995c9ad5553873bab95"
|
|
17
17
|
},
|
|
18
|
-
"composite": "
|
|
18
|
+
"composite": "ef6351e922f997eed3658b3bb4b94d06ee4af897d6bc2445ff0b110f73a9179e"
|
|
19
19
|
}
|
package/dist/manifest.json
CHANGED
package/dist/serializer.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAIH,OAAO,KAAK,EAAE,UAAU,EAAoB,MAAM,6BAA6B,CAAC;
|
|
1
|
+
{"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAIH,OAAO,KAAK,EAAE,UAAU,EAAoB,MAAM,6BAA6B,CAAC;AA0JhF;;GAEG;AACH,eAAO,MAAM,aAAa,EAAE,UAwJ3B,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-fly",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.104.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"src/",
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
"zod": "^4.3.6"
|
|
70
70
|
},
|
|
71
71
|
"peerDependencies": {
|
|
72
|
-
"@intentius/chant": "^0.
|
|
72
|
+
"@intentius/chant": "^0.104.0",
|
|
73
73
|
"typescript": "^5.9.3",
|
|
74
74
|
"zod": "^4.3.6"
|
|
75
75
|
},
|
package/src/codegen/docs.ts
CHANGED
|
@@ -90,6 +90,22 @@ Each declared resource becomes a single flaps request keyed by its logical name:
|
|
|
90
90
|
|
|
91
91
|
Every serialized machine carries the \`managed-by: chant\` ownership marker in \`config.metadata\`, which the owned-only prune reads back.
|
|
92
92
|
|
|
93
|
+
## Telemetry attribution
|
|
94
|
+
|
|
95
|
+
Inside a workspace, the fly lexicon adds two variables to each Machine's \`config.env\`, so that its spans name the declaration that produced them ([#3060](https://github.com/INTENTIUS/chant/issues/3060), [ws-060](https://github.com/INTENTIUS/chant/blob/main/docs/design/decisions/ws-060-telemetry-attribution.md)).
|
|
96
|
+
|
|
97
|
+
\`\`\`json
|
|
98
|
+
"env": {
|
|
99
|
+
"PORT": "8080",
|
|
100
|
+
"OTEL_SERVICE_NAME": "notes",
|
|
101
|
+
"OTEL_RESOURCE_ATTRIBUTES": "chant.workspace=acme,chant.member=svc,chant.decl=web"
|
|
102
|
+
}
|
|
103
|
+
\`\`\`
|
|
104
|
+
|
|
105
|
+
\`OTEL_SERVICE_NAME\` is the name of the Machine's app on Fly, or the Machine's export name when the build can't tell which app owns it. \`chant.decl\` is the Machine's export name. \`deployment.environment.name\` is added when the build has an environment (\`--env\` or \`ownership.env\`), and an image pinned by digest adds \`service.version\`. A value the Machine already sets is kept, and only the missing attributes are added. An \`App\` has no env, so only Machines are stamped. A project with no \`chant.workspace.json\` builds the same plan as before unless \`telemetry.attribution: true\` opts it in, and \`telemetry.attribution: false\` turns stamping off inside a workspace.
|
|
106
|
+
|
|
107
|
+
A Machine's env values are literal strings, so the plan carries no reference to \`CHANT_RELEASE_ATTRIBUTES\` ([ws-081](https://github.com/INTENTIUS/chant/blob/main/docs/design/decisions/ws-081-release-attributes-at-deploy.md)). The \`fly-release\` step writes the release into the Machine it serves instead: on a stamped Machine, \`OTEL_RESOURCE_ATTRIBUTES\` gets \`service.version\` (the release's digest) and \`vcs.ref.head.revision\` (its commit), the same release its metadata names. A plain apply of the plan carries no release.
|
|
108
|
+
|
|
93
109
|
## Applying
|
|
94
110
|
|
|
95
111
|
The output is applied against flaps directly (or the mudflaps emulator offline). Endpoint and auth come from \`FLY_FLAPS_BASE_URL\` and \`FLY_API_TOKEN\`.
|
|
@@ -128,6 +128,42 @@ describe("fly-release", () => {
|
|
|
128
128
|
expect((m.config.env as Record<string, string>).APP_REVISION).toBe("aaaaaaa");
|
|
129
129
|
});
|
|
130
130
|
|
|
131
|
+
/** The plan with a Machine the build stamped with telemetry attribution (#3060). */
|
|
132
|
+
function stampedPlan(attributes: string) {
|
|
133
|
+
const web = PLAN.web;
|
|
134
|
+
writeFileSync(
|
|
135
|
+
planPath,
|
|
136
|
+
JSON.stringify({
|
|
137
|
+
...PLAN,
|
|
138
|
+
web: { ...web, body: { ...web.body, config: { ...web.body.config, env: { PORT: "8080", OTEL_SERVICE_NAME: "shop", OTEL_RESOURCE_ATTRIBUTES: attributes } } } },
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
test("a stamped Machine's resource attributes name the release's digest and commit (#3061, ws-081)", async () => {
|
|
144
|
+
stampedPlan("chant.workspace=acme,chant.decl=web,service.version=sha256%3Aold");
|
|
145
|
+
const s = setup();
|
|
146
|
+
await s.release.run(CTX, input("sha256:aaaaaaaaaaaa"));
|
|
147
|
+
const env = s.fake.machine("shop", "web")!.config.env as Record<string, string>;
|
|
148
|
+
expect(env.OTEL_RESOURCE_ATTRIBUTES).toBe("chant.workspace=acme,chant.decl=web,service.version=sha256%3Aaaaaaaaaaaaa,vcs.ref.head.revision=aaaaaaa");
|
|
149
|
+
expect(env.PORT).toBe("8080");
|
|
150
|
+
// A rollback puts back the attributes of the release it restores.
|
|
151
|
+
await s.release.run(CTX, input("sha256:bbbbbbbbbbbb"));
|
|
152
|
+
await s.rollback.run(CTX, { plan: planPath, endpoint: ENDPOINT, wait: NO_WAIT, verify: { intervalMs: 1, timeoutMs: 20 } });
|
|
153
|
+
expect((s.fake.machine("shop", "web")!.config.env as Record<string, string>).OTEL_RESOURCE_ATTRIBUTES).toContain("service.version=sha256%3Aaaaaaaaaaaaa");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("a Machine the build did not stamp, or a caller's own value, gets no release attributes", async () => {
|
|
157
|
+
stampedPlan("team=core");
|
|
158
|
+
const s = setup();
|
|
159
|
+
await s.release.run(CTX, input("sha256:aaaaaaaaaaaa"));
|
|
160
|
+
expect((s.fake.machine("shop", "web")!.config.env as Record<string, string>).OTEL_RESOURCE_ATTRIBUTES).toBe("team=core");
|
|
161
|
+
|
|
162
|
+
stampedPlan("chant.decl=web");
|
|
163
|
+
await s.release.run(CTX, input("sha256:bbbbbbbbbbbb", { env: { OTEL_RESOURCE_ATTRIBUTES: "mine=1" } }));
|
|
164
|
+
expect((s.fake.machine("shop", "web")!.config.env as Record<string, string>).OTEL_RESOURCE_ATTRIBUTES).toBe("mine=1");
|
|
165
|
+
});
|
|
166
|
+
|
|
131
167
|
test("each migration fires once per environment, then the Machine restarts", async () => {
|
|
132
168
|
const s = setup();
|
|
133
169
|
const migrations = [
|
|
@@ -18,7 +18,10 @@
|
|
|
18
18
|
* `APP_REVISION` env var (#2834, as chud's fly-site.mjs set it), so the
|
|
19
19
|
* app's own `/health` can report it. A caller's `env.APP_REVISION`
|
|
20
20
|
* overrides it. `fly-rollback` restores it for free: it puts back the
|
|
21
|
-
* whole recorded config, env included
|
|
21
|
+
* whole recorded config, env included. A Machine the build stamped with
|
|
22
|
+
* telemetry attribution (#3060) also gets the release's `service.version`
|
|
23
|
+
* and `vcs.ref.head.revision` in its `OTEL_RESOURCE_ATTRIBUTES` (#3061,
|
|
24
|
+
* ws-081; see {@link releaseResourceAttributes});
|
|
22
25
|
* 2. migrate: each migration runs inside the Machine once per environment,
|
|
23
26
|
* witnessed by a receipt in chant's lifecycle receipt store
|
|
24
27
|
* (`@intentius/chant/op/lifecycle-receipt-store`), then the Machine restarts
|
|
@@ -60,6 +63,7 @@ import {
|
|
|
60
63
|
import { defaultFlyHttp, parsePlan, resolveEndpoint, type FlyHttp, type WaitOpts } from "../op/activities/fly-apply";
|
|
61
64
|
import type { MachineConfigStore } from "../release-store";
|
|
62
65
|
import { readFileSync } from "node:fs";
|
|
66
|
+
import { releaseAttributesSuffix, type ReleaseIdentity } from "@intentius/chant/telemetry-attribution";
|
|
63
67
|
|
|
64
68
|
/** One migration, run inside the Machine once per environment. */
|
|
65
69
|
export interface FlyMigration {
|
|
@@ -244,6 +248,28 @@ export function migrationReceipt(app: string, migration: FlyMigration): { ref: E
|
|
|
244
248
|
};
|
|
245
249
|
}
|
|
246
250
|
|
|
251
|
+
/**
|
|
252
|
+
* A stamped Machine's `OTEL_RESOURCE_ATTRIBUTES` with the release's attributes
|
|
253
|
+
* (#3061, ws-081), or undefined when the build did not stamp it (no
|
|
254
|
+
* `chant.decl` key) or the release knows neither attribute.
|
|
255
|
+
*
|
|
256
|
+
* A Machine's env values are literal, so the deploy writes the full value in
|
|
257
|
+
* place of a `CHANT_RELEASE_ATTRIBUTES` reference the platform would expand.
|
|
258
|
+
* The release's keys replace the same keys in the declared value, as the
|
|
259
|
+
* suffix appended last does on Compose and Kubernetes: a digest-pinned image
|
|
260
|
+
* gave the build a `service.version`, and the release may run another image.
|
|
261
|
+
*/
|
|
262
|
+
export function releaseResourceAttributes(declared: unknown, release: ReleaseIdentity): string | undefined {
|
|
263
|
+
if (typeof declared !== "string") return undefined;
|
|
264
|
+
const pairs = declared.split(",").filter((kv) => kv !== "");
|
|
265
|
+
const key = (kv: string) => kv.split("=")[0]!.trim();
|
|
266
|
+
if (!pairs.some((kv) => key(kv) === "chant.decl")) return undefined;
|
|
267
|
+
const added = releaseAttributesSuffix(release).split(",").filter((kv) => kv !== "");
|
|
268
|
+
if (added.length === 0) return undefined;
|
|
269
|
+
const replaced = new Set(added.map(key));
|
|
270
|
+
return [...pairs.filter((kv) => !replaced.has(key(kv))), ...added].join(",");
|
|
271
|
+
}
|
|
272
|
+
|
|
247
273
|
function loadTarget(planPath: string, machine?: string) {
|
|
248
274
|
const plan = parsePlan(readFileSync(planPath, "utf8"));
|
|
249
275
|
return { plan, target: releaseTarget(plan, machine) };
|
|
@@ -280,7 +306,7 @@ export function createFlyReleaseCapability(deps: FlyReleaseDeps = {}): Capabilit
|
|
|
280
306
|
async run(ctx, input) {
|
|
281
307
|
if (!input.digest) throw new Error("fly-release: a release needs its digest");
|
|
282
308
|
const http = httpFor();
|
|
283
|
-
const { plan } = loadTarget(input.plan, input.machine);
|
|
309
|
+
const { plan, target } = loadTarget(input.plan, input.machine);
|
|
284
310
|
const gitSha = input.gitSha ?? (await (deps.headCommit ?? defaultHeadCommit)());
|
|
285
311
|
const configs = await (deps.configStore ?? defaultConfigStore)(ctx);
|
|
286
312
|
// The tree is checked against its digest before the Machine changes.
|
|
@@ -289,7 +315,15 @@ export function createFlyReleaseCapability(deps: FlyReleaseDeps = {}): Capabilit
|
|
|
289
315
|
// APP_REVISION carries the commit into the Machine's env, as chud's
|
|
290
316
|
// fly-site.mjs did (chant#2834), so the app's own /health can report
|
|
291
317
|
// it. A caller's own APP_REVISION (input.env) wins over the commit.
|
|
292
|
-
|
|
318
|
+
// A stamped Machine's resource attributes name the same release as its
|
|
319
|
+
// metadata (#3061, ws-081), unless the caller sets its own.
|
|
320
|
+
const declaredEnv = (target.request.body.config as { env?: Record<string, unknown> } | undefined)?.env;
|
|
321
|
+
const attributes = releaseResourceAttributes(declaredEnv?.OTEL_RESOURCE_ATTRIBUTES, { version: input.digest, revision: gitSha });
|
|
322
|
+
const env = {
|
|
323
|
+
APP_REVISION: gitSha,
|
|
324
|
+
...(attributes !== undefined ? { OTEL_RESOURCE_ATTRIBUTES: attributes } : {}),
|
|
325
|
+
...(input.env ?? {}),
|
|
326
|
+
};
|
|
293
327
|
|
|
294
328
|
const released = await flyMachineRelease(
|
|
295
329
|
{
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telemetry attribution in the fly serializer (#3060): each Machine's
|
|
3
|
+
* `config.env` gets `OTEL_SERVICE_NAME` and `OTEL_RESOURCE_ATTRIBUTES` when the
|
|
4
|
+
* build hands it `context.telemetry`, and nothing changes without it.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
import type { Declarable } from "@intentius/chant";
|
|
8
|
+
import { flySerializer } from "./serializer";
|
|
9
|
+
import { App, Machine, MachineConfig } from "./generated/index";
|
|
10
|
+
|
|
11
|
+
const telemetry = { workspace: "acme", member: "svc", environment: "prod" };
|
|
12
|
+
|
|
13
|
+
function stack(...entries: Array<[string, unknown]>): Map<string, Declarable> {
|
|
14
|
+
return new Map(entries as Array<[string, Declarable]>);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const site = (config: Record<string, unknown>) =>
|
|
18
|
+
stack(["app", new App({ name: "notes", org_slug: "acme" })], ["web", new Machine({ name: "web", config: new MachineConfig(config) })]);
|
|
19
|
+
|
|
20
|
+
const build = (entities: Map<string, Declarable>, withTelemetry = true) =>
|
|
21
|
+
JSON.parse(flySerializer.serialize(entities, undefined, withTelemetry ? { telemetry } : undefined) as string);
|
|
22
|
+
|
|
23
|
+
describe("fly telemetry attribution (#3060)", () => {
|
|
24
|
+
it("stamps each Machine with the app's name and the Machine's declaration id", () => {
|
|
25
|
+
const out = build(site({ image: "node:22-slim", env: { PORT: "8080" } }));
|
|
26
|
+
expect(out.web.body.config.env).toEqual({
|
|
27
|
+
PORT: "8080",
|
|
28
|
+
OTEL_SERVICE_NAME: "notes",
|
|
29
|
+
OTEL_RESOURCE_ATTRIBUTES: "chant.workspace=acme,chant.member=svc,chant.decl=web,deployment.environment.name=prod",
|
|
30
|
+
});
|
|
31
|
+
// The App's create body has no env.
|
|
32
|
+
expect(out.app.body).toEqual({ app_name: "notes", org_slug: "acme" });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("adds an env to a Machine that declares none", () => {
|
|
36
|
+
const out = build(site({ image: "node:22-slim" }));
|
|
37
|
+
expect(out.web.body.config.env.OTEL_SERVICE_NAME).toBe("notes");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("keeps the values a Machine sets and appends the missing attributes", () => {
|
|
41
|
+
const out = build(
|
|
42
|
+
site({
|
|
43
|
+
image: "node:22-slim",
|
|
44
|
+
env: { OTEL_SERVICE_NAME: "notes-web", OTEL_RESOURCE_ATTRIBUTES: "chant.member=team-a,team=core" },
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
expect(out.web.body.config.env).toEqual({
|
|
48
|
+
OTEL_SERVICE_NAME: "notes-web",
|
|
49
|
+
OTEL_RESOURCE_ATTRIBUTES: "chant.member=team-a,team=core,chant.workspace=acme,chant.decl=web,deployment.environment.name=prod",
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("gives service.version from an image pinned by digest", () => {
|
|
54
|
+
const digest = "sha256:" + "a".repeat(64);
|
|
55
|
+
const out = build(site({ image: `registry.fly.io/notes@${digest}` }));
|
|
56
|
+
expect(out.web.body.config.env.OTEL_RESOURCE_ATTRIBUTES).toBe(
|
|
57
|
+
`chant.workspace=acme,chant.member=svc,chant.decl=web,deployment.environment.name=prod,service.version=${encodeURIComponent(digest)}`,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("names the Machine's app when the stack has several, and falls back to the export name when none is resolved", () => {
|
|
62
|
+
const out = build(
|
|
63
|
+
stack(
|
|
64
|
+
["a", new App({ name: "alpha" })],
|
|
65
|
+
["b", new App({ name: "beta" })],
|
|
66
|
+
["worker", new Machine({ app: "beta", config: new MachineConfig({ image: "x" }) })],
|
|
67
|
+
["loose", new Machine({ config: new MachineConfig({ image: "x" }) })],
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
expect(out.worker.body.config.env.OTEL_SERVICE_NAME).toBe("beta");
|
|
71
|
+
expect(out.loose.body.config.env.OTEL_SERVICE_NAME).toBe("loose");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("writes the same bytes as before without context.telemetry", () => {
|
|
75
|
+
const entities = site({ image: "node:22-slim", env: { PORT: "8080" } });
|
|
76
|
+
const plain = flySerializer.serialize(entities) as string;
|
|
77
|
+
expect(plain).not.toContain("OTEL_");
|
|
78
|
+
expect(flySerializer.serialize(entities, undefined, {})).toBe(plain);
|
|
79
|
+
});
|
|
80
|
+
});
|
package/src/serializer.ts
CHANGED
|
@@ -36,6 +36,7 @@ import type { LexiconOutput } from "@intentius/chant/lexicon-output";
|
|
|
36
36
|
import { ownershipEntries, OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership";
|
|
37
37
|
import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
|
|
38
38
|
import { INTRINSIC_MARKER } from "@intentius/chant/intrinsic";
|
|
39
|
+
import { mergeResourceAttributes, telemetryEnvironment, type TelemetryAttribution } from "@intentius/chant/telemetry-attribution";
|
|
39
40
|
import { FLY_METADATA_OWNERSHIP_KEYS } from "./ownership";
|
|
40
41
|
|
|
41
42
|
const APP_ENTITY_TYPE = "Fly::Machines::App";
|
|
@@ -151,6 +152,39 @@ function appName(entity: Declarable, entityName: string): string {
|
|
|
151
152
|
return typeof name === "string" ? name : entityName;
|
|
152
153
|
}
|
|
153
154
|
|
|
155
|
+
// ── Telemetry attribution (#3060, D22) ────────────────────────────
|
|
156
|
+
|
|
157
|
+
/** The digest of an image reference pinned with `@sha256:...`, or undefined. */
|
|
158
|
+
function imageDigest(image: unknown): string | undefined {
|
|
159
|
+
if (typeof image !== "string") return undefined;
|
|
160
|
+
const at = image.lastIndexOf("@");
|
|
161
|
+
return at === -1 ? undefined : image.slice(at + 1);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Stamp one Machine's attributes as `OTEL_SERVICE_NAME` and
|
|
166
|
+
* `OTEL_RESOURCE_ATTRIBUTES` in its `config.env`, the env the Machine's
|
|
167
|
+
* process gets. `service` is the owning app's name on Fly, `decl` the
|
|
168
|
+
* Machine's declaration id. A value the Machine already sets is kept: its own
|
|
169
|
+
* `OTEL_SERVICE_NAME` wins, and its own resource attributes keep their keys
|
|
170
|
+
* while the missing ones are appended.
|
|
171
|
+
*
|
|
172
|
+
* Machine env values are literal strings, so there is no reference to the
|
|
173
|
+
* release attributes here: `fly-release` writes them into the value it
|
|
174
|
+
* deploys (#3061, ws-081).
|
|
175
|
+
*/
|
|
176
|
+
function stampTelemetry(config: Record<string, unknown>, service: string, decl: string, telemetry: TelemetryAttribution): void {
|
|
177
|
+
const version = imageDigest(config.image);
|
|
178
|
+
const vars = telemetryEnvironment(telemetry, { service, decl, ...(version ? { version } : {}) });
|
|
179
|
+
const env: Record<string, unknown> =
|
|
180
|
+
config.env && typeof config.env === "object" && !Array.isArray(config.env) ? { ...(config.env as Record<string, unknown>) } : {};
|
|
181
|
+
if (!("OTEL_SERVICE_NAME" in env)) env.OTEL_SERVICE_NAME = vars.OTEL_SERVICE_NAME;
|
|
182
|
+
const own = env.OTEL_RESOURCE_ATTRIBUTES;
|
|
183
|
+
if (own === undefined) env.OTEL_RESOURCE_ATTRIBUTES = vars.OTEL_RESOURCE_ATTRIBUTES;
|
|
184
|
+
else if (typeof own === "string") env.OTEL_RESOURCE_ATTRIBUTES = mergeResourceAttributes(own, vars.OTEL_RESOURCE_ATTRIBUTES);
|
|
185
|
+
config.env = env;
|
|
186
|
+
}
|
|
187
|
+
|
|
154
188
|
/**
|
|
155
189
|
* fly flaps serializer.
|
|
156
190
|
*/
|
|
@@ -242,8 +276,15 @@ export const flySerializer: Serializer = {
|
|
|
242
276
|
}
|
|
243
277
|
body.config = config;
|
|
244
278
|
|
|
279
|
+
// Telemetry attribution (#3060): service.name is the app's name on
|
|
280
|
+
// Fly, or the Machine's export name when no app is resolved.
|
|
281
|
+
const owningApp = resolveOwningApp(entity);
|
|
282
|
+
if (context?.telemetry) {
|
|
283
|
+
stampTelemetry(config, owningApp === "{app}" ? name : owningApp, name, context.telemetry);
|
|
284
|
+
}
|
|
285
|
+
|
|
245
286
|
requests[name] = {
|
|
246
|
-
endpoint: `/v1/apps/${
|
|
287
|
+
endpoint: `/v1/apps/${owningApp}/machines`,
|
|
247
288
|
method: "POST",
|
|
248
289
|
body,
|
|
249
290
|
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telemetry attribution through a real fly build (#3060): inside a workspace
|
|
3
|
+
* each Machine's env names the workspace and the member,
|
|
4
|
+
* `telemetry.attribution: false` turns that off, and outside a workspace
|
|
5
|
+
* nothing is stamped unless `telemetry.attribution: true` opts in.
|
|
6
|
+
*/
|
|
7
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
8
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { dirname, join, resolve } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { buildCommand } from "@intentius/chant/cli/commands/build";
|
|
13
|
+
import { loadPlugins, resolveProjectLexicons } from "@intentius/chant/cli";
|
|
14
|
+
|
|
15
|
+
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
|
|
16
|
+
const made: string[] = [];
|
|
17
|
+
afterAll(() => {
|
|
18
|
+
for (const dir of made) rmSync(dir, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const APP = `import { App, Machine, MachineConfig } from "@intentius/chant-lexicon-fly";
|
|
22
|
+
|
|
23
|
+
export const app = new App({ name: "notes", org_slug: "acme" });
|
|
24
|
+
|
|
25
|
+
export const web = new Machine({
|
|
26
|
+
name: "web",
|
|
27
|
+
region: "iad",
|
|
28
|
+
config: new MachineConfig({ image: "node:22-slim", env: { PORT: "8080" } }),
|
|
29
|
+
});
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
/** A fly project, inside a one-member workspace or on its own, built to its flaps plan. */
|
|
33
|
+
async function build(options: { workspace: boolean; attribution?: boolean }): Promise<Record<string, { body: { config?: { env?: Record<string, string> } } }>> {
|
|
34
|
+
const root = mkdtempSync(join(tmpdir(), "chant-3060-"));
|
|
35
|
+
made.push(root);
|
|
36
|
+
mkdirSync(join(root, ".git"));
|
|
37
|
+
const project = options.workspace ? join(root, "svc") : root;
|
|
38
|
+
mkdirSync(join(project, "src"), { recursive: true });
|
|
39
|
+
if (options.workspace) {
|
|
40
|
+
writeFileSync(
|
|
41
|
+
join(root, "chant.workspace.json"),
|
|
42
|
+
JSON.stringify({ name: "acme", schema: 1, members: [{ name: "svc", dir: "svc", kind: "chant" }] }, null, 2),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
const telemetry = options.attribution === undefined ? "" : ` telemetry: { attribution: ${options.attribution} },\n`;
|
|
46
|
+
writeFileSync(join(project, "chant.config.ts"), `export default {\n lexicons: ["fly"],\n${telemetry}};\n`);
|
|
47
|
+
writeFileSync(join(project, "src", "app.ts"), APP);
|
|
48
|
+
symlinkSync(join(repoRoot, "node_modules"), join(project, "node_modules"), "dir");
|
|
49
|
+
|
|
50
|
+
const src = join(project, "src");
|
|
51
|
+
const plugins = await loadPlugins(await resolveProjectLexicons(src));
|
|
52
|
+
const serializers = plugins.map((p) => p.serializer).filter((s) => s.name === "fly");
|
|
53
|
+
const output = join(project, "fly.json");
|
|
54
|
+
const result = await buildCommand({ path: src, output, format: "json", serializers, plugins });
|
|
55
|
+
expect(result.success, result.errors.join("\n")).toBe(true);
|
|
56
|
+
return JSON.parse(readFileSync(output, "utf-8"));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe("telemetry attribution in a fly build (#3060)", () => {
|
|
60
|
+
test("inside a workspace the Machine names the workspace, the member and the declaration", async () => {
|
|
61
|
+
const env = (await build({ workspace: true })).web.body.config!.env!;
|
|
62
|
+
expect(env.OTEL_SERVICE_NAME).toBe("notes");
|
|
63
|
+
expect(env.OTEL_RESOURCE_ATTRIBUTES).toBe("chant.workspace=acme,chant.member=svc,chant.decl=web");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("telemetry.attribution false turns it off inside a workspace", async () => {
|
|
67
|
+
expect(JSON.stringify(await build({ workspace: true, attribution: false }))).not.toContain("OTEL_");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("outside a workspace nothing is stamped, and telemetry.attribution true opts in without workspace or member", async () => {
|
|
71
|
+
expect(JSON.stringify(await build({ workspace: false }))).not.toContain("OTEL_");
|
|
72
|
+
const env = (await build({ workspace: false, attribution: true })).web.body.config!.env!;
|
|
73
|
+
expect(env.OTEL_RESOURCE_ATTRIBUTES).toBe("chant.decl=web");
|
|
74
|
+
});
|
|
75
|
+
});
|