Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<endpoint>/v1/<signal>`.
- **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`).
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/blocks/conformance/observability/cms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
});

Expand Down
14 changes: 8 additions & 6 deletions packages/blocks/src/v8/__conformance__/extra.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>, 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<string, unknown>, 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,
Expand Down
13 changes: 7 additions & 6 deletions packages/blocks/src/v8/__conformance__/guides.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>)[] = [];
(globalThis as Record<symbol, unknown>)[HOOK] = (task: () => Promise<void>) => tasks.push(task);
Expand All @@ -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" });
Expand Down
76 changes: 51 additions & 25 deletions packages/blocks/src/v8/__conformance__/observability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`", () => {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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" });
Expand Down Expand Up @@ -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
Expand All @@ -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,
});
});
Expand All @@ -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" });
Expand Down Expand Up @@ -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<string, unknown>,
limits: Record<string, number> | undefined,
defaults: Record<string, number> | undefined,
random: number,
): Promise<number> {
resetForTests();
Expand All @@ -661,20 +666,24 @@ 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" });
await runBackground();
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({
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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" });
Expand Down Expand Up @@ -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" });
Expand Down
Loading
Loading