Skip to content

06 — Observability: OpenTelemetry → New Relic

Adventive’s observability platform of record is New Relic. On legacy infrastructure this works through the New Relic system agent + PHP agent. Cloudflare Workers and Pages are serverless and have no agent surface to install into, so we use OpenTelemetry native exports as the bridge: Workers and Pages instrument with OTel and ship via OTLP-HTTP to New Relic’s OTLP endpoint.

This chapter is mandatory reading for any Worker, Pages site, or Pages Function we ship under the Adventive Cloudflare account. OpenTelemetry instrumentation is a required baseline for every new runtime component, on the same footing as naming (01), wrangler config (02), and secrets policy (03).


Why OTel rather than Cloudflare-native-only

Section titled “Why OTel rather than Cloudflare-native-only”
NeedWhy Workers Logs / Analytics Engine alone is not enough
Single pane of glass with our existing PHP/EC2 servicesLogs and Analytics live inside Cloudflare; New Relic is where on-call already looks.
Cross-service traces (UI → Worker → upstream API → DB)Cloudflare-native trace events stop at the Worker boundary. OTel propagates traceparent end-to-end.
Vendor portabilityOTel is a standard. If we ever change APM vendor, instrumentation stays; only the exporter target moves.
Aurora query telemetry through Hyperdrive + TunnelWe need DB span timing tied back to the originating request. Only OTel gives us that.

We still keep Cloudflare-native sources on (Workers Logs, Logpush, Tail Workers, Analytics Engine) — they are complementary. OTel is the durable path; native sources catch what slips through (e.g., out-of-band errors before SDK init).


┌─────────────────────────────────────────────────────────────────┐
│ Cloudflare Worker / Pages Function │
│ │
│ src/index.ts │
│ ↓ wrap with @microlabs/otel-cf-workers │
│ instrumented fetch() handler │
│ ↓ spans for: fetch, sub-fetch, KV, DO, D1, │
│ Hyperdrive, queues, scheduled, custom user spans │
│ ↓ │
│ OTLP-HTTP exporter (gzip, batched) │
└─────────────────────────────────────────────────────────────────┘
│ POST /v1/traces (OTLP-HTTP)
│ api-key: <NR ingest license key>
https://otlp.nr-data.net:4318
New Relic One
(APM + Distributed Tracing + Logs in Context)

For browser-side (Pages SPA): the same OTLP-HTTP path, sent from the browser via @opentelemetry/sdk-trace-web + @opentelemetry/exporter-trace-otlp-http. The license key is exposed as a browser ingest key (separate, narrower scope than the server ingest key) — see §“Browser SDK” below.


A single env-var switch controls whether instrumentation is wired in at cold start. This is the universal Adventive contract for OTel — every Worker, Pages Function, and SPA honors it identically:

Env varTypeSource
OTEL_ENABLED"true" | "false"[vars] per env in wrangler.toml (Worker) or .env.<mode> (SPA)

Default values per environment:

EnvironmentOTEL_ENABLED defaultRationale
devfalseNo NR ingest cost or noise from local dev iterations.
stgtrueRequired to validate signal shape and parity with PHP before promotion.
prdtrueProduction observability is non-negotiable.

Project-level POC carve-out (current standing exception): Until OTel is proven end-to-end across a representative Worker and Pages SPA, dev may be set to true to shake out instrumentation issues. This is a temporary state and must be reverted to false before the project ships to production. Track the carve-out in decisions/YYYY-MM-DD-otel-dev-on-for-poc.md so the revert isn’t forgotten.

Runtime behavior when OTEL_ENABLED=false: the instrumentation wrapper is skipped entirely — export default app exports the bare Hono app instead of the wrapped one. Zero runtime cost, zero extra fetches. The exporter is never constructed.

Switching at any time: updating wrangler.toml [vars] and redeploying flips state. For an emergency override without a redeploy, ship a one-shot wrangler deploy --var OTEL_ENABLED:false --env <env> from the sandbox.


Add these to every environment block in wrangler.toml:

