@decocms/start 4.3.0 → 4.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bun.lock +36 -110
- package/package.json +4 -3
- package/scripts/migrate/phase-report.ts +8 -0
- package/scripts/migrate/templates/server-entry.ts +39 -3
- package/scripts/migrate-to-cf-observability.test.ts +169 -0
- package/scripts/migrate-to-cf-observability.ts +611 -0
- package/src/sdk/logger.test.ts +79 -0
- package/src/sdk/logger.ts +40 -2
- package/src/sdk/otel.test.ts +128 -15
- package/src/sdk/otel.ts +179 -98
- package/src/sdk/sampler.ts +17 -5
- package/src/sdk/workerEntry.ts +7 -4
package/src/sdk/logger.ts
CHANGED
|
@@ -88,6 +88,20 @@ export const defaultLoggerAdapter: LoggerAdapter = {
|
|
|
88
88
|
let activeAdapter: LoggerAdapter = defaultLoggerAdapter;
|
|
89
89
|
let minLevel: LogLevel = "info";
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Per-record attribute floor — merged into every log line BEFORE the
|
|
93
|
+
* caller's `attrs` (caller wins). Used to stamp `deco.runtime.version`,
|
|
94
|
+
* `deco.apps.version`, `deployment.environment` on every log so HyperDX
|
|
95
|
+
* panels filtering on these dimensions keep working under
|
|
96
|
+
* Cloudflare-managed log export (which otherwise strips our resource
|
|
97
|
+
* attributes — only the JSON record body survives).
|
|
98
|
+
*
|
|
99
|
+
* Set via `setLoggerAttributeFloor(...)` at boot from
|
|
100
|
+
* `instrumentWorker()`. Default empty so the logger is a no-op for sites
|
|
101
|
+
* that don't wire `instrumentWorker`.
|
|
102
|
+
*/
|
|
103
|
+
let attributeFloor: Record<string, unknown> = {};
|
|
104
|
+
|
|
91
105
|
/**
|
|
92
106
|
* Replace the active logger adapter.
|
|
93
107
|
* Call once at worker boot from `instrumentWorker()`.
|
|
@@ -96,6 +110,22 @@ export function configureLogger(adapter: LoggerAdapter): void {
|
|
|
96
110
|
activeAdapter = adapter;
|
|
97
111
|
}
|
|
98
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Replace the per-record attribute floor — keys here will be added to
|
|
115
|
+
* every log line UNLESS the caller passes the same key in their `attrs`
|
|
116
|
+
* (caller wins). Set once at worker boot from `instrumentWorker()`.
|
|
117
|
+
*/
|
|
118
|
+
export function setLoggerAttributeFloor(attrs: Record<string, unknown>): void {
|
|
119
|
+
attributeFloor = { ...attrs };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Test-only: read the current attribute floor. Do not call from app code.
|
|
124
|
+
*/
|
|
125
|
+
export function _getLoggerAttributeFloorForTests(): Record<string, unknown> {
|
|
126
|
+
return { ...attributeFloor };
|
|
127
|
+
}
|
|
128
|
+
|
|
99
129
|
/**
|
|
100
130
|
* Get the current active logger adapter (for tests / advanced wiring).
|
|
101
131
|
*/
|
|
@@ -197,13 +227,21 @@ export function serializeError(err: unknown): SerializedError {
|
|
|
197
227
|
|
|
198
228
|
function emit(level: LogLevel, msg: string, attrs?: Record<string, unknown>): void {
|
|
199
229
|
if (!shouldLog(level)) return;
|
|
230
|
+
// Merge floor → caller attrs so caller can override any floor key.
|
|
231
|
+
// Skipped entirely when the floor is empty so the no-op path stays cheap.
|
|
232
|
+
const merged: Record<string, unknown> | undefined =
|
|
233
|
+
Object.keys(attributeFloor).length === 0
|
|
234
|
+
? attrs
|
|
235
|
+
: attrs
|
|
236
|
+
? { ...attributeFloor, ...attrs }
|
|
237
|
+
: { ...attributeFloor };
|
|
200
238
|
try {
|
|
201
|
-
activeAdapter.log(level, msg,
|
|
239
|
+
activeAdapter.log(level, msg, merged);
|
|
202
240
|
} catch {
|
|
203
241
|
// Adapter blew up. Fall back to default so we don't lose the line.
|
|
204
242
|
if (activeAdapter !== defaultLoggerAdapter) {
|
|
205
243
|
try {
|
|
206
|
-
defaultLoggerAdapter.log(level, msg,
|
|
244
|
+
defaultLoggerAdapter.log(level, msg, merged);
|
|
207
245
|
} catch {
|
|
208
246
|
/* swallow */
|
|
209
247
|
}
|
package/src/sdk/otel.test.ts
CHANGED
|
@@ -1,27 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* plain vitest because `@microlabs/otel-cf-workers` imports `cloudflare:workers`,
|
|
4
|
-
* which only exists in the Workers runtime.
|
|
2
|
+
* Coverage for `instrumentWorker` and the public observability surface.
|
|
5
3
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* test suite to `@cloudflare/vitest-pool-workers`, restore the in-process
|
|
11
|
-
* smoke test here.
|
|
12
|
-
*
|
|
13
|
-
* Until then this file just guards the public API shape of the granular
|
|
14
|
-
* modules so refactors that break the export surface fail loudly in CI.
|
|
15
|
-
* (We deliberately don't import `./observability` here — it transitively
|
|
16
|
-
* pulls in `@microlabs/otel-cf-workers` and therefore `cloudflare:workers`.)
|
|
4
|
+
* As of 4.4.0 the framework no longer wraps with `@microlabs/otel-cf-workers`
|
|
5
|
+
* (`cloudflare:workers`-only), so importing `./otel` and `./observability`
|
|
6
|
+
* works in plain vitest. Earlier versions of this file documented that
|
|
7
|
+
* constraint — it's gone.
|
|
17
8
|
*/
|
|
18
|
-
import { describe, expect, it } from "vitest";
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
19
10
|
import * as observability from "../middleware/observability";
|
|
20
11
|
import * as composite from "./composite";
|
|
21
12
|
import * as logger from "./logger";
|
|
13
|
+
import { _resetBootStateForTests, instrumentWorker } from "./otel";
|
|
22
14
|
import * as adapters from "./otelAdapters";
|
|
23
15
|
import * as sampler from "./sampler";
|
|
24
16
|
|
|
17
|
+
interface TestEnv extends Record<string, unknown> {
|
|
18
|
+
DECO_SITE_NAME?: string;
|
|
19
|
+
OTEL_EXPORTER_OTLP_ENDPOINT?: string;
|
|
20
|
+
OTEL_EXPORTER_OTLP_HEADERS?: string;
|
|
21
|
+
DECO_METRICS?: { writeDataPoint: () => void };
|
|
22
|
+
CF_VERSION_METADATA?: { id: string };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function fakeCtx() {
|
|
26
|
+
const waited: Array<Promise<unknown>> = [];
|
|
27
|
+
return {
|
|
28
|
+
waitUntil: (p: Promise<unknown>) => {
|
|
29
|
+
waited.push(p);
|
|
30
|
+
},
|
|
31
|
+
passThroughOnException: () => {},
|
|
32
|
+
waited,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
25
36
|
describe("observability granular modules", () => {
|
|
26
37
|
it("exports the logger surface", () => {
|
|
27
38
|
expect(typeof logger.logger.info).toBe("function");
|
|
@@ -41,6 +52,8 @@ describe("observability granular modules", () => {
|
|
|
41
52
|
expect(typeof adapters.createAnalyticsEngineMeterAdapter).toBe("function");
|
|
42
53
|
expect(typeof adapters.setRuntimeEnv).toBe("function");
|
|
43
54
|
expect(typeof adapters.getRuntimeEnv).toBe("function");
|
|
55
|
+
expect(typeof adapters.flushOtelProviders).toBe("function");
|
|
56
|
+
expect(typeof adapters.registerOtelFlushHandler).toBe("function");
|
|
44
57
|
});
|
|
45
58
|
|
|
46
59
|
it("exports sampler API", () => {
|
|
@@ -57,3 +70,103 @@ describe("observability granular modules", () => {
|
|
|
57
70
|
expect(observability.MetricNames.RESOLVE_DURATION_MS).toBe("resolve_duration_ms");
|
|
58
71
|
});
|
|
59
72
|
});
|
|
73
|
+
|
|
74
|
+
describe("instrumentWorker — CF-native default boot", () => {
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
_resetBootStateForTests();
|
|
77
|
+
adapters._resetFlushHandlersForTests();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
afterEach(() => {
|
|
81
|
+
_resetBootStateForTests();
|
|
82
|
+
adapters._resetFlushHandlersForTests();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("default mode (no opts, only OTLP endpoint set): wires meter flush only, NOT logger flush", async () => {
|
|
86
|
+
const handler = { fetch: vi.fn().mockResolvedValue(new Response("ok")) };
|
|
87
|
+
const wrapped = instrumentWorker(handler);
|
|
88
|
+
|
|
89
|
+
const env: TestEnv = {
|
|
90
|
+
OTEL_EXPORTER_OTLP_ENDPOINT: "https://in-otel.hyperdx.io",
|
|
91
|
+
OTEL_EXPORTER_OTLP_HEADERS: "authorization=Bearer test",
|
|
92
|
+
};
|
|
93
|
+
const ctx = fakeCtx();
|
|
94
|
+
await wrapped.fetch(new Request("https://example.test/"), env, ctx);
|
|
95
|
+
|
|
96
|
+
// Exactly one provider registered: the OTLP meter. The OTLP logger is
|
|
97
|
+
// gated behind `enableAppSideOtlpLogs` opt-in and CF handles log export.
|
|
98
|
+
expect(adapters._getFlushHandlerCountForTests()).toBe(1);
|
|
99
|
+
expect(handler.fetch).toHaveBeenCalledOnce();
|
|
100
|
+
expect(ctx.waited).toHaveLength(1);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("default mode without OTLP endpoint: no flush handlers registered (pure CF-native)", async () => {
|
|
104
|
+
const handler = { fetch: vi.fn().mockResolvedValue(new Response("ok")) };
|
|
105
|
+
const wrapped = instrumentWorker(handler);
|
|
106
|
+
|
|
107
|
+
const env: TestEnv = {};
|
|
108
|
+
const ctx = fakeCtx();
|
|
109
|
+
await wrapped.fetch(new Request("https://example.test/"), env, ctx);
|
|
110
|
+
|
|
111
|
+
expect(adapters._getFlushHandlerCountForTests()).toBe(0);
|
|
112
|
+
// ctx.waitUntil still called with the no-op flush, but the handler array is empty.
|
|
113
|
+
expect(ctx.waited).toHaveLength(1);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("opt-in mode (enableAppSideOtlpLogs: true): wires BOTH logger and meter flush", async () => {
|
|
117
|
+
const handler = { fetch: vi.fn().mockResolvedValue(new Response("ok")) };
|
|
118
|
+
const wrapped = instrumentWorker(handler, { enableAppSideOtlpLogs: true });
|
|
119
|
+
|
|
120
|
+
const env: TestEnv = {
|
|
121
|
+
OTEL_EXPORTER_OTLP_ENDPOINT: "https://in-otel.hyperdx.io",
|
|
122
|
+
OTEL_EXPORTER_OTLP_HEADERS: "authorization=Bearer test",
|
|
123
|
+
};
|
|
124
|
+
const ctx = fakeCtx();
|
|
125
|
+
await wrapped.fetch(new Request("https://example.test/"), env, ctx);
|
|
126
|
+
|
|
127
|
+
expect(adapters._getFlushHandlerCountForTests()).toBe(2);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("flush is awaited via ctx.waitUntil even when fetch throws", async () => {
|
|
131
|
+
const handler = { fetch: vi.fn().mockRejectedValue(new Error("boom")) };
|
|
132
|
+
const wrapped = instrumentWorker(handler);
|
|
133
|
+
|
|
134
|
+
const env: TestEnv = {};
|
|
135
|
+
const ctx = fakeCtx();
|
|
136
|
+
await expect(wrapped.fetch(new Request("https://example.test/"), env, ctx)).rejects.toThrow(
|
|
137
|
+
"boom",
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
expect(ctx.waited).toHaveLength(1);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("boot is idempotent across requests: flush handler count stable", async () => {
|
|
144
|
+
const handler = { fetch: vi.fn().mockResolvedValue(new Response("ok")) };
|
|
145
|
+
const wrapped = instrumentWorker(handler);
|
|
146
|
+
|
|
147
|
+
const env: TestEnv = {
|
|
148
|
+
OTEL_EXPORTER_OTLP_ENDPOINT: "https://in-otel.hyperdx.io",
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
for (let i = 0; i < 3; i++) {
|
|
152
|
+
await wrapped.fetch(new Request("https://example.test/"), env, fakeCtx());
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
expect(adapters._getFlushHandlerCountForTests()).toBe(1);
|
|
156
|
+
expect(handler.fetch).toHaveBeenCalledTimes(3);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("AE meter is wired without OTLP when DECO_METRICS binding is present", async () => {
|
|
160
|
+
const writeDataPoint = vi.fn();
|
|
161
|
+
const handler = { fetch: vi.fn().mockResolvedValue(new Response("ok")) };
|
|
162
|
+
const wrapped = instrumentWorker(handler);
|
|
163
|
+
|
|
164
|
+
const env: TestEnv = { DECO_METRICS: { writeDataPoint } };
|
|
165
|
+
const ctx = fakeCtx();
|
|
166
|
+
await wrapped.fetch(new Request("https://example.test/"), env, ctx);
|
|
167
|
+
|
|
168
|
+
// AE adapter doesn't register a flush (writeDataPoint is fire-and-forget),
|
|
169
|
+
// so no providers should be registered when only AE is wired.
|
|
170
|
+
expect(adapters._getFlushHandlerCountForTests()).toBe(0);
|
|
171
|
+
});
|
|
172
|
+
});
|
package/src/sdk/otel.ts
CHANGED
|
@@ -2,47 +2,66 @@
|
|
|
2
2
|
* Single observability entry point for `@decocms/start` on Cloudflare Workers.
|
|
3
3
|
*
|
|
4
4
|
* `instrumentWorker(handler, options)` wraps a Worker handler with:
|
|
5
|
-
* - structured JSON logger (stdout → Cloudflare Logs
|
|
6
|
-
* - OTLP/HTTP logs exporter (HyperDX) — when `OTEL_EXPORTER_OTLP_ENDPOINT` is set
|
|
7
|
-
* - OTLP/HTTP metrics exporter (HyperDX) — same condition
|
|
5
|
+
* - structured JSON logger (stdout → Cloudflare Workers Logs) — always
|
|
8
6
|
* - Workers Analytics Engine metrics — when `env.DECO_METRICS` binding exists
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
11
|
-
* -
|
|
12
|
-
*
|
|
13
|
-
*
|
|
7
|
+
* - OTLP/HTTP metrics exporter (HyperDX) — when `OTEL_EXPORTER_OTLP_ENDPOINT`
|
|
8
|
+
* is set (CF doesn't support OTLP metrics export yet, so this stays app-side)
|
|
9
|
+
* - Per-request `ctx.waitUntil(forceFlush)` for any registered OTel batch
|
|
10
|
+
* processors so log/metric batches don't die with the isolate
|
|
11
|
+
* - Bridges framework-internal `withTracing()` calls onto the global
|
|
12
|
+
* `@opentelemetry/api` tracer, stamping `deco.*` attributes on every span
|
|
13
|
+
* so they survive Cloudflare's platform-managed trace export
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
* **Logs and traces export to HyperDX is now handled by Cloudflare** via the
|
|
16
|
+
* `observability.{logs,traces}.destinations` block in `wrangler.jsonc`. CF
|
|
17
|
+
* captures `console.*` output and `@opentelemetry/api` global tracer spans
|
|
18
|
+
* out-of-band and ships them OTLP-encoded to whatever destination is
|
|
19
|
+
* configured. This eliminates the in-Worker exporter SDK, the per-request
|
|
20
|
+
* subrequest cost of pushing OTLP, and the entire class of bug PR #153 fixed
|
|
21
|
+
* (batch processors that never flush before isolate recycling).
|
|
17
22
|
*
|
|
18
23
|
* @example
|
|
19
24
|
* ```ts
|
|
25
|
+
* // worker-entry.ts
|
|
20
26
|
* import { createDecoWorkerEntry } from "@decocms/start/sdk/workerEntry";
|
|
21
27
|
* import { instrumentWorker } from "@decocms/start/sdk/otel";
|
|
22
28
|
*
|
|
23
29
|
* const handler = createDecoWorkerEntry(serverEntry, options);
|
|
24
|
-
*
|
|
25
30
|
* export default instrumentWorker(handler, { serviceName: "my-store" });
|
|
26
31
|
* ```
|
|
27
32
|
*
|
|
28
|
-
*
|
|
33
|
+
* Companion `wrangler.jsonc` block (run `scripts/migrate-to-cf-observability.ts`
|
|
34
|
+
* to inject this automatically):
|
|
29
35
|
* ```jsonc
|
|
30
|
-
* "
|
|
31
|
-
*
|
|
36
|
+
* "observability": {
|
|
37
|
+
* "logs": { "enabled": true, "destinations": ["hyperdx-logs"],
|
|
38
|
+
* "head_sampling_rate": 1.0, "persist": false },
|
|
39
|
+
* "traces": { "enabled": true, "destinations": ["hyperdx-traces"],
|
|
40
|
+
* "head_sampling_rate": 0.1, "persist": false }
|
|
41
|
+
* },
|
|
42
|
+
* "version_metadata": { "binding": "CF_VERSION_METADATA" },
|
|
43
|
+
* "analytics_engine_datasets": [{ "binding": "DECO_METRICS",
|
|
44
|
+
* "dataset": "deco_metrics_my_site" }]
|
|
32
45
|
* ```
|
|
33
46
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
47
|
+
* **Back-compat seam.** Sites that need to keep app-side OTLP log export
|
|
48
|
+
* (custom destination not covered by CF, custom batching, etc.) can opt back
|
|
49
|
+
* in with `enableAppSideOtlpLogs: true` and the existing `OTEL_EXPORTER_OTLP_*`
|
|
50
|
+
* secrets. Slated for removal in 5.0.0.
|
|
37
51
|
*/
|
|
38
52
|
|
|
39
|
-
import { instrument, type ResolveConfigFn } from "@microlabs/otel-cf-workers";
|
|
40
53
|
import { trace } from "@opentelemetry/api";
|
|
41
54
|
import { type Resource, resourceFromAttributes } from "@opentelemetry/resources";
|
|
42
55
|
|
|
43
56
|
import { configureMeter, configureTracer } from "../middleware/observability";
|
|
44
57
|
import { createCompositeLogger, createCompositeMeter } from "./composite";
|
|
45
|
-
import {
|
|
58
|
+
import {
|
|
59
|
+
configureLogger,
|
|
60
|
+
defaultLoggerAdapter,
|
|
61
|
+
type LogLevel,
|
|
62
|
+
logger,
|
|
63
|
+
setLoggerAttributeFloor,
|
|
64
|
+
} from "./logger";
|
|
46
65
|
import {
|
|
47
66
|
createAnalyticsEngineMeterAdapter,
|
|
48
67
|
createOtelLoggerAdapter,
|
|
@@ -51,7 +70,6 @@ import {
|
|
|
51
70
|
setRuntimeEnv,
|
|
52
71
|
} from "./otelAdapters";
|
|
53
72
|
import { RequestContext } from "./requestContext";
|
|
54
|
-
import { createUrlBasedHeadSampler, decodeSamplingConfig } from "./sampler";
|
|
55
73
|
|
|
56
74
|
const VALID_LOG_LEVELS: readonly LogLevel[] = ["debug", "info", "warn", "error"];
|
|
57
75
|
|
|
@@ -79,20 +97,38 @@ export interface OtelOptions {
|
|
|
79
97
|
/** Push interval for OTLP metrics, in ms. Defaults to env.OTEL_EXPORT_INTERVAL or 60_000. */
|
|
80
98
|
metricsExportIntervalMillis?: number;
|
|
81
99
|
/**
|
|
82
|
-
* Minimum severity to forward to OTLP
|
|
83
|
-
*
|
|
84
|
-
*
|
|
100
|
+
* Minimum severity to forward to the **app-side OTLP** logger (only
|
|
101
|
+
* relevant when `enableAppSideOtlpLogs: true`). The default `console.*`
|
|
102
|
+
* adapter is unaffected and continues to capture every level for
|
|
103
|
+
* Cloudflare Workers Logs / CF-side OTLP export.
|
|
85
104
|
*
|
|
86
105
|
* Defaults to `"warn"`. Falls back to env `OTEL_LOG_MIN_SEVERITY` when
|
|
87
106
|
* unset. Set to `"debug"` to forward everything.
|
|
88
107
|
*/
|
|
89
108
|
otlpMinSeverity?: LogLevel;
|
|
90
109
|
/**
|
|
91
|
-
*
|
|
110
|
+
* Opt-in: also wire an in-Worker OTLP logger that pushes log records to
|
|
111
|
+
* `OTEL_EXPORTER_OTLP_ENDPOINT`. Defaults to `false` — sites should
|
|
112
|
+
* prefer the platform-managed CF-side path
|
|
113
|
+
* (`observability.logs.destinations` in `wrangler.jsonc`), which is
|
|
114
|
+
* cheaper, has no flush-bug class, and consumes zero subrequest budget.
|
|
115
|
+
*
|
|
116
|
+
* Use this only when CF's OTLP logs export doesn't meet a specific need
|
|
117
|
+
* (e.g. shipping to a destination CF doesn't support, custom batching,
|
|
118
|
+
* staging-only debugging). Requires `OTEL_EXPORTER_OTLP_ENDPOINT` and
|
|
119
|
+
* `OTEL_EXPORTER_OTLP_HEADERS` to be set.
|
|
120
|
+
*
|
|
121
|
+
* Slated for removal in 5.0.0.
|
|
122
|
+
*/
|
|
123
|
+
enableAppSideOtlpLogs?: boolean;
|
|
124
|
+
/**
|
|
125
|
+
* Version of `@decocms/start` to advertise as `deco.runtime.version`
|
|
126
|
+
* on every span (CF doesn't preserve it as a resource attribute since
|
|
127
|
+
* we no longer ship our own resource — we stamp it per-span instead).
|
|
92
128
|
* Falls back to a build-time constant; override only for tests.
|
|
93
129
|
*/
|
|
94
130
|
decoRuntimeVersion?: string;
|
|
95
|
-
/** Optional `@decocms/apps` version,
|
|
131
|
+
/** Optional `@decocms/apps` version, stamped as `deco.apps.version` on every span. */
|
|
96
132
|
decoAppsVersion?: string;
|
|
97
133
|
}
|
|
98
134
|
|
|
@@ -124,25 +160,59 @@ interface BootState {
|
|
|
124
160
|
|
|
125
161
|
let bootState: BootState | null = null;
|
|
126
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Per-span attribute floor — stamped on every span we create via
|
|
165
|
+
* `configureTracer().startSpan(...)`. These match what the legacy resource
|
|
166
|
+
* attributes used to carry (when `@microlabs/otel-cf-workers` shipped its
|
|
167
|
+
* own OTel `Resource`); we now stamp them on each span so HyperDX panels
|
|
168
|
+
* filtering on `deco.runtime.version`, `deco.apps.version`, or
|
|
169
|
+
* `deployment.environment` keep working with CF-managed export, which only
|
|
170
|
+
* preserves CF's own resource attribute set (`service.name`, `faas.name`,
|
|
171
|
+
* `cloudflare.script_version.id`, etc.).
|
|
172
|
+
*
|
|
173
|
+
* Populated by `bootObservability` before any span is created. Stays an
|
|
174
|
+
* empty object until then so early span creation is a no-op stamp.
|
|
175
|
+
*/
|
|
176
|
+
let spanAttributeFloor: Record<string, string> = {};
|
|
177
|
+
|
|
127
178
|
// ---------------------------------------------------------------------------
|
|
128
179
|
// instrumentWorker
|
|
129
180
|
// ---------------------------------------------------------------------------
|
|
130
181
|
|
|
131
182
|
/**
|
|
132
|
-
* Wraps a Cloudflare Worker handler with the
|
|
133
|
-
*
|
|
134
|
-
*
|
|
183
|
+
* Wraps a Cloudflare Worker handler with the @decocms/start observability
|
|
184
|
+
* stack:
|
|
185
|
+
* - structured JSON logger (always)
|
|
186
|
+
* - AE meter (when `DECO_METRICS` binding present)
|
|
187
|
+
* - optional app-side OTLP meter (when `OTEL_EXPORTER_OTLP_ENDPOINT` set)
|
|
188
|
+
* - optional app-side OTLP logger (when `enableAppSideOtlpLogs: true`)
|
|
189
|
+
* - per-request `ctx.waitUntil(forceFlush)` for any registered batch processors
|
|
190
|
+
* - bridge from framework-internal `withTracing()` to `@opentelemetry/api`
|
|
191
|
+
* global tracer, with `deco.*` attributes stamped on every span
|
|
192
|
+
*
|
|
193
|
+
* Logs and traces export to HyperDX (or any OTLP destination) is handled
|
|
194
|
+
* by Cloudflare via `observability.{logs,traces}.destinations` in
|
|
195
|
+
* `wrangler.jsonc`. This wrapper does NOT call `@microlabs/otel-cf-workers`
|
|
196
|
+
* `instrument()` — CF's platform-managed export captures `console.*` output
|
|
197
|
+
* and global-tracer spans out-of-band.
|
|
135
198
|
*/
|
|
136
199
|
export function instrumentWorker(
|
|
137
200
|
handler: WorkerHandler,
|
|
138
201
|
options: OtelOptions | ((env: Record<string, unknown>) => OtelOptions) = {},
|
|
139
202
|
): WorkerHandler {
|
|
140
|
-
// Bridge our pluggable TracerAdapter onto @opentelemetry/api
|
|
141
|
-
//
|
|
142
|
-
//
|
|
203
|
+
// Bridge our pluggable TracerAdapter onto @opentelemetry/api. Framework
|
|
204
|
+
// code calls `withTracing("name", fn, { attr: val })`; that delegates here
|
|
205
|
+
// and lands on `trace.getTracer("@decocms/start").startSpan(...)`.
|
|
206
|
+
//
|
|
207
|
+
// CF Workers Tracing (when `observability.traces.enabled = true` in
|
|
208
|
+
// wrangler) installs its own TracerProvider into the @opentelemetry/api
|
|
209
|
+
// global, so these spans flow through to whatever OTLP destination is
|
|
210
|
+
// configured. Without CF tracing the global tracer is a no-op proxy and
|
|
211
|
+
// the spans simply drop — same outcome as before, no error.
|
|
143
212
|
configureTracer({
|
|
144
213
|
startSpan: (name, attrs) => {
|
|
145
|
-
const
|
|
214
|
+
const merged = { ...spanAttributeFloor, ...(attrs ?? {}) };
|
|
215
|
+
const span = trace.getTracer("@decocms/start").startSpan(name, { attributes: merged });
|
|
146
216
|
return {
|
|
147
217
|
end: () => span.end(),
|
|
148
218
|
setError: (error) => {
|
|
@@ -153,64 +223,32 @@ export function instrumentWorker(
|
|
|
153
223
|
},
|
|
154
224
|
});
|
|
155
225
|
|
|
156
|
-
|
|
157
|
-
const opts = typeof options === "function" ? options(env as Record<string, unknown>) : options;
|
|
158
|
-
bootObservability(opts, env as Record<string, unknown>);
|
|
159
|
-
|
|
160
|
-
const state = bootState!;
|
|
161
|
-
|
|
162
|
-
// Sampling — base64 JSON via OTEL_SAMPLING_CONFIG, see sdk/sampler.ts
|
|
163
|
-
const samplingConfig = decodeSamplingConfig(env.OTEL_SAMPLING_CONFIG as string | undefined);
|
|
164
|
-
const headSampler = createUrlBasedHeadSampler(samplingConfig);
|
|
165
|
-
|
|
166
|
-
// microlabs requires an exporter even when we only want internal
|
|
167
|
-
// tracing. When OTLP isn't configured, we still set up a no-op
|
|
168
|
-
// collector — the URL we'd never reach so spans simply drop.
|
|
169
|
-
const exporterUrl = state.otlpEndpoint ?? "http://127.0.0.1:0/v1/traces";
|
|
170
|
-
|
|
171
|
-
return {
|
|
172
|
-
exporter: {
|
|
173
|
-
url: joinPath(exporterUrl, "/v1/traces"),
|
|
174
|
-
headers: state.otlpHeaders,
|
|
175
|
-
},
|
|
176
|
-
service: {
|
|
177
|
-
name: state.serviceName,
|
|
178
|
-
version: (env.CF_VERSION_METADATA as { id?: string } | undefined)?.id,
|
|
179
|
-
},
|
|
180
|
-
sampling: { headSampler },
|
|
181
|
-
// microlabs auto-instruments globalThis.fetch + KV + waitUntil.
|
|
182
|
-
instrumentation: {
|
|
183
|
-
instrumentGlobalFetch: true,
|
|
184
|
-
instrumentGlobalCache: true,
|
|
185
|
-
},
|
|
186
|
-
};
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
const innerHandler: WorkerHandler = {
|
|
226
|
+
return {
|
|
190
227
|
async fetch(request, env, ctx) {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
228
|
+
const opts =
|
|
229
|
+
typeof options === "function" ? options(env as Record<string, unknown>) : options;
|
|
230
|
+
bootObservability(opts, env as Record<string, unknown>);
|
|
231
|
+
|
|
232
|
+
// Stash env so request-scoped adapters (AE) can resolve their
|
|
233
|
+
// bindings. Done inside RequestContext.run wrapping in workerEntry.ts
|
|
234
|
+
// too, but we re-stash here in case `instrumentWorker` is wrapped
|
|
235
|
+
// over a handler that doesn't go through `createDecoWorkerEntry`.
|
|
195
236
|
const wrap = async () => {
|
|
196
237
|
setRuntimeEnv(env);
|
|
197
238
|
return handler.fetch(request, env, ctx);
|
|
198
239
|
};
|
|
199
240
|
|
|
200
241
|
try {
|
|
201
|
-
// RequestContext may already be active (createDecoWorkerEntry sets it
|
|
202
|
-
// up). If so, run inline; otherwise wrap. Cheap to detect via current.
|
|
203
242
|
if (RequestContext.current) {
|
|
204
243
|
return await wrap();
|
|
205
244
|
}
|
|
206
245
|
return await RequestContext.run(request, wrap);
|
|
207
246
|
} finally {
|
|
208
|
-
// Drain OTLP
|
|
209
|
-
// the
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
// OTLP isn't configured, so this is safe in every code path.
|
|
247
|
+
// Drain OTLP meter (and OTLP logger, if `enableAppSideOtlpLogs`)
|
|
248
|
+
// batches inside the post-response window `waitUntil` guarantees.
|
|
249
|
+
// Without this hook, `PeriodicExportingMetricReader` (60s flush)
|
|
250
|
+
// batches usually die with the isolate before the timer fires.
|
|
251
|
+
// No-op when no batch processors are registered.
|
|
214
252
|
try {
|
|
215
253
|
ctx.waitUntil(flushOtelProviders());
|
|
216
254
|
} catch {
|
|
@@ -220,9 +258,6 @@ export function instrumentWorker(
|
|
|
220
258
|
}
|
|
221
259
|
},
|
|
222
260
|
};
|
|
223
|
-
|
|
224
|
-
// deno-lint-ignore no-explicit-any
|
|
225
|
-
return instrument(innerHandler as any, resolveConfig) as unknown as WorkerHandler;
|
|
226
261
|
}
|
|
227
262
|
|
|
228
263
|
// ---------------------------------------------------------------------------
|
|
@@ -239,21 +274,57 @@ function bootObservability(opts: OtelOptions, env: Record<string, unknown>): voi
|
|
|
239
274
|
const otlpHeaders =
|
|
240
275
|
opts.headers ?? parseHeaders(env.OTEL_EXPORTER_OTLP_HEADERS as string | undefined);
|
|
241
276
|
|
|
277
|
+
const decoRuntimeVersion = opts.decoRuntimeVersion ?? DECO_RUNTIME_VERSION;
|
|
278
|
+
const deploymentEnvironment = (env.DECO_ENV_NAME as string | undefined) ?? "production";
|
|
279
|
+
|
|
242
280
|
const resource = buildResource({
|
|
243
281
|
serviceName,
|
|
244
282
|
serviceVersion: (env.CF_VERSION_METADATA as { id?: string } | undefined)?.id,
|
|
245
283
|
serviceInstanceId: cryptoRandomId(),
|
|
246
|
-
deploymentEnvironment
|
|
247
|
-
decoRuntimeVersion
|
|
284
|
+
deploymentEnvironment,
|
|
285
|
+
decoRuntimeVersion,
|
|
248
286
|
decoAppsVersion: opts.decoAppsVersion,
|
|
249
287
|
});
|
|
250
288
|
|
|
289
|
+
// Stamp deco.* attributes on every span we create. CF-managed trace
|
|
290
|
+
// export emits its own resource attribute set (service.name=Worker name,
|
|
291
|
+
// faas.name, cloudflare.script_version.id, faas.version, etc.) so the
|
|
292
|
+
// legacy resource attrs from `buildResource` don't survive on the
|
|
293
|
+
// CF-side path. Stamping them per-span preserves the dimensions
|
|
294
|
+
// existing HyperDX dashboards filter on.
|
|
295
|
+
spanAttributeFloor = {
|
|
296
|
+
"deco.runtime.version": decoRuntimeVersion,
|
|
297
|
+
"deployment.environment": deploymentEnvironment,
|
|
298
|
+
...(opts.decoAppsVersion ? { "deco.apps.version": opts.decoAppsVersion } : {}),
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
// Same set, stamped on every log record. CF Workers Logs ships the JSON
|
|
302
|
+
// body verbatim (resource attrs from `buildResource` are NOT applied to
|
|
303
|
+
// logs in default mode), so HyperDX panels grouping by these dimensions
|
|
304
|
+
// would otherwise return empty. Caller-supplied `attrs` still win on key
|
|
305
|
+
// collision.
|
|
306
|
+
setLoggerAttributeFloor({
|
|
307
|
+
"deco.runtime.version": decoRuntimeVersion,
|
|
308
|
+
"deployment.environment": deploymentEnvironment,
|
|
309
|
+
...(opts.decoAppsVersion ? { "deco.apps.version": opts.decoAppsVersion } : {}),
|
|
310
|
+
});
|
|
311
|
+
|
|
251
312
|
// ---- Logger ----------------------------------------------------------
|
|
313
|
+
// Default mode: console JSON only. Cloudflare Workers Logs captures the
|
|
314
|
+
// output and ships it via `observability.logs.destinations` to whichever
|
|
315
|
+
// OTLP destination is configured in `wrangler.jsonc`. This is the
|
|
316
|
+
// recommended path: zero in-Worker exporter, no flush bug, no subrequest
|
|
317
|
+
// cost per emit.
|
|
318
|
+
//
|
|
319
|
+
// Opt-in mode (`enableAppSideOtlpLogs: true`): also wire the OTLP logger
|
|
320
|
+
// adapter for sites with destinations CF doesn't support. Requires
|
|
321
|
+
// `OTEL_EXPORTER_OTLP_ENDPOINT` to be set.
|
|
252
322
|
const otlpMinSeverity =
|
|
253
323
|
opts.otlpMinSeverity ?? parseLogLevel(env.OTEL_LOG_MIN_SEVERITY) ?? "warn";
|
|
254
324
|
|
|
325
|
+
const wantAppSideLogs = opts.enableAppSideOtlpLogs === true;
|
|
255
326
|
const otelLogger =
|
|
256
|
-
otlpEndpoint != null
|
|
327
|
+
wantAppSideLogs && otlpEndpoint != null
|
|
257
328
|
? createOtelLoggerAdapter({
|
|
258
329
|
endpoint: otlpEndpoint,
|
|
259
330
|
headers: otlpHeaders,
|
|
@@ -266,6 +337,9 @@ function bootObservability(opts: OtelOptions, env: Record<string, unknown>): voi
|
|
|
266
337
|
configureLogger(createCompositeLogger([defaultLoggerAdapter, otelLogger]));
|
|
267
338
|
|
|
268
339
|
// ---- Meter -----------------------------------------------------------
|
|
340
|
+
// OTLP meter stays default-on when an endpoint is configured: CF doesn't
|
|
341
|
+
// support OTLP metrics export yet, so this is the only path to
|
|
342
|
+
// HyperDX-compatible metrics. Drop this branch when CF ships metrics.
|
|
269
343
|
const otelMeter =
|
|
270
344
|
otlpEndpoint != null
|
|
271
345
|
? createOtelMeterAdapter({
|
|
@@ -295,17 +369,32 @@ function bootObservability(opts: OtelOptions, env: Record<string, unknown>): voi
|
|
|
295
369
|
booted = true;
|
|
296
370
|
|
|
297
371
|
// Single boot-time breadcrumb so operators can confirm the wiring at a
|
|
298
|
-
// glance from CF Logs without enabling debug.
|
|
372
|
+
// glance from CF Logs without enabling debug. Surfaces which export
|
|
373
|
+
// mode is active (CF-native vs app-side) so misconfigured sites are
|
|
374
|
+
// obvious from the first request.
|
|
299
375
|
logger.info("observability booted", {
|
|
300
376
|
service: serviceName,
|
|
301
|
-
|
|
302
|
-
|
|
377
|
+
mode: wantAppSideLogs ? "hybrid (app-side OTLP logs + CF traces)" : "cf-native",
|
|
378
|
+
otlpMeter: Boolean(otlpEndpoint),
|
|
379
|
+
otlpLogger: wantAppSideLogs && otlpEndpoint != null,
|
|
380
|
+
otlpMinSeverity: wantAppSideLogs && otlpEndpoint != null ? otlpMinSeverity : null,
|
|
303
381
|
analyticsEngine: aeEnabled,
|
|
304
|
-
|
|
305
|
-
|
|
382
|
+
runtimeVersion: decoRuntimeVersion,
|
|
383
|
+
deploymentEnvironment,
|
|
306
384
|
});
|
|
307
385
|
}
|
|
308
386
|
|
|
387
|
+
/**
|
|
388
|
+
* Test-only: clear boot state so successive tests can re-boot
|
|
389
|
+
* `instrumentWorker` with different options. Do not call from app code.
|
|
390
|
+
*/
|
|
391
|
+
export function _resetBootStateForTests(): void {
|
|
392
|
+
booted = false;
|
|
393
|
+
bootState = null;
|
|
394
|
+
spanAttributeFloor = {};
|
|
395
|
+
setLoggerAttributeFloor({});
|
|
396
|
+
}
|
|
397
|
+
|
|
309
398
|
// ---------------------------------------------------------------------------
|
|
310
399
|
// Resource attributes
|
|
311
400
|
// ---------------------------------------------------------------------------
|
|
@@ -345,7 +434,7 @@ function buildResource(input: ResourceInput): Resource {
|
|
|
345
434
|
* Drift is acceptable — this attribute is for operator triage, not for
|
|
346
435
|
* billing / SLOs.
|
|
347
436
|
*/
|
|
348
|
-
const DECO_RUNTIME_VERSION = "
|
|
437
|
+
const DECO_RUNTIME_VERSION = "4.4.0";
|
|
349
438
|
|
|
350
439
|
function parseHeaders(str?: string): Record<string, string> {
|
|
351
440
|
if (!str) return {};
|
|
@@ -365,14 +454,6 @@ function numericEnv(value: unknown, fallback: number): number {
|
|
|
365
454
|
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
366
455
|
}
|
|
367
456
|
|
|
368
|
-
function joinPath(base: string, path: string): string {
|
|
369
|
-
if (!base) return path;
|
|
370
|
-
if (base.endsWith("/")) base = base.slice(0, -1);
|
|
371
|
-
if (!path.startsWith("/")) path = "/" + path;
|
|
372
|
-
if (base.toLowerCase().endsWith(path.toLowerCase())) return base;
|
|
373
|
-
return base + path;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
457
|
function cryptoRandomId(): string {
|
|
377
458
|
// crypto.randomUUID is universally available in CF Workers + Node 19+.
|
|
378
459
|
try {
|