@decocms/start 2.28.1 → 2.29.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/README.md +178 -79
- package/package.json +16 -14
- package/src/cms/resolve.ts +81 -63
- package/src/cms/sectionLoaders.ts +11 -0
- package/src/index.ts +3 -0
- package/src/sdk/cachedLoader.ts +36 -13
- package/src/sdk/composite.test.ts +121 -0
- package/src/sdk/composite.ts +114 -0
- package/src/sdk/instrumentedFetch.ts +56 -0
- package/src/sdk/logger.test.ts +135 -0
- package/src/sdk/logger.ts +166 -0
- package/src/sdk/observability.ts +75 -0
- package/src/sdk/otel.test.ts +59 -0
- package/src/sdk/otel.ts +270 -29
- package/src/sdk/otelAdapters.test.ts +135 -0
- package/src/sdk/otelAdapters.ts +401 -0
- package/src/sdk/sampler.test.ts +127 -0
- package/src/sdk/sampler.ts +183 -0
- package/src/sdk/workerEntry.ts +541 -476
- package/src/vite/plugin.js +6 -3
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pluggable structured logger for @decocms/start.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the public shape of `@deco/deco/o11y` logger so site code that
|
|
5
|
+
* does `logger.info("...", { key: "value" })` keeps working unchanged
|
|
6
|
+
* after the Fresh → TanStack migration.
|
|
7
|
+
*
|
|
8
|
+
* Backed by a `LoggerAdapter`. The default adapter writes one JSON line
|
|
9
|
+
* to `console.log` per call — that line is what Cloudflare Logs / Logpush
|
|
10
|
+
* captures, so logging works out of the box on Workers without any
|
|
11
|
+
* additional configuration.
|
|
12
|
+
*
|
|
13
|
+
* To dual-emit to OTLP (HyperDX, etc.), wrap the default with
|
|
14
|
+
* `createCompositeLogger([defaultLoggerAdapter, otelLoggerAdapter])`
|
|
15
|
+
* inside `instrumentWorker()`.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* import { logger } from "@decocms/start/sdk/logger";
|
|
20
|
+
*
|
|
21
|
+
* logger.info("checkout started", { orderFormId, items: cart.items.length });
|
|
22
|
+
* logger.warn("retrying vtex call", { attempt, host });
|
|
23
|
+
* logger.error("payment failed", { reason, code, traceId });
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
28
|
+
|
|
29
|
+
const LEVEL_RANK: Record<LogLevel, number> = {
|
|
30
|
+
debug: 10,
|
|
31
|
+
info: 20,
|
|
32
|
+
warn: 30,
|
|
33
|
+
error: 40,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export interface LoggerAdapter {
|
|
37
|
+
log(level: LogLevel, msg: string, attrs?: Record<string, unknown>): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Default adapter — structured JSON to console.log
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Cloudflare-Logs-friendly default. One JSON object per call, on stdout.
|
|
46
|
+
* Always safe to use — never throws, never depends on env, never makes
|
|
47
|
+
* a network call.
|
|
48
|
+
*/
|
|
49
|
+
export const defaultLoggerAdapter: LoggerAdapter = {
|
|
50
|
+
log(level, msg, attrs) {
|
|
51
|
+
const payload: Record<string, unknown> = {
|
|
52
|
+
level,
|
|
53
|
+
msg,
|
|
54
|
+
timestamp: new Date().toISOString(),
|
|
55
|
+
};
|
|
56
|
+
if (attrs) {
|
|
57
|
+
// Spread last so explicit attrs win over our defaults except
|
|
58
|
+
// for `level` / `msg` / `timestamp` which we always want canonical.
|
|
59
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
60
|
+
if (k !== "level" && k !== "msg" && k !== "timestamp") {
|
|
61
|
+
payload[k] = v;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Route by level so Cloudflare's log dashboard colorises correctly.
|
|
66
|
+
const fn =
|
|
67
|
+
level === "error"
|
|
68
|
+
? console.error
|
|
69
|
+
: level === "warn"
|
|
70
|
+
? console.warn
|
|
71
|
+
: level === "debug"
|
|
72
|
+
? console.debug
|
|
73
|
+
: console.log;
|
|
74
|
+
try {
|
|
75
|
+
fn(JSON.stringify(payload));
|
|
76
|
+
} catch {
|
|
77
|
+
// Last-resort: fall back to plain string. Never crash the request
|
|
78
|
+
// because of a circular-ref or non-serialisable attribute.
|
|
79
|
+
fn(`${level} ${msg}`);
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Configurable global state
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
let activeAdapter: LoggerAdapter = defaultLoggerAdapter;
|
|
89
|
+
let minLevel: LogLevel = "info";
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Replace the active logger adapter.
|
|
93
|
+
* Call once at worker boot from `instrumentWorker()`.
|
|
94
|
+
*/
|
|
95
|
+
export function configureLogger(adapter: LoggerAdapter): void {
|
|
96
|
+
activeAdapter = adapter;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Get the current active logger adapter (for tests / advanced wiring).
|
|
101
|
+
*/
|
|
102
|
+
export function getLoggerAdapter(): LoggerAdapter {
|
|
103
|
+
return activeAdapter;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Set the minimum log level. Calls below this level are dropped before
|
|
108
|
+
* reaching any adapter — useful to silence `debug` in production.
|
|
109
|
+
*
|
|
110
|
+
* Defaults to `info`. Override per environment via `setLogLevel("debug")`
|
|
111
|
+
* or by reading an env var at boot.
|
|
112
|
+
*/
|
|
113
|
+
export function setLogLevel(level: LogLevel): void {
|
|
114
|
+
minLevel = level;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function getLogLevel(): LogLevel {
|
|
118
|
+
return minLevel;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function shouldLog(level: LogLevel): boolean {
|
|
122
|
+
return LEVEL_RANK[level] >= LEVEL_RANK[minLevel];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Public logger surface
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Mirrors `@deco/deco/o11y` logger:
|
|
131
|
+
* - first arg is the message
|
|
132
|
+
* - optional second arg is a flat attributes object
|
|
133
|
+
*
|
|
134
|
+
* Adapters decide the destination (stdout JSON, OTLP, both, …).
|
|
135
|
+
*/
|
|
136
|
+
export interface Logger {
|
|
137
|
+
debug(msg: string, attrs?: Record<string, unknown>): void;
|
|
138
|
+
info(msg: string, attrs?: Record<string, unknown>): void;
|
|
139
|
+
warn(msg: string, attrs?: Record<string, unknown>): void;
|
|
140
|
+
error(msg: string, attrs?: Record<string, unknown>): void;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function emit(level: LogLevel, msg: string, attrs?: Record<string, unknown>): void {
|
|
144
|
+
if (!shouldLog(level)) return;
|
|
145
|
+
try {
|
|
146
|
+
activeAdapter.log(level, msg, attrs);
|
|
147
|
+
} catch {
|
|
148
|
+
// Adapter blew up. Fall back to default so we don't lose the line.
|
|
149
|
+
if (activeAdapter !== defaultLoggerAdapter) {
|
|
150
|
+
try {
|
|
151
|
+
defaultLoggerAdapter.log(level, msg, attrs);
|
|
152
|
+
} catch {
|
|
153
|
+
/* swallow */
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export const logger: Logger = {
|
|
160
|
+
debug: (msg, attrs) => emit("debug", msg, attrs),
|
|
161
|
+
info: (msg, attrs) => emit("info", msg, attrs),
|
|
162
|
+
warn: (msg, attrs) => emit("warn", msg, attrs),
|
|
163
|
+
error: (msg, attrs) => emit("error", msg, attrs),
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
export default logger;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-stop import for everything observability-related in `@decocms/start`.
|
|
3
|
+
*
|
|
4
|
+
* Consumers (sites, apps) should prefer importing from here so future
|
|
5
|
+
* re-organisations don't ripple through every site:
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* import {
|
|
9
|
+
* instrumentWorker,
|
|
10
|
+
* logger,
|
|
11
|
+
* setLogLevel,
|
|
12
|
+
* withTracing,
|
|
13
|
+
* recordRequestMetric,
|
|
14
|
+
* recordCacheMetric,
|
|
15
|
+
* MetricNames,
|
|
16
|
+
* } from "@decocms/start/sdk/observability";
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* The granular modules (`@decocms/start/sdk/logger`, `.../otelAdapters`,
|
|
20
|
+
* `.../sampler`) remain importable for advanced use cases (custom adapters,
|
|
21
|
+
* tests, etc.) but the common path stays here.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// Tracer / meter / request log primitives (re-exported from the middleware)
|
|
25
|
+
export {
|
|
26
|
+
configureMeter,
|
|
27
|
+
configureTracer,
|
|
28
|
+
getActiveSpan,
|
|
29
|
+
getMeter,
|
|
30
|
+
getTracer,
|
|
31
|
+
logRequest,
|
|
32
|
+
type MeterAdapter,
|
|
33
|
+
MetricNames,
|
|
34
|
+
recordCacheMetric,
|
|
35
|
+
recordRequestMetric,
|
|
36
|
+
type Span,
|
|
37
|
+
setSpanAttribute,
|
|
38
|
+
type TracerAdapter,
|
|
39
|
+
withTracing,
|
|
40
|
+
} from "../middleware/observability";
|
|
41
|
+
// Composite helpers (for advanced multi-backend wiring)
|
|
42
|
+
export { createCompositeLogger, createCompositeMeter } from "./composite";
|
|
43
|
+
// Logger surface
|
|
44
|
+
export {
|
|
45
|
+
configureLogger,
|
|
46
|
+
defaultLoggerAdapter,
|
|
47
|
+
getLoggerAdapter,
|
|
48
|
+
getLogLevel,
|
|
49
|
+
type Logger,
|
|
50
|
+
type LoggerAdapter,
|
|
51
|
+
type LogLevel,
|
|
52
|
+
logger,
|
|
53
|
+
setLogLevel,
|
|
54
|
+
} from "./logger";
|
|
55
|
+
// Worker-entry wrapper + adapter wiring
|
|
56
|
+
export { instrumentWorker, type OtelOptions } from "./otel";
|
|
57
|
+
// Adapters (for tests / custom wiring)
|
|
58
|
+
export {
|
|
59
|
+
type AnalyticsEngineMeterAdapterOptions,
|
|
60
|
+
createAnalyticsEngineMeterAdapter,
|
|
61
|
+
createOtelLoggerAdapter,
|
|
62
|
+
createOtelMeterAdapter,
|
|
63
|
+
getRuntimeEnv,
|
|
64
|
+
type OtelLoggerAdapterOptions,
|
|
65
|
+
type OtelMeterAdapterOptions,
|
|
66
|
+
setRuntimeEnv,
|
|
67
|
+
} from "./otelAdapters";
|
|
68
|
+
// Sampler
|
|
69
|
+
export {
|
|
70
|
+
createUrlBasedHeadSampler,
|
|
71
|
+
decodeSamplingConfig,
|
|
72
|
+
type SamplingConfig,
|
|
73
|
+
type SamplingRule,
|
|
74
|
+
URLBasedSampler,
|
|
75
|
+
} from "./sampler";
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NOTE: full `instrumentWorker(...)` integration tests aren't feasible in
|
|
3
|
+
* plain vitest because `@microlabs/otel-cf-workers` imports `cloudflare:workers`,
|
|
4
|
+
* which only exists in the Workers runtime.
|
|
5
|
+
*
|
|
6
|
+
* The wiring is validated end-to-end on the lebiscuit canary (Section 2 of
|
|
7
|
+
* the otel-hyperdx-parity plan): hit the deployed preview, confirm log
|
|
8
|
+
* lines via `wrangler tail`, traces via the HyperDX UI, and AE data points
|
|
9
|
+
* via `wrangler analytics-engine sql`. If we ever migrate the framework
|
|
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`.)
|
|
17
|
+
*/
|
|
18
|
+
import { describe, expect, it } from "vitest";
|
|
19
|
+
import * as observability from "../middleware/observability";
|
|
20
|
+
import * as composite from "./composite";
|
|
21
|
+
import * as logger from "./logger";
|
|
22
|
+
import * as adapters from "./otelAdapters";
|
|
23
|
+
import * as sampler from "./sampler";
|
|
24
|
+
|
|
25
|
+
describe("observability granular modules", () => {
|
|
26
|
+
it("exports the logger surface", () => {
|
|
27
|
+
expect(typeof logger.logger.info).toBe("function");
|
|
28
|
+
expect(typeof logger.configureLogger).toBe("function");
|
|
29
|
+
expect(typeof logger.setLogLevel).toBe("function");
|
|
30
|
+
expect(typeof logger.defaultLoggerAdapter.log).toBe("function");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("exports composite helpers", () => {
|
|
34
|
+
expect(typeof composite.createCompositeLogger).toBe("function");
|
|
35
|
+
expect(typeof composite.createCompositeMeter).toBe("function");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("exports OTel adapter factories", () => {
|
|
39
|
+
expect(typeof adapters.createOtelLoggerAdapter).toBe("function");
|
|
40
|
+
expect(typeof adapters.createOtelMeterAdapter).toBe("function");
|
|
41
|
+
expect(typeof adapters.createAnalyticsEngineMeterAdapter).toBe("function");
|
|
42
|
+
expect(typeof adapters.setRuntimeEnv).toBe("function");
|
|
43
|
+
expect(typeof adapters.getRuntimeEnv).toBe("function");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("exports sampler API", () => {
|
|
47
|
+
expect(typeof sampler.URLBasedSampler).toBe("function");
|
|
48
|
+
expect(typeof sampler.decodeSamplingConfig).toBe("function");
|
|
49
|
+
expect(typeof sampler.createUrlBasedHeadSampler).toBe("function");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("exports observability primitives from middleware/observability", () => {
|
|
53
|
+
expect(typeof observability.withTracing).toBe("function");
|
|
54
|
+
expect(typeof observability.recordRequestMetric).toBe("function");
|
|
55
|
+
expect(typeof observability.recordCacheMetric).toBe("function");
|
|
56
|
+
expect(observability.MetricNames.HTTP_REQUEST_DURATION_MS).toBe("http_request_duration_ms");
|
|
57
|
+
expect(observability.MetricNames.RESOLVE_DURATION_MS).toBe("resolve_duration_ms");
|
|
58
|
+
});
|
|
59
|
+
});
|
package/src/sdk/otel.ts
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Single observability entry point for `@decocms/start` on Cloudflare Workers.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* `instrumentWorker(handler, options)` wraps a Worker handler with:
|
|
5
|
+
* - structured JSON logger (stdout → Cloudflare Logs / Logpush) — always
|
|
6
|
+
* - OTLP/HTTP logs exporter (HyperDX) — when `OTEL_EXPORTER_OTLP_ENDPOINT` is set
|
|
7
|
+
* - OTLP/HTTP metrics exporter (HyperDX) — same condition
|
|
8
|
+
* - Workers Analytics Engine metrics — when `env.DECO_METRICS` binding exists
|
|
9
|
+
* - OTel traces via `@microlabs/otel-cf-workers` — when OTLP endpoint is set,
|
|
10
|
+
* or always-on for the framework's internal `withTracing()` calls.
|
|
11
|
+
* - URL-based head sampler from `OTEL_SAMPLING_CONFIG`
|
|
12
|
+
* - OTel `Resource` with service.* / cloud.* / deployment.environment /
|
|
13
|
+
* deco.runtime.version attributes
|
|
6
14
|
*
|
|
7
|
-
*
|
|
8
|
-
* -
|
|
9
|
-
* - `@opentelemetry/api`
|
|
15
|
+
* Removing HyperDX = unsetting `OTEL_EXPORTER_OTLP_ENDPOINT`. The console-JSON
|
|
16
|
+
* logger and AE metrics keep flowing — this is the "no vendor lock-in" guarantee.
|
|
10
17
|
*
|
|
11
18
|
* @example
|
|
12
19
|
* ```ts
|
|
@@ -18,30 +25,64 @@
|
|
|
18
25
|
* export default instrumentWorker(handler, { serviceName: "my-store" });
|
|
19
26
|
* ```
|
|
20
27
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
28
|
+
* Wrangler bindings to add when enabling OTLP + AE:
|
|
29
|
+
* ```jsonc
|
|
30
|
+
* "version_metadata": { "binding": "CF_VERSION_METADATA" },
|
|
31
|
+
* "analytics_engine_datasets": [{ "binding": "DECO_METRICS", "dataset": "deco_metrics_my_site" }]
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* Required Worker secrets when OTLP is enabled:
|
|
35
|
+
* wrangler secret put OTEL_EXPORTER_OTLP_ENDPOINT # https://in-otel.hyperdx.io
|
|
36
|
+
* wrangler secret put OTEL_EXPORTER_OTLP_HEADERS # authorization=<token>
|
|
24
37
|
*/
|
|
25
38
|
|
|
26
39
|
import { instrument, type ResolveConfigFn } from "@microlabs/otel-cf-workers";
|
|
27
40
|
import { trace } from "@opentelemetry/api";
|
|
28
|
-
import {
|
|
41
|
+
import { type Resource, resourceFromAttributes } from "@opentelemetry/resources";
|
|
42
|
+
|
|
43
|
+
import { configureMeter, configureTracer } from "../middleware/observability";
|
|
44
|
+
import { createCompositeLogger, createCompositeMeter } from "./composite";
|
|
45
|
+
import { configureLogger, defaultLoggerAdapter, logger } from "./logger";
|
|
46
|
+
import {
|
|
47
|
+
createAnalyticsEngineMeterAdapter,
|
|
48
|
+
createOtelLoggerAdapter,
|
|
49
|
+
createOtelMeterAdapter,
|
|
50
|
+
setRuntimeEnv,
|
|
51
|
+
} from "./otelAdapters";
|
|
52
|
+
import { RequestContext } from "./requestContext";
|
|
53
|
+
import { createUrlBasedHeadSampler, decodeSamplingConfig } from "./sampler";
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Types
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
29
58
|
|
|
30
59
|
export interface OtelOptions {
|
|
31
|
-
|
|
32
|
-
|
|
60
|
+
/** Logical service name. Falls back to `env.DECO_SITE_NAME`, then "deco-site". */
|
|
61
|
+
serviceName?: string;
|
|
62
|
+
/** Override OTLP endpoint. Defaults to `env.OTEL_EXPORTER_OTLP_ENDPOINT`. */
|
|
33
63
|
endpoint?: string;
|
|
34
|
-
/** OTLP auth headers. Defaults to env.OTEL_EXPORTER_OTLP_HEADERS
|
|
64
|
+
/** Override OTLP auth headers. Defaults to parsed `env.OTEL_EXPORTER_OTLP_HEADERS`. */
|
|
35
65
|
headers?: Record<string, string>;
|
|
66
|
+
/** Env var name holding the AE binding. Defaults to `"DECO_METRICS"`. */
|
|
67
|
+
analyticsEngineBindingName?: string;
|
|
68
|
+
/** Set to `false` to disable AE even when the binding is present. */
|
|
69
|
+
analyticsEngineEnabled?: boolean;
|
|
70
|
+
/** Push interval for OTLP metrics, in ms. Defaults to env.OTEL_EXPORT_INTERVAL or 60_000. */
|
|
71
|
+
metricsExportIntervalMillis?: number;
|
|
72
|
+
/**
|
|
73
|
+
* Version of `@decocms/start` to advertise as `deco.runtime.version`.
|
|
74
|
+
* Falls back to a build-time constant; override only for tests.
|
|
75
|
+
*/
|
|
76
|
+
decoRuntimeVersion?: string;
|
|
77
|
+
/** Optional `@decocms/apps` version, advertised as `deco.apps.version`. */
|
|
78
|
+
decoAppsVersion?: string;
|
|
36
79
|
}
|
|
37
80
|
|
|
38
|
-
/** Minimal Cloudflare Worker execution context. */
|
|
39
81
|
interface WorkerExecutionContext {
|
|
40
82
|
waitUntil(promise: Promise<unknown>): void;
|
|
41
83
|
passThroughOnException(): void;
|
|
42
84
|
}
|
|
43
85
|
|
|
44
|
-
/** Handler shape returned by createDecoWorkerEntry. */
|
|
45
86
|
interface WorkerHandler {
|
|
46
87
|
fetch(
|
|
47
88
|
request: Request,
|
|
@@ -50,19 +91,40 @@ interface WorkerHandler {
|
|
|
50
91
|
): Promise<Response>;
|
|
51
92
|
}
|
|
52
93
|
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Boot state — guard against double-init across worker reloads
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
let booted = false;
|
|
99
|
+
|
|
100
|
+
interface BootState {
|
|
101
|
+
serviceName: string;
|
|
102
|
+
otlpEndpoint: string | null;
|
|
103
|
+
otlpHeaders: Record<string, string>;
|
|
104
|
+
resource: Resource;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let bootState: BootState | null = null;
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// instrumentWorker
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
53
113
|
/**
|
|
54
|
-
* Wraps a Cloudflare Worker handler with
|
|
55
|
-
*
|
|
56
|
-
*
|
|
114
|
+
* Wraps a Cloudflare Worker handler with the full @decocms/start
|
|
115
|
+
* observability stack. Idempotent — calling twice on the same handler
|
|
116
|
+
* is a no-op (returns the already-instrumented handler).
|
|
57
117
|
*/
|
|
58
118
|
export function instrumentWorker(
|
|
59
119
|
handler: WorkerHandler,
|
|
60
|
-
options: OtelOptions | ((env: Record<string, unknown>) => OtelOptions),
|
|
61
|
-
) {
|
|
62
|
-
// Bridge
|
|
120
|
+
options: OtelOptions | ((env: Record<string, unknown>) => OtelOptions) = {},
|
|
121
|
+
): WorkerHandler {
|
|
122
|
+
// Bridge our pluggable TracerAdapter onto @opentelemetry/api so
|
|
123
|
+
// framework-internal `withTracing()` calls produce real OTel spans
|
|
124
|
+
// for whatever exporter is configured (OTLP, console, etc.).
|
|
63
125
|
configureTracer({
|
|
64
126
|
startSpan: (name, attrs) => {
|
|
65
|
-
const span = trace.getTracer("
|
|
127
|
+
const span = trace.getTracer("@decocms/start").startSpan(name, { attributes: attrs });
|
|
66
128
|
return {
|
|
67
129
|
end: () => span.end(),
|
|
68
130
|
setError: (error) => {
|
|
@@ -75,21 +137,178 @@ export function instrumentWorker(
|
|
|
75
137
|
|
|
76
138
|
const resolveConfig: ResolveConfigFn = (env, _trigger) => {
|
|
77
139
|
const opts = typeof options === "function" ? options(env as Record<string, unknown>) : options;
|
|
78
|
-
|
|
79
|
-
|
|
140
|
+
bootObservability(opts, env as Record<string, unknown>);
|
|
141
|
+
|
|
142
|
+
const state = bootState!;
|
|
143
|
+
|
|
144
|
+
// Sampling — base64 JSON via OTEL_SAMPLING_CONFIG, see sdk/sampler.ts
|
|
145
|
+
const samplingConfig = decodeSamplingConfig(env.OTEL_SAMPLING_CONFIG as string | undefined);
|
|
146
|
+
const headSampler = createUrlBasedHeadSampler(samplingConfig);
|
|
147
|
+
|
|
148
|
+
// microlabs requires an exporter even when we only want internal
|
|
149
|
+
// tracing. When OTLP isn't configured, we still set up a no-op
|
|
150
|
+
// collector — the URL we'd never reach so spans simply drop.
|
|
151
|
+
const exporterUrl = state.otlpEndpoint ?? "http://127.0.0.1:0/v1/traces";
|
|
80
152
|
|
|
81
153
|
return {
|
|
82
|
-
exporter: {
|
|
83
|
-
|
|
154
|
+
exporter: {
|
|
155
|
+
url: joinPath(exporterUrl, "/v1/traces"),
|
|
156
|
+
headers: state.otlpHeaders,
|
|
157
|
+
},
|
|
158
|
+
service: {
|
|
159
|
+
name: state.serviceName,
|
|
160
|
+
version: (env.CF_VERSION_METADATA as { id?: string } | undefined)?.id,
|
|
161
|
+
},
|
|
162
|
+
sampling: { headSampler },
|
|
163
|
+
// microlabs auto-instruments globalThis.fetch + KV + waitUntil.
|
|
164
|
+
instrumentation: {
|
|
165
|
+
instrumentGlobalFetch: true,
|
|
166
|
+
instrumentGlobalCache: true,
|
|
167
|
+
},
|
|
84
168
|
};
|
|
85
169
|
};
|
|
86
170
|
|
|
87
|
-
|
|
88
|
-
|
|
171
|
+
const innerHandler: WorkerHandler = {
|
|
172
|
+
async fetch(request, env, ctx) {
|
|
173
|
+
// Stash env so request-scoped adapters (AE) can resolve their bindings.
|
|
174
|
+
// Done inside RequestContext.run wrapping in workerEntry.ts as well, but
|
|
175
|
+
// for instrumentWorker we re-stash in case this handler is wrapped over
|
|
176
|
+
// the top of a Worker that doesn't go through createDecoWorkerEntry.
|
|
177
|
+
const wrap = async () => {
|
|
178
|
+
setRuntimeEnv(env);
|
|
179
|
+
return handler.fetch(request, env, ctx);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// RequestContext may already be active (createDecoWorkerEntry sets it
|
|
183
|
+
// up). If so, run inline; otherwise wrap. Cheap to detect via current.
|
|
184
|
+
if (RequestContext.current) {
|
|
185
|
+
return wrap();
|
|
186
|
+
}
|
|
187
|
+
return RequestContext.run(request, wrap);
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
|
|
89
191
|
// deno-lint-ignore no-explicit-any
|
|
90
|
-
return instrument(
|
|
192
|
+
return instrument(innerHandler as any, resolveConfig) as unknown as WorkerHandler;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// Boot — wires the loggers/meters once (per worker isolate)
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
function bootObservability(opts: OtelOptions, env: Record<string, unknown>): void {
|
|
200
|
+
if (booted && bootState) return;
|
|
201
|
+
|
|
202
|
+
const serviceName = opts.serviceName ?? (env.DECO_SITE_NAME as string | undefined) ?? "deco-site";
|
|
203
|
+
|
|
204
|
+
const otlpEndpoint =
|
|
205
|
+
opts.endpoint ?? (env.OTEL_EXPORTER_OTLP_ENDPOINT as string | undefined) ?? null;
|
|
206
|
+
const otlpHeaders =
|
|
207
|
+
opts.headers ?? parseHeaders(env.OTEL_EXPORTER_OTLP_HEADERS as string | undefined);
|
|
208
|
+
|
|
209
|
+
const resource = buildResource({
|
|
210
|
+
serviceName,
|
|
211
|
+
serviceVersion: (env.CF_VERSION_METADATA as { id?: string } | undefined)?.id,
|
|
212
|
+
serviceInstanceId: cryptoRandomId(),
|
|
213
|
+
deploymentEnvironment: (env.DECO_ENV_NAME as string | undefined) ?? "production",
|
|
214
|
+
decoRuntimeVersion: opts.decoRuntimeVersion ?? DECO_RUNTIME_VERSION,
|
|
215
|
+
decoAppsVersion: opts.decoAppsVersion,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// ---- Logger ----------------------------------------------------------
|
|
219
|
+
const otelLogger =
|
|
220
|
+
otlpEndpoint != null
|
|
221
|
+
? createOtelLoggerAdapter({
|
|
222
|
+
endpoint: otlpEndpoint,
|
|
223
|
+
headers: otlpHeaders,
|
|
224
|
+
resource,
|
|
225
|
+
name: serviceName,
|
|
226
|
+
})
|
|
227
|
+
: null;
|
|
228
|
+
|
|
229
|
+
configureLogger(createCompositeLogger([defaultLoggerAdapter, otelLogger]));
|
|
230
|
+
|
|
231
|
+
// ---- Meter -----------------------------------------------------------
|
|
232
|
+
const otelMeter =
|
|
233
|
+
otlpEndpoint != null
|
|
234
|
+
? createOtelMeterAdapter({
|
|
235
|
+
endpoint: otlpEndpoint,
|
|
236
|
+
headers: otlpHeaders,
|
|
237
|
+
resource,
|
|
238
|
+
exportIntervalMillis:
|
|
239
|
+
opts.metricsExportIntervalMillis ?? numericEnv(env.OTEL_EXPORT_INTERVAL, 60_000),
|
|
240
|
+
name: serviceName,
|
|
241
|
+
})
|
|
242
|
+
: null;
|
|
243
|
+
|
|
244
|
+
const aeBindingName = opts.analyticsEngineBindingName ?? "DECO_METRICS";
|
|
245
|
+
const aeEnabled = opts.analyticsEngineEnabled !== false && Boolean(env[aeBindingName]);
|
|
246
|
+
const aeMeter = aeEnabled
|
|
247
|
+
? createAnalyticsEngineMeterAdapter({ bindingName: aeBindingName })
|
|
248
|
+
: null;
|
|
249
|
+
|
|
250
|
+
configureMeter(createCompositeMeter([aeMeter, otelMeter]));
|
|
251
|
+
|
|
252
|
+
bootState = {
|
|
253
|
+
serviceName,
|
|
254
|
+
otlpEndpoint,
|
|
255
|
+
otlpHeaders,
|
|
256
|
+
resource,
|
|
257
|
+
};
|
|
258
|
+
booted = true;
|
|
259
|
+
|
|
260
|
+
// Single boot-time breadcrumb so operators can confirm the wiring at a
|
|
261
|
+
// glance from CF Logs without enabling debug.
|
|
262
|
+
logger.info("observability booted", {
|
|
263
|
+
service: serviceName,
|
|
264
|
+
otlp: Boolean(otlpEndpoint),
|
|
265
|
+
analyticsEngine: aeEnabled,
|
|
266
|
+
sampling: Boolean(env.OTEL_SAMPLING_CONFIG),
|
|
267
|
+
runtimeVersion: opts.decoRuntimeVersion ?? DECO_RUNTIME_VERSION,
|
|
268
|
+
});
|
|
91
269
|
}
|
|
92
270
|
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// Resource attributes
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
interface ResourceInput {
|
|
276
|
+
serviceName: string;
|
|
277
|
+
serviceVersion?: string;
|
|
278
|
+
serviceInstanceId: string;
|
|
279
|
+
deploymentEnvironment: string;
|
|
280
|
+
decoRuntimeVersion: string;
|
|
281
|
+
decoAppsVersion?: string;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function buildResource(input: ResourceInput): Resource {
|
|
285
|
+
const attrs: Record<string, string> = {
|
|
286
|
+
"service.name": input.serviceName,
|
|
287
|
+
"service.version": input.serviceVersion ?? "unknown",
|
|
288
|
+
"service.instance.id": input.serviceInstanceId,
|
|
289
|
+
"cloud.provider": "cloudflare",
|
|
290
|
+
"deployment.environment": input.deploymentEnvironment,
|
|
291
|
+
"deco.runtime.version": input.decoRuntimeVersion,
|
|
292
|
+
};
|
|
293
|
+
if (input.decoAppsVersion) attrs["deco.apps.version"] = input.decoAppsVersion;
|
|
294
|
+
return resourceFromAttributes(attrs);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
// Helpers
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Build-time @decocms/start version. Hand-bumped at release; we deliberately
|
|
303
|
+
* avoid `import("../../package.json")` to keep the module side-effect-free
|
|
304
|
+
* and JSON-import-quirk-free across the various build pipelines that
|
|
305
|
+
* consume @decocms/start.
|
|
306
|
+
*
|
|
307
|
+
* Drift is acceptable — this attribute is for operator triage, not for
|
|
308
|
+
* billing / SLOs.
|
|
309
|
+
*/
|
|
310
|
+
const DECO_RUNTIME_VERSION = "2.28.2";
|
|
311
|
+
|
|
93
312
|
function parseHeaders(str?: string): Record<string, string> {
|
|
94
313
|
if (!str) return {};
|
|
95
314
|
return Object.fromEntries(
|
|
@@ -102,3 +321,25 @@ function parseHeaders(str?: string): Record<string, string> {
|
|
|
102
321
|
.filter(([k]) => k.length > 0),
|
|
103
322
|
);
|
|
104
323
|
}
|
|
324
|
+
|
|
325
|
+
function numericEnv(value: unknown, fallback: number): number {
|
|
326
|
+
const n = Number(value);
|
|
327
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function joinPath(base: string, path: string): string {
|
|
331
|
+
if (!base) return path;
|
|
332
|
+
if (base.endsWith("/")) base = base.slice(0, -1);
|
|
333
|
+
if (!path.startsWith("/")) path = "/" + path;
|
|
334
|
+
if (base.toLowerCase().endsWith(path.toLowerCase())) return base;
|
|
335
|
+
return base + path;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function cryptoRandomId(): string {
|
|
339
|
+
// crypto.randomUUID is universally available in CF Workers + Node 19+.
|
|
340
|
+
try {
|
|
341
|
+
return crypto.randomUUID();
|
|
342
|
+
} catch {
|
|
343
|
+
return `inst-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
344
|
+
}
|
|
345
|
+
}
|