# Top of file — dev defaults
[vars]
OTEL_ENABLED = "false" # POC carve-out: temporarily "true" until end-to-end validated. See decisions/.
OTEL_SERVICE_NAME = "adv-svc-<project>-dev"
OTEL_EXPORTER_OTLP_ENDPOINT = "https://otlp.nr-data.net:4318"
OTEL_TRACES_SAMPLER = "parentbased_traceidratio"
OTEL_TRACES_SAMPLER_ARG = "1.0"
OTEL_RESOURCE_ATTRIBUTES = "deployment.environment=dev,cloud.provider=cloudflare,cloud.platform=cloudflare_workers"
[env.staging.vars]
OTEL_ENABLED = "true"
OTEL_SERVICE_NAME = "adv-svc-<project>-stg"
OTEL_EXPORTER_OTLP_ENDPOINT = "https://otlp.nr-data.net:4318"
OTEL_TRACES_SAMPLER = "parentbased_traceidratio"
OTEL_TRACES_SAMPLER_ARG = "1.0"
OTEL_RESOURCE_ATTRIBUTES = "deployment.environment=stg,cloud.provider=cloudflare,cloud.platform=cloudflare_workers"
[env.production.vars]
OTEL_ENABLED = "true"
OTEL_SERVICE_NAME = "adv-svc-<project>-prd"
OTEL_EXPORTER_OTLP_ENDPOINT = "https://otlp.nr-data.net:4318"
OTEL_TRACES_SAMPLER = "parentbased_traceidratio"
OTEL_TRACES_SAMPLER_ARG = "0.1" # 10% sampling in prod to bound NR ingest cost
OTEL_RESOURCE_ATTRIBUTES = "deployment.environment=prd,cloud.provider=cloudflare,cloud.platform=cloudflare_workers"

If the Worker is deployed to the EU region (rare for Adventive today), swap the endpoint to https://otlp.eu01.nr-data.net:4318.

Required secret per environment — via Cloudflare Secrets Store

Section titled “Required secret per environment — via Cloudflare Secrets Store”

The New Relic ingest license key is bound to every Worker via Cloudflare Secrets Store, NOT via wrangler secret put. The whole point of Secrets Store is create the secret once at the account level, never save the cleartext anywhere else, never re-paste it. Per-Worker wrangler secret put is forbidden for committed scaffolds — see canonical CLAUDE.md §3 hard rules.

One Secrets Store entry per environment, bare lowercase-hyphenated names:

  • new-relic-license-key-dev
  • new-relic-license-key-stg
  • new-relic-license-key-prd

A single secret per env is shared across every Worker that needs the NR ingest key (tools-gateway, status-worker, admin-api, public-api, etc.). The store is account-scoped so there’s no namespace conflict.

Provisioning (one-time, by a Secrets Store Admin)

Section titled “Provisioning (one-time, by a Secrets Store Admin)”

Done at the moment the source-of-truth value is generated in New Relic. Either via the Cloudflare dashboard (Secrets Store → Add secret, scope = workers) or via the API. The CLI form, for completeness, is:

Terminal window
cd ~ && . ~/Documents/Claude/.cowork-env && export PATH=~/.npm-global/bin:$PATH && wrangler secrets-store secret create <STORE_ID> --name new-relic-license-key-dev --scopes workers --remote

Even this CLI form pastes once during initial provisioning — that’s the only authorized touch of the cleartext outside Cloudflare. The dashboard path is preferred to avoid even that one paste.

The Cloudflare Secrets Store ID is captured as a Terraform output from adventive-platform-infra and referenced consistently across all wrangler.toml files. Looking it up ad-hoc:

Terminal window
cd ~ && . ~/Documents/Claude/.cowork-env && export PATH=~/.npm-global/bin:$PATH && wrangler secrets-store store list

Binding in wrangler.toml (every environment block)

Section titled “Binding in wrangler.toml (every environment block)”

Add a [[env.<env>.secrets_store_secrets]] block per environment. The binding name is the same across envs (NEW_RELIC_LICENSE_KEY) so Worker code stays env-agnostic; the secret_name differs:

[[env.dev.secrets_store_secrets]]
binding = "NEW_RELIC_LICENSE_KEY"
store_id = "<adventive-store-id-from-tf-output>"
secret_name = "new-relic-license-key-dev"
[[env.stg.secrets_store_secrets]]
binding = "NEW_RELIC_LICENSE_KEY"
store_id = "<adventive-store-id-from-tf-output>"
secret_name = "new-relic-license-key-stg"
[[env.prd.secrets_store_secrets]]
binding = "NEW_RELIC_LICENSE_KEY"
store_id = "<adventive-store-id-from-tf-output>"
secret_name = "new-relic-license-key-prd"

