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”| Need | Why Workers Logs / Analytics Engine alone is not enough |
|---|---|
| Single pane of glass with our existing PHP/EC2 services | Logs 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 portability | OTel is a standard. If we ever change APM vendor, instrumentation stays; only the exporter target moves. |
| Aurora query telemetry through Hyperdrive + Tunnel | We 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).
Architecture
Section titled “Architecture”┌─────────────────────────────────────────────────────────────────┐│ 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.
The toggle: OTEL_ENABLED env var
Section titled “The toggle: OTEL_ENABLED env var”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 var | Type | Source |
|---|---|---|
OTEL_ENABLED | "true" | "false" | [vars] per env in wrangler.toml (Worker) or .env.<mode> (SPA) |
Default values per environment:
| Environment | OTEL_ENABLED default | Rationale |
|---|---|---|
dev | false | No NR ingest cost or noise from local dev iterations. |
stg | true | Required to validate signal shape and parity with PHP before promotion. |
prd | true | Production 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.
Required [vars] per environment (Worker)
Section titled “Required [vars] per environment (Worker)”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 costOTEL_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.
Naming
Section titled “Naming”One Secrets Store entry per environment, bare lowercase-hyphenated names:
new-relic-license-key-devnew-relic-license-key-stgnew-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:
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 --remoteEven 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.
Store ID
Section titled “Store ID”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:
cd ~ && . ~/Documents/Claude/.cowork-env && export PATH=~/.npm-global/bin:$PATH && wrangler secrets-store store listBinding 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.
Local development
Section titled “Local development”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:
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 gate
Section titled “Production gate”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.
Implementation pattern (Worker)
Section titled “Implementation pattern (Worker)”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.
src/lib/otel.ts — placeholder
Section titled “src/lib/otel.ts — placeholder”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:
/** * 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 — the lazy-init wrapper
Section titled “src/worker.ts — the lazy-init wrapper”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.
cachedHandlerlives in module scope and is populated on first request. Subsequent requests in the same isolate skip theawait env.NEW_RELIC_LICENSE_KEY.get()and go straight tohandler.fetch!(). - The closure captures
apiKeyfor the syncotelConfig. 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’sinstrument()wrap expects. The globalRequestis too broad; explicit typing avoids a TypeScript error here.- Tests import the bare
appfromsrc/index.ts, not the instrumented default export.src/index.tskeeps the bare Hono app with a named export so test code can run in Node without triggering@microlabs/otel-cf-workers’scloudflare: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.
What this means for New Relic APM 2.0 UI
Section titled “What this means for New Relic APM 2.0 UI”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 viaServiceConfig.name. - ✅
service.namespace— set viaServiceConfig.namespace(added 2026-06-03; was missing before). - ✅
service.version— set viaServiceConfig.version. Currently"unknown"until a build-timeCOMMIT_SHAis plumbed. - ❌
service.instance.id— not settable with the current library. Required by NR APM 2.0 for per-instance views. - ❌
deployment.environment— not 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.
Follow-up — upstream issue or fork
Section titled “Follow-up — upstream issue or fork”This is queued in FOLLOW_UPS.md as a watch item. When @microlabs/otel-cf-workers ships either:
- An async
ResolveConfigFn(collapses our lazy-init wrapper back to a directinstrument(app, async otelConfig)), or - A
resourceAttributesfield onTraceConfigBase(lets us set arbitrary Resource attrs, includingservice.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).
Env interface additions
Section titled “Env interface additions”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.
Custom spans for business logic
Section titled “Custom spans for business logic”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).
Required env vars (Vite)
Section titled “Required env vars (Vite)”.env.development:
VITE_OTEL_ENABLED=falseVITE_OTEL_SERVICE_NAME=adv-cli-admin-ui-devVITE_OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp.nr-data.net:4318VITE_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.developmentmay beVITE_OTEL_ENABLED=trueduring 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.
Bootstrap (src/lib/otel.ts in the SPA)
Section titled “Bootstrap (src/lib/otel.ts in the SPA)”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:
import { initOtel } from "./lib/otel";initOtel();
import("./bootstrap-app").then(({ mount }) => mount());The dynamic-import pattern ensures OTel registers before any traced fetch fires.
Trace context propagation to our Workers
Section titled “Trace context propagation to our Workers”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.
Coverage of Cloudflare design pillars
Section titled “Coverage of Cloudflare design pillars”OTel touches all six of the standing pillars. Anywhere a pillar overlaps with this chapter, defer to the explicit text here.
- Redundancy — OTLP exporter retries on 5xx with exponential backoff (built into the library). Fallback: spans drop after retry budget. Worker remains healthy.
- Resiliency — Exporter failures must never affect request handling. The wrapper isolates failures (
waitUntil()-scoped). If NR is down, requests still return 200. - 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.
- Backup — N/A for traces. Span data is ephemeral. Aggregate metrics live in NR.
- Deployment strategy —
OTEL_ENABLEDenv var is a feature flag. Promote off → on per env via the standard QA gate (04). - Observability — This chapter is the observability pillar. NR is the system of record.
Verification — every Worker
Section titled “Verification — every Worker”After enabling OTel, verify before declaring the rollout done:
- Cold-start span appears. Send one request; within 60 seconds it shows up in NR APM under
entities.guidmatching the service name. - 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. - Hyperdrive query is a span. A handler that queries the DB produces a
db.statement-attributed span with non-zero duration. - 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. - Disable behavior. Flip
OTEL_ENABLED=falseand redeploy. New requests produce zero spans in NR for ≥ 5 minutes. Worker latency unchanged within noise. - 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.
References
Section titled “References”- New Relic OTLP endpoint docs: https://docs.newrelic.com/docs/more-integrations/open-source-telemetry-integrations/opentelemetry/best-practices/opentelemetry-otlp/
- New Relic license keys: https://docs.newrelic.com/docs/apis/intro-apis/new-relic-api-keys/
- Cloudflare Workers Observability + OTel: https://developers.cloudflare.com/workers/observability/
@microlabs/otel-cf-workers: https://github.com/evanderkoogh/otel-cf-workers- OpenTelemetry browser SDK: https://opentelemetry.io/docs/instrumentation/js/getting-started/browser/
- Adventive design pillars: see [feedback memory
feedback_cloudflare_design_pillars.md]
Chapter introduced: 2026-05-02. Owner: Adventive Platform Engineering.