diff --git a/.agents/skills/deco-storefront-performance/references/render-location.md b/.agents/skills/deco-storefront-performance/references/render-location.md index 20add476..abda389d 100644 --- a/.agents/skills/deco-storefront-performance/references/render-location.md +++ b/.agents/skills/deco-storefront-performance/references/render-location.md @@ -43,8 +43,9 @@ not the absolute numbers. In production, the same data exists as telemetry spans: when a trace is sampled, every block run becomes a span named after its block type, with `deco.block.type`. Sampling is off by default. It needs a telemetry destination (a `token` or a `telemetry.endpoint` -on `createCMS`), the cap `createCMS({ telemetry: { limits: { traceSampleRate } } })` -(default 0), and `telemetry.traceSampleRate` in the saved `CMS` block (default 0). +on `createCMS`) and a trace rate: `telemetry.traceSampleRate` in the saved `CMS` block, +which wins, else code's `createCMS({ telemetry: { defaults: { traceSampleRate } } })` +(both default 0). ## 2. A prop resolved eagerly and then thrown away diff --git a/.agents/skills/deco-v7-to-v8-migration/references/telemetry.md b/.agents/skills/deco-v7-to-v8-migration/references/telemetry.md index 1ab1c51e..a0ccd384 100644 --- a/.agents/skills/deco-v7-to-v8-migration/references/telemetry.md +++ b/.agents/skills/deco-v7-to-v8-migration/references/telemetry.md @@ -4,7 +4,7 @@ From the site migrations; the sites and what the commit hashes refer to are list - Telemetry is explicit params only: the SDK reads no environment variable (`OTEL_*` and v7's `DECO_OTEL_*` are ignored). A site that kept a collector reads its own variables and passes `telemetry: { endpoint, headers }`; with `createCMS({ site, token })` and no `endpoint`, telemetry goes to the hosted collector (a `token` without `site` is a configuration error). Pass `telemetry: false` in dev or parity runs (`89eae7d`). v7's per-signal `DECO_OTEL_{METRICS,LOGS,TRACES}_ENDPOINT` have no v8 equivalent: every signal goes to `/v1/`. - **New telemetry is a behaviour change.** A site with its own OpenTelemetry export that v7 never fed CMS telemetry starts sending it once `telemetry` points at that collector. List the new traffic for the product owner. -- `service.name`, `service.version` and `deployment.environment.name` come from `telemetry.resource`, on the hosted collector too (`telemetry: { resource }` without `endpoint`). v7 took them from `DECO_SITE_NAME`, the `CF_VERSION_METADATA` binding and `DECO_ENV_NAME`: keep the `version_metadata` binding in `wrangler.jsonc`, read the three in the site and pass them (or a Vite `define` of the commit). Vars left in `wrangler.jsonc` that nothing reads are dead config a reviewer will flag. v7's `DECO_OTEL_ERROR_PROMOTION_RATE` becomes `errorSampleRate` in `CMS.json`'s telemetry section (default 0.05, capped by `telemetry.limits`, default 0.1); `DECO_OTEL_LOGS_MIN_LEVEL` has no equivalent (v8 sends error logs only): list it. +- `service.name`, `service.version` and `deployment.environment.name` come from `telemetry.resource`, on the hosted collector too (`telemetry: { resource }` without `endpoint`). v7 took them from `DECO_SITE_NAME`, the `CF_VERSION_METADATA` binding and `DECO_ENV_NAME`: keep the `version_metadata` binding in `wrangler.jsonc`, read the three in the site and pass them (or a Vite `define` of the commit). Vars left in `wrangler.jsonc` that nothing reads are dead config a reviewer will flag. v7's `DECO_OTEL_ERROR_PROMOTION_RATE` becomes `errorSampleRate` in `CMS.json`'s telemetry section (default 0.05, which wins over code's `telemetry.defaults`; there's no cap); `DECO_OTEL_LOGS_MIN_LEVEL` has no equivalent (v8 sends error logs only): list it. - **Local development and hosted releases.** `createCMS` reads no `NODE_ENV` and has no `dev` option. With `site`, content always comes from the CDN, on the dev server too; without `site`, it is the `content` the site passes. Passing both throws. A site that wants local files in dev picks one or the other in its own code. - **v7 Workers bindings with no v8 writer.** v7's `instrumentWorker` wrote per-route request counts, edge cache hit/miss and request duration to an Analytics Engine dataset (`DECO_METRICS`) and read `CF_VERSION_METADATA`. In v8, `createDecoWorker` records every request as `http.server.request.duration` (seconds) in the CMS's own telemetry, with `http.route` (the content's page template, `redirect` for every CMS redirect, a fixed one such as `/_serverFn/*` or `/assets/*`, else `unmatched`), `http.request.method`, `status_class`, `cache` (`DRAFT` for a preview, else from the `X-Cache` the site's `pageCache` answers: `HIT`/`MISS`/`BYPASS`) and `device`; with no `token` and no `endpoint` it records nothing. On Next.js, `export { register } from "@decocms/nextjs/instrumentation"` in `instrumentation.ts` records the same metric from Next's own request span: `http.route` is Next's route template (`/[[...path]]`, `/_draft/[[...path]]`; RSC requests under their page's), `cache` is `DRAFT` for a draft render and otherwise `BYPASS` (Next's `x-nextjs-cache` is not visible to the span), no `device`; responses the proxy answers itself are not counted and Edge routes are `unmatched`. A site with its own OpenTelemetry SDK (`@vercel/otel`) adds `serverRequestSpanProcessor()` to its span processors instead. Both bindings record through `recordServerRequest` from `@decocms/blocks/metrics`; a site never imports it (and `@decocms/blocks/fetch` is only `createInstrumentedFetch`). Nothing writes `DECO_METRICS`; a site that still wants it writes there from its worker entry's `pageCache` (it knows the profile and the decision). Don't keep a binding nothing writes, and don't drop one silently: list each by name (and the edge layer's `deco.cache.requests` counter) as a product decision in the PR. - Analytics: `AnalyticsScript` with `(await cms.settings()).analytics` plus `track` replace OneDollarStats (the collector lives in the `analytics` section of `CMS.json`); it sends collector beacons directly instead of loading the SDK (`4a50b13`, blog `9e97455`). diff --git a/.agents/skills/deco-v7-to-v8-migration/scripts/siteSettings.ts b/.agents/skills/deco-v7-to-v8-migration/scripts/siteSettings.ts index 2cf295f1..2bead432 100644 --- a/.agents/skills/deco-v7-to-v8-migration/scripts/siteSettings.ts +++ b/.agents/skills/deco-v7-to-v8-migration/scripts/siteSettings.ts @@ -241,7 +241,7 @@ export function foldSiteSettings(root: string, report: Report): void { step: "settings", subject: "DECO_OTEL_* sampling", message: - "if your hosting sets sampling variables, move them to the telemetry section's errorSampleRate and traceSampleRate, within the limits in code (/next/telemetry#sampling)", + "if your hosting sets sampling variables, move them to the telemetry section's errorSampleRate and traceSampleRate, which win over code's telemetry.defaults (/next/telemetry#sampling)", }, { step: "settings", diff --git a/packages/blocks/conformance/observability/cms.ts b/packages/blocks/conformance/observability/cms.ts index 3eef6a46..a290fac3 100644 --- a/packages/blocks/conformance/observability/cms.ts +++ b/packages/blocks/conformance/observability/cms.ts @@ -23,7 +23,7 @@ export const sampled = () => content, telemetry: { endpoint: process.env.OTLP_ENDPOINT!, - limits: { errorSampleRate: 0.1, traceSampleRate: 0 }, // the defaults + defaults: { errorSampleRate: 0.05, traceSampleRate: 0 }, // the built-in defaults }, }); diff --git a/packages/blocks/src/v8/__conformance__/extra.test.ts b/packages/blocks/src/v8/__conformance__/extra.test.ts index 80ebd6d3..5844d8d6 100644 --- a/packages/blocks/src/v8/__conformance__/extra.test.ts +++ b/packages/blocks/src/v8/__conformance__/extra.test.ts @@ -600,20 +600,22 @@ describe("the content protocol (content-protocol.mdx)", () => { }); describe("telemetry settings are content (telemetry.mdx)", () => { - it("X31 the telemetry section's rates are capped by telemetry.limits (trace default cap 0); enabled: false switches it off", async () => { - const settings = (telemetry: Record, limits?: { traceSampleRate: number }) => + it("X31 the telemetry section's rates win over telemetry.defaults (trace built-in default 0); enabled: false switches it off", async () => { + const settings = (telemetry: Record, defaults?: { traceSampleRate: number }) => createCMS({ blocks: {}, content: snap( { CMS: { __resolveType: "cms-settings", telemetry } }, JSON.stringify(telemetry), ), - telemetry: { endpoint: "https://otel.example", ...(limits ? { limits } : {}) }, + telemetry: { endpoint: "https://otel.example", ...(defaults ? { defaults } : {}) }, }).settings(); - expect((await settings({ traceSampleRate: 1 })).telemetry.traceSampleRate).toBe(0); + expect((await settings({})).telemetry.traceSampleRate).toBe(0); + expect((await settings({}, { traceSampleRate: 0.2 })).telemetry.traceSampleRate).toBe(0.2); + expect((await settings({ traceSampleRate: 1 })).telemetry.traceSampleRate).toBe(1); expect( - (await settings({ traceSampleRate: 1 }, { traceSampleRate: 1 })).telemetry.traceSampleRate, - ).toBe(1); + (await settings({ traceSampleRate: 0 }, { traceSampleRate: 1 })).telemetry.traceSampleRate, + ).toBe(0); expect( (await settings({ traceSampleRate: 1, enabled: false }, { traceSampleRate: 1 })).telemetry .enabled, diff --git a/packages/blocks/src/v8/__conformance__/guides.test.ts b/packages/blocks/src/v8/__conformance__/guides.test.ts index a4ef3229..f2991cb1 100644 --- a/packages/blocks/src/v8/__conformance__/guides.test.ts +++ b/packages/blocks/src/v8/__conformance__/guides.test.ts @@ -937,7 +937,7 @@ describe("design decisions", () => { release(); }); - it("dd-15/tr-18: sample rates above the code limit are capped; enabled: false sends nothing", async () => { + it("dd-15/tr-18: content's sample rate wins over code's default; enabled: false sends nothing", async () => { const HOOK = Symbol.for("decocms.blocks.background"); const tasks: (() => Promise)[] = []; (globalThis as Record)[HOOK] = (task: () => Promise) => tasks.push(task); @@ -956,24 +956,25 @@ describe("design decisions", () => { throw new Error("no"); }; try { - const capped = createCMS({ + const raised = createCMS({ blocks: { broken }, content: snap({ CMS: { __resolveType: "cms-settings", telemetry: { errorSampleRate: 1 } }, }), - telemetry: { endpoint: "https://otel.example.com", limits: { errorSampleRate: 0.1 } }, + telemetry: { endpoint: "https://otel.example.com", defaults: { errorSampleRate: 0.1 } }, }); vi.spyOn(Math, "random").mockReturnValue(0.5); // inside the editor's 1, above code's 0.1 - await capped.forRelease().resolve({ __resolveType: "broken" }); + await raised.forRelease().resolve({ __resolveType: "broken" }); await flush(); - expect(sent).not.toHaveBeenCalled(); + expect(sent).toHaveBeenCalledTimes(1); + sent.mockClear(); const off = createCMS({ blocks: { broken }, content: snap({ CMS: { __resolveType: "cms-settings", telemetry: { enabled: false, errorSampleRate: 1 } }, }), - telemetry: { endpoint: "https://otel2.example.com", limits: { errorSampleRate: 1 } }, + telemetry: { endpoint: "https://otel2.example.com", defaults: { errorSampleRate: 1 } }, }); vi.spyOn(Math, "random").mockReturnValue(0); await off.forRelease().resolve({ __resolveType: "broken" }); diff --git a/packages/blocks/src/v8/__conformance__/observability.test.ts b/packages/blocks/src/v8/__conformance__/observability.test.ts index c9d64148..55818dad 100644 --- a/packages/blocks/src/v8/__conformance__/observability.test.ts +++ b/packages/blocks/src/v8/__conformance__/observability.test.ts @@ -180,7 +180,7 @@ describe("the docs' snippets typecheck (conformance/observability)", () => { ); expect(errorsIn("cms.ts")).toBe(""); }); - it("tel-25/tel-24: the Sampling snippet (limits) compiles inside createCMS", () => { + it("tel-25/tel-24: the Sampling snippet (defaults) compiles inside createCMS", () => { expect(errorsIn("cms.ts")).toBe(""); }); it("tel-29: the traced() block map compiles with `satisfies Blocks`", () => { @@ -398,7 +398,7 @@ describe("what's sent (telemetry.mdx)", () => { const cms = createCMS({ blocks: { ...docsBlocks(), broken }, content: withTelemetryBlock({ errorSampleRate: 1 }), - telemetry: { endpoint: ENDPOINT, limits: { errorSampleRate: 1 } }, + telemetry: { endpoint: ENDPOINT, defaults: { errorSampleRate: 1 } }, }); const [, error] = await cms.forRelease().resolve({ __resolveType: "broken" }); expect(error?.code).toBe("BLOCK_FAILED"); @@ -429,13 +429,13 @@ describe("what's sent (telemetry.mdx)", () => { // The first client of a release must already honour that release's telemetry // section: no warm-up request before the one that should be traced. - it("tel-12: with limits and block traceSampleRate 1, block resolution and upstream spans are sent", async () => { + it("tel-12: with block traceSampleRate 1, block resolution and upstream spans are sent", async () => { vi.spyOn(Math, "random").mockReturnValue(0); const { sent } = collector(); const cms = createCMS({ blocks: docsBlocks(), content: withTelemetryBlock({ traceSampleRate: 1 }), - telemetry: { endpoint: ENDPOINT, limits: { traceSampleRate: 1 } }, + telemetry: { endpoint: ENDPOINT, defaults: { traceSampleRate: 1 } }, }); await cms.forRelease().resolve("SummerPage"); await upstreamWith().request("https://search.example/q", { operation: "search" }); @@ -560,7 +560,7 @@ describe("the telemetry section of the CMS block (telemetry.mdx)", () => { const cms = createCMS({ blocks: { ...docsBlocks(), broken }, content: docsSnapshot(), - telemetry: { endpoint: ENDPOINT, limits: { errorSampleRate: 1, traceSampleRate: 1 } }, + telemetry: { endpoint: ENDPOINT }, }); const random = vi.spyOn(Math, "random"); random.mockReturnValue(0.049); // < 0.05: an error is kept; >= 0 trace rate: no trace @@ -574,16 +574,21 @@ describe("the telemetry section of the CMS block (telemetry.mdx)", () => { expect(spans(sent)).toHaveLength(0); }); - it("tel-17: {__resolveType: 'cms-settings'} returns its input with the telemetry defaults filled in (built-in-blocks)", async () => { + it("tel-17: {__resolveType: 'cms-settings'} returns its input with the telemetry switches filled in, sample rates only as saved (built-in-blocks)", async () => { const cms = createCMS({ blocks: docsBlocks(), content: docsSnapshot() }); const [value] = await cms.forRelease().resolve<{ telemetry: unknown }>({ __resolveType: "cms-settings", telemetry: { metrics: false }, }); - expect(value?.telemetry).toEqual({ + expect(value?.telemetry).toEqual({ enabled: true, metrics: false }); + const [saved] = await cms.forRelease().resolve<{ telemetry: unknown }>({ + __resolveType: "cms-settings", + telemetry: { errorSampleRate: 2, traceSampleRate: 0 }, + }); + expect(saved?.telemetry).toEqual({ enabled: true, - metrics: false, - errorSampleRate: 0.05, + metrics: true, + errorSampleRate: 1, traceSampleRate: 0, }); }); @@ -594,7 +599,7 @@ describe("the telemetry section of the CMS block (telemetry.mdx)", () => { const cms = createCMS({ blocks: { ...docsBlocks(), broken }, content: withTelemetryBlock({ enabled: false, errorSampleRate: 1, traceSampleRate: 1 }), - telemetry: { endpoint: ENDPOINT, limits: { errorSampleRate: 1, traceSampleRate: 1 } }, + telemetry: { endpoint: ENDPOINT, defaults: { errorSampleRate: 1, traceSampleRate: 1 } }, }); await cms.forRelease().resolve({ __resolveType: "broken" }); await upstreamWith().request("https://search.example/q", { operation: "search" }); @@ -649,10 +654,10 @@ describe("the telemetry section of the CMS block (telemetry.mdx)", () => { // telemetry.mdx › Sampling // --------------------------------------------------------------------------- -describe("sampling and limits (telemetry.mdx)", () => { +describe("sampling: code's defaults, content wins (telemetry.mdx)", () => { async function errorsKept( block: Record, - limits: Record | undefined, + defaults: Record | undefined, random: number, ): Promise { resetForTests(); @@ -661,7 +666,7 @@ describe("sampling and limits (telemetry.mdx)", () => { const cms = createCMS({ blocks: { ...docsBlocks(), broken }, content: withTelemetryBlock(block), - telemetry: { endpoint: ENDPOINT, ...(limits ? { limits } : {}) }, + telemetry: { endpoint: ENDPOINT, ...(defaults ? { defaults } : {}) }, }); vi.spyOn(Math, "random").mockReturnValue(random); await cms.forRelease().resolve({ __resolveType: "broken" }); @@ -669,12 +674,16 @@ describe("sampling and limits (telemetry.mdx)", () => { return logRecords(sent).length; } - it("tel-24: without limits, a block errorSampleRate of 1 is capped at 0.1", async () => { - expect(await errorsKept({ errorSampleRate: 1 }, undefined, 0.09)).toBe(1); - expect(await errorsKept({ errorSampleRate: 1 }, undefined, 0.11)).toBe(0); + it("tel-24: a block errorSampleRate of 1 sends every error log, with no defaults in code", async () => { + expect(await errorsKept({ errorSampleRate: 1 }, undefined, 0.99)).toBe(1); }); - it("tel-24: without limits, a block traceSampleRate of 1 stays at 0 (traces off)", async () => { + it("tel-24: without the block's rate or code's defaults, 5% of error logs are sent", async () => { + expect(await errorsKept({}, undefined, 0.04)).toBe(1); + expect(await errorsKept({}, undefined, 0.06)).toBe(0); + }); + + it("tel-24: a block traceSampleRate of 1 traces every request, with no defaults in code", async () => { vi.spyOn(Math, "random").mockReturnValue(0); const { sent } = collector(); const cms = createCMS({ @@ -684,14 +693,31 @@ describe("sampling and limits (telemetry.mdx)", () => { }); await cms.forRelease().resolve("SummerPage"); await runBackground(); - expect(spans(sent)).toHaveLength(0); + expect(spans(sent).length).toBeGreaterThan(0); }); - it("tel-24: the effective rate is min(block, limit)", async () => { - expect(await errorsKept({ errorSampleRate: 0.3 }, { errorSampleRate: 0.5 }, 0.29)).toBe(1); + it("tel-24: the block's rate wins over code's defaults, up or down; code's apply when the block has none", async () => { + // Content above code's default: content's. + expect(await errorsKept({ errorSampleRate: 1 }, { errorSampleRate: 0.5 }, 0.9)).toBe(1); + // Content below code's default: content's. expect(await errorsKept({ errorSampleRate: 0.3 }, { errorSampleRate: 0.5 }, 0.31)).toBe(0); - expect(await errorsKept({ errorSampleRate: 1 }, { errorSampleRate: 0.5 }, 0.49)).toBe(1); - expect(await errorsKept({ errorSampleRate: 1 }, { errorSampleRate: 0.5 }, 0.51)).toBe(0); + expect(await errorsKept({ errorSampleRate: 0.3 }, { errorSampleRate: 0.5 }, 0.29)).toBe(1); + // Content saves 0: off, whatever code says. + expect(await errorsKept({ errorSampleRate: 0 }, { errorSampleRate: 1 }, 0)).toBe(0); + // Content saves no rate: code's default. + expect(await errorsKept({}, { errorSampleRate: 0.5 }, 0.49)).toBe(1); + expect(await errorsKept({}, { errorSampleRate: 0.5 }, 0.51)).toBe(0); + }); + + it("tel-24: telemetry.limits was removed: createCMS throws, naming telemetry.defaults", () => { + expect(() => + createCMS({ + blocks: docsBlocks(), + content: docsSnapshot(), + // @ts-expect-error -- removed from the types; a JS caller still gets a clear error + telemetry: { endpoint: ENDPOINT, limits: { errorSampleRate: 1 } }, + }), + ).toThrow(/telemetry\.limits` was removed.*telemetry\.defaults/); }); it("tel-23: sending never delays a response; a collector that hangs only holds its own batch", async () => { @@ -750,7 +776,7 @@ describe("privacy (telemetry.mdx, telemetry-internals.mdx)", () => { const cms = createCMS({ blocks: { ...docsBlocks(), broken }, content: withTelemetryBlock({ errorSampleRate: 1 }), - telemetry: { endpoint: ENDPOINT, limits: { errorSampleRate: 1 } }, + telemetry: { endpoint: ENDPOINT, defaults: { errorSampleRate: 1 } }, }); await cms.forRelease().resolve({ __resolveType: "broken" }); const failing = createInstrumentedFetch({ @@ -847,7 +873,7 @@ describe("how telemetry is sent (telemetry-internals.mdx)", () => { const cms = createCMS({ blocks: { ...docsBlocks(), broken }, content: withTelemetryBlock({ errorSampleRate: 1, traceSampleRate: 1 }), - telemetry: { endpoint: ENDPOINT, limits: { errorSampleRate: 1, traceSampleRate: 1 } }, + telemetry: { endpoint: ENDPOINT, defaults: { errorSampleRate: 1, traceSampleRate: 1 } }, }); await cms.forRelease().resolve({ __resolveType: "broken" }); await upstreamWith().request("https://search.example/q", { operation: "search" }); @@ -927,7 +953,7 @@ describe("how telemetry is sent (telemetry-internals.mdx)", () => { blocks: { ...docsBlocks(), broken }, site: "acme", token: "tok", - telemetry: { limits: { errorSampleRate: 1, traceSampleRate: 1 } }, + telemetry: { defaults: { errorSampleRate: 1, traceSampleRate: 1 } }, }); await cms.forRelease().resolve({ __resolveType: "broken" }); await upstreamWith().request("https://search.example/q", { operation: "search" }); diff --git a/packages/blocks/src/v8/__conformance__/sdk.test.ts b/packages/blocks/src/v8/__conformance__/sdk.test.ts index 41e79af3..ff3d4021 100644 --- a/packages/blocks/src/v8/__conformance__/sdk.test.ts +++ b/packages/blocks/src/v8/__conformance__/sdk.test.ts @@ -241,7 +241,7 @@ createCMS({ blocks, content: loader, interval: 60_000, telemetry: false }); createCMS({ blocks, interval: 60_000, telemetry: false, site: "s", token: "t" }); // @ts-expect-error secrets live in the deploy platform: createCMS takes no key createCMS({ blocks, content, secrets: { key: "k" } }); -createCMS({ blocks, site: "s", token: "t", telemetry: { limits: { errorSampleRate: 0.1, traceSampleRate: 0 } } }); +createCMS({ blocks, site: "s", token: "t", telemetry: { defaults: { errorSampleRate: 0.1, traceSampleRate: 0 } } }); createCMS({ blocks, content, telemetry: { endpoint: "https://otel.example", headers: { a: "b" }, resource: { "service.version": "abc" } } }); createCMS({ blocks, content, preview: { hosts: ["*.example.com", "localhost:3000"], draftHosts: [".decocms.com"] } }); // @ts-expect-error draft hosts are preview.draftHosts @@ -320,7 +320,7 @@ assert>(); assert>(); assert, [number, null] | [null, CMSError]>>(); -assert; resource?: Record; limits?: { errorSampleRate?: number; traceSampleRate?: number } }>>(); +assert; resource?: Record; defaults?: { errorSampleRate?: number; traceSampleRate?: number } }>>(); export { r1, r2 }; `, }); @@ -366,16 +366,20 @@ describe("AR-05 interval: default 60 000, minimum 5 000; no environment variable }); }); -describe("AR-06 / AR-07 telemetry destination and limits", () => { - it("limits default to 0.1 and 0", () => { - expect(resolveDestination({ endpoint: "https://otel.example" })?.limits).toEqual({ - errorSampleRate: 0.1, +describe("AR-06 / AR-07 telemetry destination and defaults", () => { + it("defaults are the built-in 0.05 and 0, replaced rate by rate by telemetry.defaults", () => { + expect(resolveDestination({ endpoint: "https://otel.example" })?.defaults).toEqual({ + errorSampleRate: 0.05, traceSampleRate: 0, }); - expect(resolveDestination(undefined, "s", "t")?.limits).toEqual({ - errorSampleRate: 0.1, + expect(resolveDestination(undefined, "s", "t")?.defaults).toEqual({ + errorSampleRate: 0.05, traceSampleRate: 0, }); + expect( + resolveDestination({ endpoint: "https://otel.example", defaults: { errorSampleRate: 1 } }) + ?.defaults, + ).toEqual({ errorSampleRate: 1, traceSampleRate: 0 }); }); it("false sends nothing, even with a token", () => { @@ -1325,8 +1329,8 @@ describe("AR-59 multivariate and lazy", () => { }); }); -describe("AR-60 the telemetry section: defaults and caps", () => { - it("defaults 0.05 / 0, capped by limits", async () => { +describe("AR-60 the telemetry section: content wins over code's defaults", () => { + it("defaults 0.05 / 0, replaced by telemetry.defaults; a rate the section sets wins", async () => { const sent: string[] = []; vi.stubGlobal( "fetch", @@ -1335,36 +1339,56 @@ describe("AR-60 the telemetry section: defaults and caps", () => { return new Response(""); }), ); - const pipeline = (limits?: { errorSampleRate?: number; traceSampleRate?: number }) => - new TelemetryPipeline(resolveDestination({ endpoint: "https://otel.example", limits })!); + const pipeline = (defaults?: { errorSampleRate?: number; traceSampleRate?: number }) => + new TelemetryPipeline(resolveDestination({ endpoint: "https://otel.example", defaults })!); const random = vi.spyOn(Math, "random"); - - // errorSampleRate 0.5 under the default limit 0.1: a draw of 0.2 is dropped, 0.09 kept. - const capped = pipeline(); - capped.apply({ errorSampleRate: 0.5 }); - random.mockReturnValue(0.2); - capped.error("e", {}); - await capped.flush(); - expect(sent.filter((u) => u.includes("/v1/logs"))).toHaveLength(0); - random.mockReturnValue(0.09); - capped.error("e", {}); - await capped.flush(); - expect(sent.filter((u) => u.includes("/v1/logs"))).toHaveLength(1); - - // Default errorSampleRate 0.05: 0.06 dropped. + const logs = () => sent.filter((u) => u.includes("/v1/logs")).length; + + // The section's errorSampleRate 0.5 wins: a draw of 0.6 is dropped, 0.4 kept. + const set = pipeline(); + set.apply({ errorSampleRate: 0.5 }); + random.mockReturnValue(0.6); + set.error("e", {}); + await set.flush(); + expect(logs()).toBe(0); + random.mockReturnValue(0.4); + set.error("e", {}); + await set.flush(); + expect(logs()).toBe(1); + + // Nothing in the section or code: the built-in 0.05, so 0.06 is dropped. sent.length = 0; - const defaults = pipeline({ errorSampleRate: 1 }); - defaults.apply({}); + const builtIn = pipeline(); + builtIn.apply({}); random.mockReturnValue(0.06); - defaults.error("e", {}); - await defaults.flush(); - expect(sent.filter((u) => u.includes("/v1/logs"))).toHaveLength(0); + builtIn.error("e", {}); + await builtIn.flush(); + expect(logs()).toBe(0); + + // Code's default 1 with nothing in the section: every error is kept. + const fromCode = pipeline({ errorSampleRate: 1 }); + fromCode.apply({}); + random.mockReturnValue(0.99); + fromCode.error("e", {}); + await fromCode.flush(); + expect(logs()).toBe(1); + + // The section's 0 wins over code's 1. + sent.length = 0; + const off = pipeline({ errorSampleRate: 1 }); + off.apply({ errorSampleRate: 0 }); + random.mockReturnValue(0); + off.error("e", {}); + await off.flush(); + expect(logs()).toBe(0); - // traceSampleRate defaults to 0 and is capped at limit 0. + // traceSampleRate: built-in 0, the section's 1 traces. random.mockReturnValue(0); const traces = pipeline(); - traces.apply({ traceSampleRate: 1 }); + traces.apply({}); expect(traces.sampleTrace()).toBe(false); + traces.apply({ traceSampleRate: 1 }); + expect(traces.sampleTrace()).toBe(true); }); it("is always in the schema", () => { diff --git a/packages/blocks/src/v8/builtins.test.ts b/packages/blocks/src/v8/builtins.test.ts index 58053e4a..2d9eba6a 100644 --- a/packages/blocks/src/v8/builtins.test.ts +++ b/packages/blocks/src/v8/builtins.test.ts @@ -503,10 +503,10 @@ describe("redirect, cms-settings", () => { ]); }); - it("cms-settings fills in the telemetry and analytics defaults and keeps preview as saved", async () => { + it("cms-settings fills in the telemetry switches and analytics defaults, and keeps sample rates and preview as saved", async () => { expect(await client({}).resolve({ __resolveType: "cms-settings" })).toEqual([ { - telemetry: { enabled: true, metrics: true, errorSampleRate: 0.05, traceSampleRate: 0 }, + telemetry: { enabled: true, metrics: true }, analytics: { collector: HOSTED_ANALYTICS_COLLECTOR, enabled: true, @@ -529,7 +529,7 @@ describe("redirect, cms-settings", () => { expect(await client({}).resolve({ __resolveType: "cms-settings", ...saved })).toEqual([ { preview: { hosts: ["staging.example.com"] }, - telemetry: { enabled: false, metrics: true, errorSampleRate: 0.5, traceSampleRate: 0 }, + telemetry: { enabled: false, metrics: true, errorSampleRate: 0.5 }, analytics: { collector: "https://stats.example.com/events", enabled: false, diff --git a/packages/blocks/src/v8/builtins/data.ts b/packages/blocks/src/v8/builtins/data.ts index 6b5d9b2c..5150ea5a 100644 --- a/packages/blocks/src/v8/builtins/data.ts +++ b/packages/blocks/src/v8/builtins/data.ts @@ -42,7 +42,7 @@ export const redirects = (props: { ) : []; -/** The `telemetry` section's defaults (sample rates before code's caps). */ +/** The `telemetry` section's built-in defaults; code's `telemetry.defaults` replace the two rates. */ export const TELEMETRY_DEFAULTS: Readonly> = Object.freeze({ enabled: true, metrics: true, @@ -54,14 +54,44 @@ export const TELEMETRY_DEFAULTS: Readonly> = Object.freeze({ export const SETTINGS_BLOCK = "CMS"; export const SETTINGS_TYPE = "cms-settings"; -/** The `telemetry` section with its defaults filled in; rates as saved, before any cap. */ -export function telemetrySection(section: unknown): Required { +/** The two sample rates a site falls back to when content saves none. */ +export type SampleRates = { errorSampleRate: number; traceSampleRate: number }; + +/** + * The `telemetry` section with its defaults filled in: a rate content saved + * wins (held to 0–1), else `defaults` (code's `telemetry.defaults`, else + * the built-in ones). + */ +export function telemetrySection( + section: unknown, + defaults: SampleRates = TELEMETRY_DEFAULTS, +): Required { const t = isRecord(section) ? section : {}; return { enabled: typeof t.enabled === "boolean" ? t.enabled : TELEMETRY_DEFAULTS.enabled, metrics: typeof t.metrics === "boolean" ? t.metrics : TELEMETRY_DEFAULTS.metrics, - errorSampleRate: rate(t.errorSampleRate, TELEMETRY_DEFAULTS.errorSampleRate), - traceSampleRate: rate(t.traceSampleRate, TELEMETRY_DEFAULTS.traceSampleRate), + errorSampleRate: rate(t.errorSampleRate, defaults.errorSampleRate), + traceSampleRate: rate(t.traceSampleRate, defaults.traceSampleRate), + }; +} + +/** + * The `telemetry` section as the `cms-settings` block returns it: switches + * filled in, sample rates only where content saved one. The block can't see + * code's `telemetry.defaults`; `cms.settings()` fills the rates from them. + */ +function savedTelemetry(section: unknown): Telemetry & { enabled: boolean; metrics: boolean } { + const t = isRecord(section) ? section : {}; + const { enabled, metrics } = telemetrySection(t); + return { + enabled, + metrics, + ...(typeof t.errorSampleRate === "number" && Number.isFinite(t.errorSampleRate) + ? { errorSampleRate: rate(t.errorSampleRate, 0) } + : {}), + ...(typeof t.traceSampleRate === "number" && Number.isFinite(t.traceSampleRate) + ? { traceSampleRate: rate(t.traceSampleRate, 0) } + : {}), }; } @@ -81,14 +111,19 @@ export function analyticsSection(section: unknown): Required { /** * `cms-settings`: the type of the well-known saved block `CMS`. Returns its - * input with the telemetry and analytics defaults filled in. Preview hosts - * aren't here: Studio writes them into the release metadata. + * input with the telemetry switches and the analytics defaults filled in; a + * sample rate content didn't save stays out (`cms.settings()` fills it from + * code's `telemetry.defaults`). Preview hosts aren't here: Studio writes them + * into the release metadata. */ export const cmsSettings = ( props: CMSSettings = {}, -): CMSSettings & { telemetry: Required; analytics: Required } => ({ +): CMSSettings & { + telemetry: Telemetry & { enabled: boolean; metrics: boolean }; + analytics: Required; +} => ({ ...props, - telemetry: telemetrySection(props.telemetry), + telemetry: savedTelemetry(props.telemetry), analytics: analyticsSection(props.analytics), }); diff --git a/packages/blocks/src/v8/cms.ts b/packages/blocks/src/v8/cms.ts index 7273a776..012e1456 100644 --- a/packages/blocks/src/v8/cms.ts +++ b/packages/blocks/src/v8/cms.ts @@ -28,13 +28,13 @@ import { previewHosts, readSettings, type SavedSettings, - type SettingsCaps, + type SettingsDefaults, } from "./settings.ts"; import { resolveDestination, setCurrentTelemetry, TelemetryPipeline, - telemetryLimits, + telemetryDefaults, } from "./telemetry.ts"; import type { Blocks, @@ -84,7 +84,7 @@ class CMSInstance { readonly #interval: number; readonly #warned = new Set(); readonly #telemetry: TelemetryPipeline | undefined; - readonly #caps: SettingsCaps; + readonly #caps: SettingsDefaults; /** Settings of a `CMS` block with nothing to run, per release index. */ #staticSettings = new WeakMap(); /** Code's `preview.hosts`, parsed: added to the release's own preview hosts, or the only list without a site. */ @@ -105,7 +105,7 @@ class CMSInstance { this.#store = new ContentStore(contentOf(config), parseDraftHosts(config.preview)); this.fingerprint = fingerprintOf(config, interval); this.#codeHosts = parseCodeHosts(config.preview); - this.#caps = { limits: telemetryLimits(config.telemetry) }; + this.#caps = { telemetry: telemetryDefaults(config.telemetry) }; const destination = resolveDestination(config.telemetry, config.site, config.token); if (destination !== null) { this.#telemetry = new TelemetryPipeline(destination); diff --git a/packages/blocks/src/v8/remoteLoader.test.ts b/packages/blocks/src/v8/remoteLoader.test.ts index 76ab37cf..f1f77c3a 100644 --- a/packages/blocks/src/v8/remoteLoader.test.ts +++ b/packages/blocks/src/v8/remoteLoader.test.ts @@ -412,7 +412,7 @@ describe("settings", () => { const cms = createCMS({ blocks: docsBlocks(), site: SITE, - telemetry: { endpoint: "https://otel.example", limits: { traceSampleRate: 1 } }, + telemetry: { endpoint: "https://otel.example", defaults: { traceSampleRate: 1 } }, }); await cms.forRelease().revision(); expect(currentTelemetry()?.sampleTrace()).toBe(false); diff --git a/packages/blocks/src/v8/settings.test.ts b/packages/blocks/src/v8/settings.test.ts index ea11a83e..962ad22d 100644 --- a/packages/blocks/src/v8/settings.test.ts +++ b/packages/blocks/src/v8/settings.test.ts @@ -143,30 +143,48 @@ describe("cms.settings(): defaults", () => { }); }); -describe("cms.settings(): caps from code", () => { - it("telemetry rates are the lower of content's and telemetry.limits (defaults 0.1 and 0)", async () => { +describe("cms.settings(): code's defaults and caps", () => { + it("a telemetry rate content saved wins over telemetry.defaults, up or down", async () => { const content = withSettings({ telemetry: { errorSampleRate: 0.5, traceSampleRate: 0.5 } }); - const defaults = await createCMS({ blocks: {}, content }).settings(); - expect(defaults.telemetry).toMatchObject({ errorSampleRate: 0.1, traceSampleRate: 0 }); + const plain = await createCMS({ blocks: {}, content }).settings(); + expect(plain.telemetry).toMatchObject({ errorSampleRate: 0.5, traceSampleRate: 0.5 }); resetForTests(); - const limited = await createCMS({ + const withDefaults = await createCMS({ blocks: {}, content, telemetry: { endpoint: "https://otel.example", - limits: { errorSampleRate: 1, traceSampleRate: 0.2 }, + defaults: { errorSampleRate: 1, traceSampleRate: 0.2 }, }, }).settings(); - expect(limited.telemetry).toMatchObject({ errorSampleRate: 0.5, traceSampleRate: 0.2 }); + expect(withDefaults.telemetry).toMatchObject({ errorSampleRate: 0.5, traceSampleRate: 0.5 }); }); - it("telemetry: false still caps with the default limits", async () => { + it("a rate content didn't save is code's telemetry.defaults, else the built-in 0.05 and 0", async () => { + const content = withSettings({ telemetry: { enabled: true } }); + expect((await createCMS({ blocks: {}, content }).settings()).telemetry).toMatchObject({ + errorSampleRate: 0.05, + traceSampleRate: 0, + }); + resetForTests(); + const cms = createCMS({ + blocks: {}, + content, + telemetry: { endpoint: "https://otel.example", defaults: { errorSampleRate: 1 } }, + }); + expect((await cms.settings()).telemetry).toMatchObject({ + errorSampleRate: 1, + traceSampleRate: 0, + }); + }); + + it("telemetry: false reads content's rates as saved", async () => { const cms = createCMS({ blocks: {}, content: withSettings({ telemetry: { errorSampleRate: 1 } }), telemetry: false, }); - expect((await cms.settings()).telemetry.errorSampleRate).toBe(0.1); + expect((await cms.settings()).telemetry.errorSampleRate).toBe(1); }); it("without preview hosts in the release, code's list applies (in normal form)", async () => { @@ -389,7 +407,7 @@ describe("cms.settings(): always the release in memory", () => { const cms = createCMS({ blocks: docsBlocks(), content: release, - telemetry: { endpoint: "https://otel.example", limits: { traceSampleRate: 1 } }, + telemetry: { endpoint: "https://otel.example", defaults: { traceSampleRate: 1 } }, }); await cms.forRelease().revision(); expect((await cms.forDraft(pointer).resolve("SummerSEO"))[1]).toBeNull(); @@ -405,7 +423,7 @@ describe("cms.settings(): always the release in memory", () => { const cms = createCMS({ blocks: docsBlocks(), content: withSettings({ telemetry: { traceSampleRate: 1 } }), - telemetry: { endpoint: "https://otel.example", limits: { traceSampleRate: 1 } }, + telemetry: { endpoint: "https://otel.example" }, }); expect(currentTelemetry()?.sampleTrace()).toBe(false); // the defaults, before any client await cms.forRelease().revision(); @@ -438,7 +456,7 @@ describe("cms.settings(): always the release in memory", () => { const cms = createCMS({ blocks: docsBlocks(), content, - telemetry: { endpoint: "https://otel.example", limits: { traceSampleRate: 1 } }, + telemetry: { endpoint: "https://otel.example", defaults: { traceSampleRate: 1 } }, }); await cms.forRelease().revision(); expect(currentTelemetry()?.sampleTrace()).toBe(false); diff --git a/packages/blocks/src/v8/settings.ts b/packages/blocks/src/v8/settings.ts index b217cfae..5b02f9ff 100644 --- a/packages/blocks/src/v8/settings.ts +++ b/packages/blocks/src/v8/settings.ts @@ -2,15 +2,16 @@ * The CMS settings (see /next/built-in-blocks#cms-settings and * /next/api-reference#cms-settings): the saved block `CMS`, of the built-in * type `cms-settings`, read from the release, with the defaults filled in and - * code's caps applied. + * code's defaults and caps applied. * * - The caller hands in the `CMS` body already in memory (local content * holds it; a hosted index carries it inline); nothing here loads content, * so reading settings never fetches (a section referencing another saved * block resolves it as any read would). * - A missing block, a block of another type, or a section that fails to - * resolve gives that section's defaults, still capped. Nothing rejects. - * - Telemetry rates are the lower of content's and `telemetry.limits`. + * resolve gives that section's defaults. Nothing rejects. + * - A telemetry rate content saved wins; else code's `telemetry.defaults`; + * else the built-in 0.05 and 0. Code doesn't cap content's rates. * - Preview hosts aren't content: Studio writes them into the release * metadata (PO 2026-10-22), and `previewHosts` merges them with code's * `preview.hosts`. A remote site with no list published (no `latest.json` @@ -20,6 +21,7 @@ */ import { analyticsSection, + type SampleRates, SETTINGS_BLOCK, SETTINGS_TYPE, telemetrySection, @@ -29,9 +31,9 @@ import { EVERY_HOST, formatHostPattern, type HostPattern, parseHostPattern } fro import { isPlainObject } from "./json.ts"; import type { Client, CMSConfig, EffectiveSettings } from "./types.ts"; -/** What code allows: the caps every section's content is held to. */ -export interface SettingsCaps { - limits: { errorSampleRate: number; traceSampleRate: number }; +/** What code decides for content: the sample rates when content saves none. */ +export interface SettingsDefaults { + telemetry: SampleRates; } /** The settings a release's `CMS` block holds: every section but `preview`, which isn't content. */ @@ -72,8 +74,8 @@ function parseCodePatterns( }); } -/** The settings with nothing saved: every default, capped. */ -export function defaultSettings(caps: SettingsCaps): SavedSettings { +/** The settings with nothing saved: every default. */ +export function defaultSettings(caps: SettingsDefaults): SavedSettings { return effectiveSettings({}, caps); } @@ -85,7 +87,7 @@ export function defaultSettings(caps: SettingsCaps): SavedSettings { export async function readSettings( entry: unknown, client: () => Client, - caps: SettingsCaps, + caps: SettingsDefaults, ): Promise { try { if (!isPlainObject(entry) || entry.__resolveType !== SETTINGS_TYPE) { @@ -125,14 +127,9 @@ function holdsBlock(value: unknown): boolean { * The settings, deep-frozen: one object per release is handed to every caller, * so a caller that writes to it would change it for every request. */ -function effectiveSettings(value: Record, caps: SettingsCaps): SavedSettings { - const telemetry = telemetrySection(value.telemetry); +function effectiveSettings(value: Record, caps: SettingsDefaults): SavedSettings { return deepFreeze({ - telemetry: { - ...telemetry, - errorSampleRate: Math.min(telemetry.errorSampleRate, caps.limits.errorSampleRate), - traceSampleRate: Math.min(telemetry.traceSampleRate, caps.limits.traceSampleRate), - }, + telemetry: telemetrySection(value.telemetry, caps.telemetry), analytics: analyticsSection(value.analytics), }); } diff --git a/packages/blocks/src/v8/telemetry.test.ts b/packages/blocks/src/v8/telemetry.test.ts index a1c59cc9..778010a3 100644 --- a/packages/blocks/src/v8/telemetry.test.ts +++ b/packages/blocks/src/v8/telemetry.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node /** * Telemetry (telemetry.mdx, telemetry-internals.mdx, hosted-telemetry.mdx): - * where it goes, what the Telemetry block and limits allow, the OTLP/HTTP + * where it goes, what the Telemetry block and code's defaults set, the OTLP/HTTP * JSON wire format, background sending and scrubbing. */ import { gunzipSync } from "node:zlib"; @@ -95,9 +95,9 @@ describe("where telemetry goes", () => { headers: { authorization: "Bearer tok" }, site: "acme", }); - expect(resolveDestination({ limits: { traceSampleRate: 1 } }, "acme", "tok")).toMatchObject({ + expect(resolveDestination({ defaults: { traceSampleRate: 1 } }, "acme", "tok")).toMatchObject({ endpoint: HOSTED_TELEMETRY_ENDPOINT, - limits: { traceSampleRate: 1 }, + defaults: { traceSampleRate: 1 }, }); }); @@ -229,7 +229,7 @@ describe("metrics", () => { ...withTelemetryBlock({ enabled: false, errorSampleRate: 1 }), root: "off", }, - telemetry: { endpoint: ENDPOINT, limits: { errorSampleRate: 1 } }, + telemetry: { endpoint: ENDPOINT, defaults: { errorSampleRate: 1 } }, }); await off.forRelease().resolve({ __resolveType: "broken" }); await upstream("https://search.example/q"); @@ -318,14 +318,14 @@ describe("error logs", () => { expect(JSON.stringify(sent)).not.toContain(pointer); }); - it("samples at the Telemetry block's errorSampleRate, capped by limits (default 0.1)", async () => { + it("samples at the Telemetry block's errorSampleRate, which wins over code's defaults", async () => { const { fetch } = collector(); const cms = createCMS({ blocks: { ...docsBlocks(), broken }, - content: withTelemetryBlock({ errorSampleRate: 1 }), - telemetry: { endpoint: ENDPOINT }, + content: withTelemetryBlock({ errorSampleRate: 0.1 }), + telemetry: { endpoint: ENDPOINT, defaults: { errorSampleRate: 1 } }, }); - const random = vi.spyOn(Math, "random").mockReturnValue(0.2); // above the 0.1 cap + const random = vi.spyOn(Math, "random").mockReturnValue(0.2); // above the block's 0.1 await cms.forRelease().resolve({ __resolveType: "broken" }); await runBackground(); expect(fetch).not.toHaveBeenCalled(); @@ -351,12 +351,12 @@ describe("error logs", () => { }); describe("traces", () => { - it("are off by default, even when content asks for them", async () => { + it("are off by default, with no rate in content or code", async () => { vi.spyOn(Math, "random").mockReturnValue(0); const { sent } = collector(); const cms = createCMS({ blocks: docsBlocks(), - content: withTelemetryBlock({ traceSampleRate: 1 }), + content: withTelemetryBlock({}), telemetry: { endpoint: ENDPOINT }, }); await cms.forRelease().revision(); @@ -365,13 +365,16 @@ describe("traces", () => { expect(sent.some((s) => s.url.endsWith("/v1/traces"))).toBe(false); }); - it("trace block resolution and upstream calls once code raises the limit", async () => { + it.each([ + ["content saves traceSampleRate 1", { traceSampleRate: 1 }, undefined], + ["code's default is 1 and content saves none", {}, { traceSampleRate: 1 }], + ])("trace block resolution and upstream calls once %s", async (_, block, defaults) => { vi.spyOn(Math, "random").mockReturnValue(0); const { sent } = collector(); const cms = createCMS({ blocks: docsBlocks(), - content: withTelemetryBlock({ traceSampleRate: 1 }), - telemetry: { endpoint: ENDPOINT, limits: { traceSampleRate: 1 } }, + content: withTelemetryBlock(block), + telemetry: { endpoint: ENDPOINT, ...(defaults ? { defaults } : {}) }, }); await cms.forRelease().revision(); await cms.forRelease().resolve("SummerSEO"); diff --git a/packages/blocks/src/v8/telemetry.ts b/packages/blocks/src/v8/telemetry.ts index 621a2cac..cc604895 100644 --- a/packages/blocks/src/v8/telemetry.ts +++ b/packages/blocks/src/v8/telemetry.ts @@ -7,8 +7,8 @@ * `token` (sent as a Bearer token), else nowhere. `false` sends nothing. * Nothing is read from the environment. * - **How much** comes from content: the `telemetry` section of the CMS - * settings (`cms.settings()`, the release's `CMS` block), capped by - * `telemetry.limits`. The CMS reads it outside any request, when the + * settings (`cms.settings()`, the release's `CMS` block); a rate it doesn't + * save is code's `telemetry.defaults`, else the built-in 0.05 and 0. The CMS reads it outside any request, when the * release changes and then at most once a minute. * - Metrics are aggregated in memory (delta histograms); error logs and traces * are sampled. Batches leave in the background (after the response on @@ -18,7 +18,7 @@ * cookies or authorization values. */ import { hasBackgroundHook, later, runInBackground } from "./background.ts"; -import { rate, TELEMETRY_DEFAULTS } from "./builtins/data.ts"; +import { rate, type SampleRates, TELEMETRY_DEFAULTS } from "./builtins/data.ts"; import { isResolutionError } from "./errors.ts"; import type { CMSError, Telemetry, TelemetryConfig } from "./types.ts"; @@ -32,17 +32,25 @@ const MAX_SPANS = 1_000; const RETRY_STATUSES = new Set([429, 502, 503, 504]); /** OpenTelemetry's recommended `http.*.request.duration` buckets, in seconds. */ const BOUNDS = [0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10]; -const DEFAULT_LIMITS = { errorSampleRate: 0.1, traceSampleRate: 0 }; - -/** The caps on content's sample rates: `telemetry.limits`, defaults 0.1 and 0. */ -export function telemetryLimits(config: false | TelemetryConfig | undefined): { - errorSampleRate: number; - traceSampleRate: number; -} { - const limits = config ? config.limits : undefined; + +/** + * Code's sample rates for when content saves none: `telemetry.defaults`, each + * rate falling back to the built-in one. `telemetry.limits` was removed (it + * capped content's rates); a config still passing it is a TypeError, so a + * site can't keep a cap it believes in. + */ +export function telemetryDefaults(config: false | TelemetryConfig | undefined): SampleRates { + if (config && "limits" in config) { + throw new TypeError( + "createCMS: `telemetry.limits` was removed. Rename it to `telemetry.defaults`: " + + "code now sets the sample rates content doesn't save, and content's win " + + "(see /next/telemetry#sampling)", + ); + } + const defaults = config ? config.defaults : undefined; return { - errorSampleRate: rate(limits?.errorSampleRate, DEFAULT_LIMITS.errorSampleRate), - traceSampleRate: rate(limits?.traceSampleRate, DEFAULT_LIMITS.traceSampleRate), + errorSampleRate: rate(defaults?.errorSampleRate, TELEMETRY_DEFAULTS.errorSampleRate), + traceSampleRate: rate(defaults?.traceSampleRate, TELEMETRY_DEFAULTS.traceSampleRate), }; } @@ -57,7 +65,8 @@ interface Destination { site?: string; /** `telemetry.resource`: merged over the default resource attributes. */ resource: Record; - limits: { errorSampleRate: number; traceSampleRate: number }; + /** Code's sample rates for when content saves none. */ + defaults: SampleRates; } interface SpanInput { @@ -106,10 +115,10 @@ export function resolveDestination( ): Destination | null { if (config === false) return null; // OPEN: no per-signal URLs (v7's OTEL_EXPORTER_OTLP__ENDPOINT): every signal goes to `/v1/`. - const limits = telemetryLimits(config); + const defaults = telemetryDefaults(config); const resource = { ...config?.resource }; if (typeof config?.endpoint === "string" && config.endpoint) { - return { endpoint: config.endpoint, headers: { ...config.headers }, site, resource, limits }; + return { endpoint: config.endpoint, headers: { ...config.headers }, site, resource, defaults }; } if (!token) return null; return { @@ -117,7 +126,7 @@ export function resolveDestination( headers: { authorization: `Bearer ${token}` }, site, resource, - limits, + defaults, }; } @@ -133,7 +142,7 @@ export class TelemetryPipeline { constructor(destination: Destination) { this.#destination = destination; - this.apply(TELEMETRY_DEFAULTS); + this.apply({}); } /** The release being served: its revision labels batches. */ @@ -142,22 +151,17 @@ export class TelemetryPipeline { } /** - * The `telemetry` section to follow (from `cms.settings()`). Rates are - * capped by the destination's limits here too, whatever the caller passed. + * The `telemetry` section to follow (from `cms.settings()`). A rate the + * section leaves out is the destination's default (code's + * `telemetry.defaults`, else the built-in one); one it sets wins. */ apply(section: Telemetry): void { - const { limits } = this.#destination; + const { defaults } = this.#destination; this.#settings = { enabled: typeof section.enabled === "boolean" ? section.enabled : TELEMETRY_DEFAULTS.enabled, metrics: typeof section.metrics === "boolean" ? section.metrics : TELEMETRY_DEFAULTS.metrics, - errorSampleRate: Math.min( - rate(section.errorSampleRate, TELEMETRY_DEFAULTS.errorSampleRate), - limits.errorSampleRate, - ), - traceSampleRate: Math.min( - rate(section.traceSampleRate, TELEMETRY_DEFAULTS.traceSampleRate), - limits.traceSampleRate, - ), + errorSampleRate: rate(section.errorSampleRate, defaults.errorSampleRate), + traceSampleRate: rate(section.traceSampleRate, defaults.traceSampleRate), }; } diff --git a/packages/blocks/src/v8/types.ts b/packages/blocks/src/v8/types.ts index 01937da2..4f00ea23 100644 --- a/packages/blocks/src/v8/types.ts +++ b/packages/blocks/src/v8/types.ts @@ -178,9 +178,9 @@ export interface Telemetry { enabled?: boolean; /** Default `true`. */ metrics?: boolean; - /** Default 0.05, capped by `telemetry.limits`. */ + /** 0 to 1. When not saved: `telemetry.defaults` from code, else 0.05. */ errorSampleRate?: number; - /** Default 0, capped by `telemetry.limits`. */ + /** 0 to 1. When not saved: `telemetry.defaults` from code, else 0. */ traceSampleRate?: number; } @@ -249,7 +249,11 @@ export interface TelemetryConfig { * `service.version`, `deployment.environment.name`, …). */ resource?: Record; - limits?: { errorSampleRate?: number; traceSampleRate?: number }; + /** + * The sample rates when the `telemetry` section of the CMS settings saves + * none (built-in: 0.05 and 0). A rate content saves wins, from 0 to 1. + */ + defaults?: { errorSampleRate?: number; traceSampleRate?: number }; } export interface CMSConfig {