The binding name NEW_RELIC_LICENSE_KEY is the same across all repos — do not rename per project.

Cloudflare Secrets Store secrets created with --remote are NOT accessible from local wrangler dev. This is intentional. Adventive’s standard is OTEL_ENABLED=false on engineer laptops (see docs/projects/local-dev-public-api/decisions/2026-05-08-otel-off-on-laptops.md), so local Worker runs do not need a real NR key. If an engineer wants to exercise OTel locally, the path is a throwaway local secret:

Terminal window
cd ~/Repositories/GitHub/Adventive/<repo> && . ~/Documents/Claude/.cowork-env && export PATH=~/.npm-global/bin:$PATH && wrangler secrets-store secret create <STORE_ID> --name new-relic-license-key-local --scopes workers

(No --remote — writes to the local-only store under .wrangler/.) The engineer’s local wrangler.toml binding for that env points at new-relic-license-key-local. Production deploys never reference local-only secrets.

Production deploys of any Worker that depends on this binding are blocked at the canonical CLAUDE.md hard-rule level until the AWS Secrets Manager IdP recovery implementation is complete (see docs/platform/aws/secrets-manager/01-idp-recovery.md). That gate is separate from this binding — the NR key binding works fine in dev and stg; the prd block is about IdP recovery readiness, not about Worker secrets per se.


We standardize on the @microlabs/otel-cf-workers library. It auto-instruments the fetch handler, sub-fetch calls, and all common bindings (KV, D1, DO, R2, Hyperdrive, Queues), and ships OTLP-HTTP with batching + gzip out of the box.

The library has a sync/async impedance mismatch — bridge it in worker.ts

Section titled “The library has a sync/async impedance mismatch — bridge it in worker.ts”

@microlabs/otel-cf-workers defines ResolveConfigFn<Env> = (env, trigger) => TraceConfig — strictly synchronous. The NEW_RELIC_LICENSE_KEY binding from Cloudflare Secrets Store, however, exposes only get(): Promise<string>. The two contracts don’t compose directly: you can’t await inside the config-resolve function the library expects.

The canonical Adventive pattern resolves this in src/worker.ts with a lazy-init wrapper: on the first request per Worker isolate, the wrapper awaits the Secrets Store binding, captures the cleartext in a closure that the synchronous otelConfig returns, and caches the resulting instrumented handler for subsequent requests. The async cost is one extra hop per isolate cold-start; warm requests skip it.

This pattern was selected after an audit of the alternatives: RichiCoder1/opentelemetry-sdk-workers is deprecated, @hono/otel requires @microlabs/otel-cf-workers underneath, and the forks (@bruceshi, @jahands, @mymindstorm) inherit the same sync constraint. The cleanest non-fork path is to keep the library and bridge in our own code.

The OTel construction does NOT live in lib/otel.ts because the lazy-init closure needs to capture the resolved Secrets Store value, which only worker.ts can produce. Keep lib/otel.ts minimal as a hook for future shared OTel utilities:

src/lib/otel.ts
/**
* The actual instrumentation construction lives in src/worker.ts so the
* lazy-init closure can capture the resolved Cloudflare Secrets Store
* cleartext. This file is a placeholder for future shared OTel utilities
* (custom span helpers, common attribute builders).
*/
export {};
src/worker.ts
import { instrument } from "@microlabs/otel-cf-workers";
import { app } from "./index";
import type { Env } from "./lib/env";
let cachedHandler: ExportedHandler<Env> | undefined;
async function ensureHandler(env: Env): Promise<ExportedHandler<Env>> {
if (cachedHandler) return cachedHandler;
let apiKey: string | undefined;
if (env.OTEL_ENABLED === "true") {
apiKey = await env.NEW_RELIC_LICENSE_KEY.get();
}
cachedHandler = instrument(app, (e, _trigger) => {
if (e.OTEL_ENABLED !== "true") {
const noopHeaders: Record<string, string> = {};
return {
exporter: { url: "about:blank", headers: noopHeaders },
service: {
name: e.OTEL_SERVICE_NAME,
namespace: "adventive.<project>", // e.g., adventive.tools-auth, adventive.status, adventive.public-api
version: e.COMMIT_SHA ?? "unknown",
},
sampling: {
headSampler: { shouldSample: () => ({ decision: 0 as const }) },
},
};
}
const headers: Record<string, string> = { "api-key": apiKey! };
return {
exporter: {
url: `${e.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`,
headers,
},
service: {
name: e.OTEL_SERVICE_NAME,
namespace: "adventive.<project>", // e.g., adventive.tools-auth, adventive.status, adventive.public-api
version: e.COMMIT_SHA ?? "unknown",
},
};
});
return cachedHandler;
}
export default {
async fetch(
request: Request<unknown, IncomingRequestCfProperties>,
env: Env,
ctx: ExecutionContext,
): Promise<Response> {
const handler = await ensureHandler(env);
return handler.fetch!(request, env, ctx);
},
} satisfies ExportedHandler<Env>;

Notes on the pattern:

  • One async hop per isolate cold-start. cachedHandler lives in module scope and is populated on first request. Subsequent requests in the same isolate skip the await env.NEW_RELIC_LICENSE_KEY.get() and go straight to handler.fetch!().
  • The closure captures apiKey for the sync otelConfig. The library calls our config function synchronously per request, but the value it returns was resolved asynchronously at first-request time. That’s the whole bridge.
  • Request<unknown, IncomingRequestCfProperties> matches the incoming-request type the library’s instrument() wrap expects. The global Request is too broad; explicit typing avoids a TypeScript error here.
  • Tests import the bare app from src/index.ts, not the instrumented default export. src/index.ts keeps the bare Hono app with a named export so test code can run in Node without triggering @microlabs/otel-cf-workers’s cloudflare: protocol imports (which Node’s ESM loader rejects).

Library limitation: OTEL_RESOURCE_ATTRIBUTES is ignored; ServiceConfig is the only surface

Section titled “Library limitation: OTEL_RESOURCE_ATTRIBUTES is ignored; ServiceConfig is the only surface”

@microlabs/otel-cf-workers (v1.0.0-rc.x as of 2026-06-03) does NOT read any OTEL_* environment variables — confirmed by grepping the published dist/index.js. The library accepts only what we pass to the service config object:

interface ServiceConfig {
name: string;
namespace?: string;
version?: string;
}

And it hardcodes the following resource attributes at module init time:

const workerResourceAttrs = {
"cloud.provider": "cloudflare",
"cloud.platform": "cloudflare.workers",
"cloud.region": "earth",
"faas.max_memory": 134217728,
"telemetry.sdk.language": "js",
"telemetry.sdk.name": "@microlabs/otel-cf-workers",
"telemetry.sdk.version": <version>,
"telemetry.sdk.build.node_version": <node>,
};

Setting OTEL_RESOURCE_ATTRIBUTES in wrangler.toml (e.g., deployment.environment=dev,service.namespace=adventive.tools-auth,...) has no effect with this library. We keep the env var in wrangler.toml for future-compat with a library that does honor it, and because it’s the OTel spec’s canonical mechanism.

The New Relic OpenTelemetry APM 2.0 view applies semantic-convention checks to render the rich service map, Apdex, and instance-level breakdowns. With the current library limitation:

  • service.name — set via ServiceConfig.name.
  • service.namespace — set via ServiceConfig.namespace (added 2026-06-03; was missing before).
  • service.version — set via ServiceConfig.version. Currently "unknown" until a build-time COMMIT_SHA is plumbed.
  • service.instance.idnot settable with the current library. Required by NR APM 2.0 for per-instance views.
  • deployment.environmentnot settable as a resource attribute. We do set it as a span attribute via the audit emitter, but that’s per-span, not Resource-level.
  • ❌ Other arbitrary resource attributes — not settable.

Practical impact: the NR APM 2.0 rich-UI view does not currently render for our Workers. The legacy span / Distributed Tracing view does render fully — traces, spans, attributes, errors are all visible there. Operations monitoring is functional; the polished APM rendering is what’s missing.

This is queued in FOLLOW_UPS.md as a watch item. When @microlabs/otel-cf-workers ships either:

  1. An async ResolveConfigFn (collapses our lazy-init wrapper back to a direct instrument(app, async otelConfig)), or
  2. A resourceAttributes field on TraceConfigBase (lets us set arbitrary Resource attrs, including service.instance.id, deployment.environment, etc.) —

— revisit this pattern and update the platform doc. Until then, the lazy-init wrapper with ServiceConfig (name + namespace + version) is what we use, and NR APM monitoring runs on the legacy span view.

If the constraint becomes operationally painful before the upstream improvements land, options are: (a) PR upstream the resourceAttributes support (small change — the library already does resource.merge(serviceResource) in createResource, so adding a merge of a user-supplied attributes object is straightforward), (b) write a custom SpanProcessor that overrides the Resource per span (heavier; ~1 day of work), or (c) switch libraries (current alternatives are forks of @microlabs and inherit the same limitations; standing review).

src/lib/env.ts
export interface Env {
// ... existing bindings ...
OTEL_ENABLED: string; // "true" | "false"
OTEL_SERVICE_NAME: string;
OTEL_EXPORTER_OTLP_ENDPOINT: string;
OTEL_TRACES_SAMPLER: string;
OTEL_TRACES_SAMPLER_ARG: string;
OTEL_RESOURCE_ATTRIBUTES: string;
NEW_RELIC_LICENSE_KEY: SecretsStoreSecret; // Cloudflare Secrets Store binding
COMMIT_SHA?: string;
}

SecretsStoreSecret comes from @cloudflare/workers-types and is the binding object exposed when a Worker binds a Secrets Store secret. Accessing the cleartext requires the async .get() call shown in otel.ts above.

Where auto-instrumentation isn’t enough (e.g., a complex transform, a third-party fetch inside a handler), use the OTel API directly:

import { trace } from "@opentelemetry/api";
const tracer = trace.getTracer("public-api");
const span = tracer.startSpan("convertForConnector");
try {
const out = convertForConnector(rows);
span.setAttribute("rows.in", rows.length);
span.setAttribute("rows.out", out.length);
return out;
} finally {
span.end();
}

Naming convention for custom spans: <file-or-feature>.<verb> — e.g., dataconnector.transform, auth.cache_lookup, ratelimit.check.


Implementation pattern (Pages SPA — browser side)

Section titled “Implementation pattern (Pages SPA — browser side)”

For Pages SPAs (e.g., adventive-admin-ui), we instrument in the browser. The OTLP endpoint is the same; the auth header swaps from the server license key to a browser-scoped license key (lower-privilege ingest key issued specifically for browser use — never reuse the server key).

.env.development:

VITE_OTEL_ENABLED=false
VITE_OTEL_SERVICE_NAME=adv-cli-admin-ui-dev
VITE_OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp.nr-data.net:4318
VITE_NEW_RELIC_BROWSER_LICENSE_KEY=__set_via_pages_env__
VITE_OTEL_RESOURCE_ATTRIBUTES=deployment.environment=dev,cloud.provider=cloudflare,cloud.platform=cloudflare_pages

.env.staging, .env.production: same shape, VITE_OTEL_ENABLED=true and the service name suffix updated.

POC carve-out: .env.development may be VITE_OTEL_ENABLED=true during initial validation; revert before final ship.

The browser license key is not a secret the way the server key is — it’s a public-by-design ingest key with rate limiting on the NR side. But still inject it via Cloudflare Pages env vars rather than committing it.

src/lib/otel.ts
import { WebTracerProvider } from "@opentelemetry/sdk-trace-web";
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { Resource } from "@opentelemetry/resources";
import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";
import { ZoneContextManager } from "@opentelemetry/context-zone";
import { registerInstrumentations } from "@opentelemetry/instrumentation";
import { FetchInstrumentation } from "@opentelemetry/instrumentation-fetch";
import { DocumentLoadInstrumentation } from "@opentelemetry/instrumentation-document-load";
import { UserInteractionInstrumentation } from "@opentelemetry/instrumentation-user-interaction";
export function initOtel(): void {
if (import.meta.env.VITE_OTEL_ENABLED !== "true") return;
const provider = new WebTracerProvider({
resource: new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: import.meta.env.VITE_OTEL_SERVICE_NAME,
[SemanticResourceAttributes.SERVICE_VERSION]: import.meta.env.VITE_COMMIT_SHA ?? "unknown",
}),
});
provider.addSpanProcessor(
new BatchSpanProcessor(
new OTLPTraceExporter({
url: `${import.meta.env.VITE_OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`,
headers: {
"api-key": import.meta.env.VITE_NEW_RELIC_BROWSER_LICENSE_KEY,
},
}),
),
);
provider.register({ contextManager: new ZoneContextManager() });
registerInstrumentations({
instrumentations: [
new DocumentLoadInstrumentation(),
new UserInteractionInstrumentation(),
new FetchInstrumentation({
propagateTraceHeaderCorsUrls: [
/^https:\/\/api\.adventive\.dev\//,
/^https:\/\/api\.adventivestg\.com\//,
/^https:\/\/api\.adventive\.com\//,
/^https:\/\/admin-api\.adventive\.dev\//,
/^https:\/\/admin-api\.adventivestg\.com\//,
/^https:\/\/admin-api\.adventive\.com\//,
],
}),
],
});
}

Call initOtel() once at app entry, before React mount:

src/main.tsx
import { initOtel } from "./lib/otel";
initOtel();
import("./bootstrap-app").then(({ mount }) => mount());

The dynamic-import pattern ensures OTel registers before any traced fetch fires.

The propagateTraceHeaderCorsUrls allow-list above causes the browser SDK to inject W3C traceparent headers on outbound fetches to our API origins. Our Workers’ OTel instrumentation reads traceparent and continues the trace — producing a true end-to-end span tree from button click → fetch → Worker → DB.

CORS implication: every Adventive Worker that receives traffic from a browser origin must allow the traceparent and tracestate headers in its CORS config. Add them to the existing cors() middleware allow-list — non-optional once OTel is on.


OTel touches all six of the standing pillars. Anywhere a pillar overlaps with this chapter, defer to the explicit text here.

  1. Redundancy — OTLP exporter retries on 5xx with exponential backoff (built into the library). Fallback: spans drop after retry budget. Worker remains healthy.
  2. Resiliency — Exporter failures must never affect request handling. The wrapper isolates failures (waitUntil()-scoped). If NR is down, requests still return 200.
  3. Disaster recovery — Trace data is non-restorable by definition. RPO is acknowledged as zero — we accept loss of traces during NR outages. Logs (Cloudflare Logpush) and Analytics Engine remain as fallback sources.
  4. Backup — N/A for traces. Span data is ephemeral. Aggregate metrics live in NR.
  5. Deployment strategyOTEL_ENABLED env var is a feature flag. Promote off → on per env via the standard QA gate (04).
  6. Observability — This chapter is the observability pillar. NR is the system of record.

After enabling OTel, verify before declaring the rollout done:

  1. Cold-start span appears. Send one request; within 60 seconds it shows up in NR APM under entities.guid matching the service name.
  2. Sub-fetch is a child span. A handler that calls a service binding or makes an outbound fetch() produces a parent + child span pair, not two roots.
  3. Hyperdrive query is a span. A handler that queries the DB produces a db.statement-attributed span with non-zero duration.
  4. Trace propagation works browser → Worker. Open the SPA, trigger an action that calls the Worker, find the trace in NR. The browser document-load span and the Worker fetch span share a trace.id.
  5. Disable behavior. Flip OTEL_ENABLED=false and redeploy. New requests produce zero spans in NR for ≥ 5 minutes. Worker latency unchanged within noise.
  6. Sampling works in prd. With OTEL_TRACES_SAMPLER_ARG=0.1, ratio of NR-recorded requests vs. Cloudflare-counted requests is ~0.1 over a 10-minute window.

Document verification results in the Worker’s RUNBOOK.md under “Observability — verified on YYYY-MM-DD”.


What goes in 03-deployment-management.md for every project

Section titled “What goes in 03-deployment-management.md for every project”

Every Adventive project’s 03-deployment-management.md must include an “Observability” section that:

  • Names the NR account and license-key source
  • Lists the env-var values per env (or links to this chapter for the standard set)
  • Names any custom spans the project plans to emit
  • States the project-level sampling policy for prd (default 0.1)
  • States whether the POC dev-on carve-out applies and the planned revert date

Project-level deviations from these defaults are the only things worth writing — the defaults are inherited from this chapter automatically.



Chapter introduced: 2026-05-02. Owner: Adventive Platform Engineering.