@decocms/start 4.5.0 → 5.0.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/.cursor/skills/deco-edge-caching/SKILL.md +14 -10
- package/.github/workflows/release.yml +26 -2
- package/CHANGELOG.md +113 -0
- package/bun.lock +0 -67
- package/package.json +5 -10
- package/scripts/migrate-to-cf-observability.test.ts +63 -17
- package/scripts/migrate-to-cf-observability.ts +175 -87
- package/src/sdk/observability.ts +26 -18
- package/src/sdk/otel.test.ts +50 -71
- package/src/sdk/otel.ts +70 -295
- package/src/sdk/otelAdapters/clickhouseCollector.ts +65 -0
- package/src/sdk/otelAdapters.test.ts +11 -194
- package/src/sdk/otelAdapters.ts +18 -353
- package/src/sdk/workerEntry.ts +39 -21
- package/src/vite/plugin.js +55 -0
- package/src/vite/plugin.test.js +60 -1
- package/vitest.config.ts +1 -1
- package/src/sdk/sampler.test.ts +0 -165
- package/src/sdk/sampler.ts +0 -213
|
@@ -1,199 +1,16 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
} from "./otelAdapters";
|
|
1
|
+
/**
|
|
2
|
+
* Coverage for `createAnalyticsEngineMeterAdapter` and the
|
|
3
|
+
* `setRuntimeEnv` / `getRuntimeEnv` helpers.
|
|
4
|
+
*
|
|
5
|
+
* As of 5.0.0 this file no longer covers `createOtelLoggerAdapter` /
|
|
6
|
+
* `createOtelMeterAdapter` / `flushOtelProviders` — those exporters were
|
|
7
|
+
* removed when the framework converged on Cloudflare-native log + trace
|
|
8
|
+
* capture. AE remains the in-Worker metrics path.
|
|
9
|
+
*/
|
|
10
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
11
|
+
import { createAnalyticsEngineMeterAdapter, setRuntimeEnv } from "./otelAdapters";
|
|
13
12
|
import { RequestContext } from "./requestContext";
|
|
14
13
|
|
|
15
|
-
describe("createOtelLoggerAdapter / createOtelMeterAdapter", () => {
|
|
16
|
-
afterEach(() => vi.restoreAllMocks());
|
|
17
|
-
|
|
18
|
-
it("returns null when no endpoint is provided (no-op safe)", () => {
|
|
19
|
-
expect(createOtelLoggerAdapter(null)).toBeNull();
|
|
20
|
-
expect(createOtelLoggerAdapter({ endpoint: "" })).toBeNull();
|
|
21
|
-
expect(createOtelMeterAdapter(null)).toBeNull();
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it("constructs without throwing when an endpoint is provided", () => {
|
|
25
|
-
// We don't want this test to actually open a network connection.
|
|
26
|
-
// The exporters lazy-initialize their HTTP client and only POST on
|
|
27
|
-
// batch flush, so simply constructing must not throw.
|
|
28
|
-
const log = createOtelLoggerAdapter({
|
|
29
|
-
endpoint: "https://otel.example.invalid",
|
|
30
|
-
headers: { authorization: "test" },
|
|
31
|
-
name: "test-svc",
|
|
32
|
-
});
|
|
33
|
-
expect(log).not.toBeNull();
|
|
34
|
-
expect(typeof log!.log).toBe("function");
|
|
35
|
-
|
|
36
|
-
const meter = createOtelMeterAdapter({
|
|
37
|
-
endpoint: "https://otel.example.invalid",
|
|
38
|
-
headers: { authorization: "test" },
|
|
39
|
-
exportIntervalMillis: 60_000,
|
|
40
|
-
name: "test-svc",
|
|
41
|
-
});
|
|
42
|
-
expect(meter).not.toBeNull();
|
|
43
|
-
expect(typeof meter!.counterInc).toBe("function");
|
|
44
|
-
expect(typeof meter!.histogramRecord).toBe("function");
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it("logs without throwing for a variety of attribute shapes", () => {
|
|
48
|
-
// minSeverity:"debug" so the test exercises the actual emit path; the
|
|
49
|
-
// default "warn" floor would short-circuit before the OTel emit runs
|
|
50
|
-
// and the attribute-sanitization branch wouldn't be hit.
|
|
51
|
-
const log = createOtelLoggerAdapter({
|
|
52
|
-
endpoint: "https://otel.example.invalid",
|
|
53
|
-
minSeverity: "debug",
|
|
54
|
-
});
|
|
55
|
-
expect(log).not.toBeNull();
|
|
56
|
-
expect(() =>
|
|
57
|
-
log!.log("info", "ok", {
|
|
58
|
-
s: "x",
|
|
59
|
-
n: 1,
|
|
60
|
-
b: true,
|
|
61
|
-
arr: [1, "two", true],
|
|
62
|
-
nested: { a: 1, b: { c: "d" } },
|
|
63
|
-
nullV: null,
|
|
64
|
-
undef: undefined,
|
|
65
|
-
fn: () => {},
|
|
66
|
-
sym: Symbol("s") as unknown as string,
|
|
67
|
-
}),
|
|
68
|
-
).not.toThrow();
|
|
69
|
-
});
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
describe("createOtelLoggerAdapter — minSeverity floor", () => {
|
|
73
|
-
// Every call to `createOtelLoggerAdapter` constructs its own local
|
|
74
|
-
// `LoggerProvider`. The OTel global provider is set the first time
|
|
75
|
-
// (`setGlobalLoggerProvider` is "first wins"), so spying on the global
|
|
76
|
-
// doesn't observe later adapters' emits. Spying on the *prototype*
|
|
77
|
-
// sidesteps that — every provider instance, local or global, returns
|
|
78
|
-
// the same fake logger and we can count emits across all of them.
|
|
79
|
-
let emitSpy: ReturnType<typeof vi.fn>;
|
|
80
|
-
|
|
81
|
-
beforeEach(() => {
|
|
82
|
-
_resetFlushHandlersForTests();
|
|
83
|
-
emitSpy = vi.fn();
|
|
84
|
-
vi.spyOn(LoggerProvider.prototype, "getLogger").mockReturnValue({
|
|
85
|
-
emit: emitSpy,
|
|
86
|
-
} as unknown as ReturnType<LoggerProvider["getLogger"]>);
|
|
87
|
-
});
|
|
88
|
-
afterEach(() => {
|
|
89
|
-
vi.restoreAllMocks();
|
|
90
|
-
_resetFlushHandlersForTests();
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
function makeAdapter(minSeverity?: "debug" | "info" | "warn" | "error") {
|
|
94
|
-
const adapter = createOtelLoggerAdapter({
|
|
95
|
-
endpoint: "https://otel.example.invalid",
|
|
96
|
-
headers: { authorization: "test" },
|
|
97
|
-
...(minSeverity ? { minSeverity } : {}),
|
|
98
|
-
});
|
|
99
|
-
expect(adapter).not.toBeNull();
|
|
100
|
-
return adapter!;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
it("defaults to 'warn' — drops debug + info, keeps warn + error", () => {
|
|
104
|
-
const adapter = makeAdapter(undefined);
|
|
105
|
-
adapter.log("debug", "d");
|
|
106
|
-
adapter.log("info", "i");
|
|
107
|
-
adapter.log("warn", "w");
|
|
108
|
-
adapter.log("error", "e");
|
|
109
|
-
expect(emitSpy).toHaveBeenCalledTimes(2);
|
|
110
|
-
expect(emitSpy.mock.calls[0]?.[0].severityText).toBe("WARN");
|
|
111
|
-
expect(emitSpy.mock.calls[1]?.[0].severityText).toBe("ERROR");
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it("explicit minSeverity='debug' lets every level through", () => {
|
|
115
|
-
const adapter = makeAdapter("debug");
|
|
116
|
-
adapter.log("debug", "d");
|
|
117
|
-
adapter.log("info", "i");
|
|
118
|
-
adapter.log("warn", "w");
|
|
119
|
-
adapter.log("error", "e");
|
|
120
|
-
expect(emitSpy).toHaveBeenCalledTimes(4);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it("explicit minSeverity='error' only forwards error", () => {
|
|
124
|
-
const adapter = makeAdapter("error");
|
|
125
|
-
adapter.log("debug", "d");
|
|
126
|
-
adapter.log("info", "i");
|
|
127
|
-
adapter.log("warn", "w");
|
|
128
|
-
adapter.log("error", "e");
|
|
129
|
-
expect(emitSpy).toHaveBeenCalledTimes(1);
|
|
130
|
-
expect(emitSpy.mock.calls[0]?.[0].severityText).toBe("ERROR");
|
|
131
|
-
});
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
describe("flushOtelProviders / registerOtelFlushHandler", () => {
|
|
135
|
-
beforeEach(() => _resetFlushHandlersForTests());
|
|
136
|
-
afterEach(() => {
|
|
137
|
-
vi.restoreAllMocks();
|
|
138
|
-
_resetFlushHandlersForTests();
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it("resolves immediately when no handlers are registered", async () => {
|
|
142
|
-
expect(_getFlushHandlerCountForTests()).toBe(0);
|
|
143
|
-
await expect(flushOtelProviders()).resolves.toBeUndefined();
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it("invokes every registered handler exactly once", async () => {
|
|
147
|
-
const a = vi.fn(() => Promise.resolve());
|
|
148
|
-
const b = vi.fn(() => Promise.resolve());
|
|
149
|
-
registerOtelFlushHandler(a);
|
|
150
|
-
registerOtelFlushHandler(b);
|
|
151
|
-
expect(_getFlushHandlerCountForTests()).toBe(2);
|
|
152
|
-
await flushOtelProviders();
|
|
153
|
-
expect(a).toHaveBeenCalledOnce();
|
|
154
|
-
expect(b).toHaveBeenCalledOnce();
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it("does NOT reject when a handler throws — uses Promise.allSettled", async () => {
|
|
158
|
-
const ok = vi.fn(() => Promise.resolve());
|
|
159
|
-
const fail = vi.fn(() => Promise.reject(new Error("OTLP 503")));
|
|
160
|
-
registerOtelFlushHandler(fail);
|
|
161
|
-
registerOtelFlushHandler(ok);
|
|
162
|
-
// Must resolve, not throw — flush failures are telemetry incidents,
|
|
163
|
-
// not request incidents. This is the surface contract callers rely on.
|
|
164
|
-
await expect(flushOtelProviders()).resolves.toBeUndefined();
|
|
165
|
-
expect(ok).toHaveBeenCalledOnce();
|
|
166
|
-
expect(fail).toHaveBeenCalledOnce();
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it("createOtelLoggerAdapter registers exactly one flush handler", () => {
|
|
170
|
-
const before = _getFlushHandlerCountForTests();
|
|
171
|
-
const adapter = createOtelLoggerAdapter({
|
|
172
|
-
endpoint: "https://otel.example.invalid",
|
|
173
|
-
});
|
|
174
|
-
expect(adapter).not.toBeNull();
|
|
175
|
-
expect(_getFlushHandlerCountForTests()).toBe(before + 1);
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
it("createOtelMeterAdapter registers exactly one flush handler", () => {
|
|
179
|
-
const before = _getFlushHandlerCountForTests();
|
|
180
|
-
const meter = createOtelMeterAdapter({
|
|
181
|
-
endpoint: "https://otel.example.invalid",
|
|
182
|
-
exportIntervalMillis: 60_000,
|
|
183
|
-
});
|
|
184
|
-
expect(meter).not.toBeNull();
|
|
185
|
-
expect(_getFlushHandlerCountForTests()).toBe(before + 1);
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
it("returning-null factories register no handlers", () => {
|
|
189
|
-
expect(_getFlushHandlerCountForTests()).toBe(0);
|
|
190
|
-
createOtelLoggerAdapter(null);
|
|
191
|
-
createOtelLoggerAdapter({ endpoint: "" });
|
|
192
|
-
createOtelMeterAdapter(null);
|
|
193
|
-
expect(_getFlushHandlerCountForTests()).toBe(0);
|
|
194
|
-
});
|
|
195
|
-
});
|
|
196
|
-
|
|
197
14
|
describe("createAnalyticsEngineMeterAdapter", () => {
|
|
198
15
|
afterEach(() => vi.restoreAllMocks());
|
|
199
16
|
|
package/src/sdk/otelAdapters.ts
CHANGED
|
@@ -1,101 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Metric adapters for `@decocms/start`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* -
|
|
7
|
-
*
|
|
4
|
+
* The OTLP logger and OTLP meter adapters that lived here through 4.x were
|
|
5
|
+
* removed in 5.0.0. The framework now relies on:
|
|
6
|
+
* - Cloudflare's platform-managed log capture (`console.*` → CF dashboard
|
|
7
|
+
* via `observability.logs`) for log shipping
|
|
8
|
+
* - Cloudflare's auto-instrumented + global-tracer-bridged trace export
|
|
9
|
+
* (`observability.traces`) for trace shipping
|
|
10
|
+
* - Workers Analytics Engine — implemented in this file — for metrics
|
|
8
11
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
+
* Forwarding to a co-deployed OTel collector (the path to ClickHouse)
|
|
13
|
+
* lives in `./otelAdapters/clickhouseCollector.ts`. That file is a
|
|
14
|
+
* documented stub today; the implementation will reintroduce a thin
|
|
15
|
+
* OTLP/HTTP exporter aimed at the collector — never directly at a
|
|
16
|
+
* destination — when the collector is deployed.
|
|
12
17
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* in `./composite` provides additional try/catch isolation.
|
|
18
|
+
* The `setRuntimeEnv` / `getRuntimeEnv` helpers stay here because
|
|
19
|
+
* `workerEntry.ts` and the AE adapter both depend on them.
|
|
16
20
|
*/
|
|
17
21
|
|
|
18
|
-
import {
|
|
19
|
-
type Counter,
|
|
20
|
-
type Histogram,
|
|
21
|
-
metrics as metricsApi,
|
|
22
|
-
type ObservableGauge,
|
|
23
|
-
} from "@opentelemetry/api";
|
|
24
|
-
import { type AnyValue, logs as logsApi, SeverityNumber } from "@opentelemetry/api-logs";
|
|
25
|
-
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
|
|
26
|
-
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
|
|
27
|
-
import type { Resource } from "@opentelemetry/resources";
|
|
28
|
-
import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs";
|
|
29
|
-
import {
|
|
30
|
-
type AggregationOption,
|
|
31
|
-
AggregationType,
|
|
32
|
-
InstrumentType,
|
|
33
|
-
MeterProvider,
|
|
34
|
-
PeriodicExportingMetricReader,
|
|
35
|
-
type ViewOptions,
|
|
36
|
-
} from "@opentelemetry/sdk-metrics";
|
|
37
22
|
import type { MeterAdapter } from "../middleware/observability";
|
|
38
23
|
import { MetricNames } from "../middleware/observability";
|
|
39
|
-
import type { LoggerAdapter, LogLevel } from "./logger";
|
|
40
24
|
import { RequestContext } from "./requestContext";
|
|
41
25
|
|
|
42
|
-
// ---------------------------------------------------------------------------
|
|
43
|
-
// Flush registry — per-request `ctx.waitUntil` hook target
|
|
44
|
-
// ---------------------------------------------------------------------------
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Cloudflare Workers isolates are recycled aggressively (often before the
|
|
48
|
-
* next 5s `BatchLogRecordProcessor` tick or 60s `PeriodicExportingMetricReader`
|
|
49
|
-
* tick fires). Without a per-request flush, in-memory log/metric batches die
|
|
50
|
-
* with the isolate and never reach OTLP — observable as "spans show up in
|
|
51
|
-
* HyperDX, logs and metrics don't".
|
|
52
|
-
*
|
|
53
|
-
* Each OTLP adapter registers a `forceFlush` handler here at construction
|
|
54
|
-
* time. `instrumentWorker` calls `flushOtelProviders()` from
|
|
55
|
-
* `ctx.waitUntil(...)` after every response so the batch is drained inside
|
|
56
|
-
* the post-response window the platform guarantees.
|
|
57
|
-
*
|
|
58
|
-
* The registry is module-scoped (one entry per provider, not per request)
|
|
59
|
-
* and survives worker reloads — the boot guard in `bootObservability`
|
|
60
|
-
* prevents duplicate registration in steady state. Tests must call
|
|
61
|
-
* `_resetFlushHandlersForTests()` between fixtures to avoid leakage.
|
|
62
|
-
*/
|
|
63
|
-
const flushHandlers: Array<() => Promise<unknown>> = [];
|
|
64
|
-
|
|
65
|
-
/** Register a `forceFlush()`-style handler. Idempotency is the caller's problem. */
|
|
66
|
-
export function registerOtelFlushHandler(fn: () => Promise<unknown>): void {
|
|
67
|
-
flushHandlers.push(fn);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Drain every registered OTLP provider in parallel. Resolves once they
|
|
72
|
-
* all settle — never rejects, because flush failures are telemetry
|
|
73
|
-
* incidents, not request incidents.
|
|
74
|
-
*
|
|
75
|
-
* Safe to call when no adapters are registered (returns immediately).
|
|
76
|
-
*/
|
|
77
|
-
export async function flushOtelProviders(): Promise<void> {
|
|
78
|
-
if (flushHandlers.length === 0) return;
|
|
79
|
-
await Promise.allSettled(flushHandlers.map((fn) => fn()));
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Test-only: clear the flush registry between fixtures. Do not call from app code. */
|
|
83
|
-
export function _resetFlushHandlersForTests(): void {
|
|
84
|
-
flushHandlers.length = 0;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Test-only: introspect the registry size. Do not call from app code. */
|
|
88
|
-
export function _getFlushHandlerCountForTests(): number {
|
|
89
|
-
return flushHandlers.length;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
26
|
// ---------------------------------------------------------------------------
|
|
93
27
|
// Env / binding access
|
|
94
28
|
// ---------------------------------------------------------------------------
|
|
95
29
|
|
|
96
30
|
/**
|
|
97
31
|
* Per-request access to the Cloudflare Worker `env` bag. Stashed by
|
|
98
|
-
* `
|
|
32
|
+
* `workerEntry.ts` at the top of every request via
|
|
99
33
|
* `RequestContext.setBag("__deco_env", env)`.
|
|
100
34
|
*
|
|
101
35
|
* Adapters use this to look up the AE binding (or any future binding-driven
|
|
@@ -112,262 +46,6 @@ export function getRuntimeEnv(): Record<string, unknown> | undefined {
|
|
|
112
46
|
return RequestContext.getBag<Record<string, unknown>>(ENV_BAG_KEY);
|
|
113
47
|
}
|
|
114
48
|
|
|
115
|
-
// ---------------------------------------------------------------------------
|
|
116
|
-
// OtelLoggerAdapter
|
|
117
|
-
// ---------------------------------------------------------------------------
|
|
118
|
-
|
|
119
|
-
const SEVERITY: Record<LogLevel, { number: SeverityNumber; text: string }> = {
|
|
120
|
-
debug: { number: SeverityNumber.DEBUG, text: "DEBUG" },
|
|
121
|
-
info: { number: SeverityNumber.INFO, text: "INFO" },
|
|
122
|
-
warn: { number: SeverityNumber.WARN, text: "WARN" },
|
|
123
|
-
error: { number: SeverityNumber.ERROR, text: "ERROR" },
|
|
124
|
-
};
|
|
125
|
-
|
|
126
|
-
export interface OtelLoggerAdapterOptions {
|
|
127
|
-
endpoint: string;
|
|
128
|
-
headers?: Record<string, string>;
|
|
129
|
-
resource?: Resource;
|
|
130
|
-
/** OTel logger name. Defaults to "@decocms/start". */
|
|
131
|
-
name?: string;
|
|
132
|
-
/**
|
|
133
|
-
* Minimum severity to forward to OTLP. Calls below this floor are dropped
|
|
134
|
-
* by this adapter (and therefore don't ship to HyperDX or any other OTLP
|
|
135
|
-
* sink), but the framework's default console adapter still sees them, so
|
|
136
|
-
* they remain visible in Cloudflare Workers Logs.
|
|
137
|
-
*
|
|
138
|
-
* Defaults to `"warn"`. The reasoning: in Cloudflare Workers every OTLP
|
|
139
|
-
* emit eventually becomes an outbound subrequest (after batching). Routine
|
|
140
|
-
* `info` chatter compounded over many isolates can exhaust either the
|
|
141
|
-
* subrequest budget (if a hot loop logs) or the HyperDX log-ingest quota.
|
|
142
|
-
* Warn+ keeps the trace ↔ log correlation that matters during incidents
|
|
143
|
-
* without ingesting noise that's already captured by Cloudflare Logs.
|
|
144
|
-
*
|
|
145
|
-
* Override per-site via `OtelOptions.otlpMinSeverity` or the
|
|
146
|
-
* `OTEL_LOG_MIN_SEVERITY` env var. Set to `"debug"` to forward everything.
|
|
147
|
-
*/
|
|
148
|
-
minSeverity?: LogLevel;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* Streams `logger.*` calls (at or above `minSeverity`) to an OTLP/HTTP logs
|
|
153
|
-
* endpoint (e.g. HyperDX).
|
|
154
|
-
*
|
|
155
|
-
* Returns `null` when no endpoint is configured — `instrumentWorker()`
|
|
156
|
-
* uses that signal to skip registering this adapter.
|
|
157
|
-
*
|
|
158
|
-
* Side effect: registers the underlying `LoggerProvider`'s `forceFlush()`
|
|
159
|
-
* with `registerOtelFlushHandler` so per-request hooks in `instrumentWorker`
|
|
160
|
-
* can drain the batch before the Workers isolate is recycled. Without that
|
|
161
|
-
* registration, `BatchLogRecordProcessor`'s 5s timer rarely fires before
|
|
162
|
-
* GC and log records are silently dropped.
|
|
163
|
-
*/
|
|
164
|
-
export function createOtelLoggerAdapter(
|
|
165
|
-
options: OtelLoggerAdapterOptions | null,
|
|
166
|
-
): LoggerAdapter | null {
|
|
167
|
-
if (!options || !options.endpoint) return null;
|
|
168
|
-
|
|
169
|
-
const exporter = new OTLPLogExporter({
|
|
170
|
-
url: joinPath(options.endpoint, "/v1/logs"),
|
|
171
|
-
headers: options.headers,
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
const provider = new LoggerProvider({
|
|
175
|
-
resource: options.resource,
|
|
176
|
-
});
|
|
177
|
-
provider.addLogRecordProcessor(new BatchLogRecordProcessor(exporter));
|
|
178
|
-
registerOtelFlushHandler(() => provider.forceFlush());
|
|
179
|
-
|
|
180
|
-
// Register globally so `@opentelemetry/api-logs` consumers (if any)
|
|
181
|
-
// also pick it up. Idempotent — safe across multiple worker reloads.
|
|
182
|
-
try {
|
|
183
|
-
logsApi.setGlobalLoggerProvider(provider);
|
|
184
|
-
} catch {
|
|
185
|
-
/* already set */
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const otelLogger = provider.getLogger(options.name ?? "@decocms/start");
|
|
189
|
-
const minSev = SEVERITY[options.minSeverity ?? "warn"].number;
|
|
190
|
-
|
|
191
|
-
return {
|
|
192
|
-
log(level, msg, attrs) {
|
|
193
|
-
const sev = SEVERITY[level];
|
|
194
|
-
if (sev.number < minSev) return;
|
|
195
|
-
otelLogger.emit({
|
|
196
|
-
severityNumber: sev.number,
|
|
197
|
-
severityText: sev.text,
|
|
198
|
-
body: msg,
|
|
199
|
-
attributes: attrs ? sanitizeAttributes(attrs) : undefined,
|
|
200
|
-
});
|
|
201
|
-
},
|
|
202
|
-
};
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Coerce an arbitrary `Record<string, unknown>` into the strict `AnyValueMap`
|
|
207
|
-
* the OTel logs API expects. Drops anything that can't be safely
|
|
208
|
-
* represented (functions, symbols, circular structures via stringify guard).
|
|
209
|
-
*/
|
|
210
|
-
function sanitizeAttributes(attrs: Record<string, unknown>): Record<string, AnyValue> {
|
|
211
|
-
const out: Record<string, AnyValue> = {};
|
|
212
|
-
for (const [k, v] of Object.entries(attrs)) {
|
|
213
|
-
out[k] = toAnyValue(v);
|
|
214
|
-
}
|
|
215
|
-
return out;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function toAnyValue(v: unknown): AnyValue {
|
|
219
|
-
if (v === null || v === undefined) return undefined;
|
|
220
|
-
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") return v;
|
|
221
|
-
if (v instanceof Uint8Array) return v;
|
|
222
|
-
if (Array.isArray(v)) return v.map(toAnyValue);
|
|
223
|
-
if (typeof v === "object") {
|
|
224
|
-
const out: Record<string, AnyValue> = {};
|
|
225
|
-
for (const [k, vv] of Object.entries(v as Record<string, unknown>)) {
|
|
226
|
-
out[k] = toAnyValue(vv);
|
|
227
|
-
}
|
|
228
|
-
return out;
|
|
229
|
-
}
|
|
230
|
-
// Functions, symbols, bigint — stringify so the operator still sees something.
|
|
231
|
-
try {
|
|
232
|
-
return String(v);
|
|
233
|
-
} catch {
|
|
234
|
-
return undefined;
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// ---------------------------------------------------------------------------
|
|
239
|
-
// OtelMeterAdapter
|
|
240
|
-
// ---------------------------------------------------------------------------
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Histogram bucket boundaries for millisecond timings.
|
|
244
|
-
* Mirrors `deco-cx/deco/observability/otel/metrics.ts` so HyperDX
|
|
245
|
-
* panels built off the Fresh stack keep working unchanged.
|
|
246
|
-
*/
|
|
247
|
-
const MS_BOUNDARIES = [10, 100, 500, 1000, 5000, 10000, 15000];
|
|
248
|
-
/** Histogram bucket boundaries for second timings. */
|
|
249
|
-
const SECONDS_BOUNDARIES = [1, 5, 10, 50];
|
|
250
|
-
|
|
251
|
-
export interface OtelMeterAdapterOptions {
|
|
252
|
-
endpoint: string;
|
|
253
|
-
headers?: Record<string, string>;
|
|
254
|
-
resource?: Resource;
|
|
255
|
-
/** Push interval in ms. Defaults to env.OTEL_EXPORT_INTERVAL or 60_000. */
|
|
256
|
-
exportIntervalMillis?: number;
|
|
257
|
-
/** OTel meter name. Defaults to "@decocms/start". */
|
|
258
|
-
name?: string;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* Streams metric writes to an OTLP/HTTP metrics endpoint with a
|
|
263
|
-
* `PeriodicExportingMetricReader`.
|
|
264
|
-
*
|
|
265
|
-
* Two histogram views are pre-registered:
|
|
266
|
-
* - any metric ending with `_ms` → millisecond bucket boundaries
|
|
267
|
-
* - any metric ending with `_s` → second bucket boundaries
|
|
268
|
-
*
|
|
269
|
-
* Returns `null` when no endpoint is configured.
|
|
270
|
-
*/
|
|
271
|
-
export function createOtelMeterAdapter(
|
|
272
|
-
options: OtelMeterAdapterOptions | null,
|
|
273
|
-
): MeterAdapter | null {
|
|
274
|
-
if (!options || !options.endpoint) return null;
|
|
275
|
-
|
|
276
|
-
const exporter = new OTLPMetricExporter({
|
|
277
|
-
url: joinPath(options.endpoint, "/v1/metrics"),
|
|
278
|
-
headers: options.headers,
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
const reader = new PeriodicExportingMetricReader({
|
|
282
|
-
exporter,
|
|
283
|
-
exportIntervalMillis: options.exportIntervalMillis ?? 60_000,
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
const histogramAggregation = (boundaries: number[]): AggregationOption => ({
|
|
287
|
-
type: AggregationType.EXPLICIT_BUCKET_HISTOGRAM,
|
|
288
|
-
options: { boundaries, recordMinMax: true },
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
const views: ViewOptions[] = [
|
|
292
|
-
{
|
|
293
|
-
instrumentName: "*_ms",
|
|
294
|
-
instrumentType: InstrumentType.HISTOGRAM,
|
|
295
|
-
aggregation: histogramAggregation(MS_BOUNDARIES),
|
|
296
|
-
},
|
|
297
|
-
{
|
|
298
|
-
instrumentName: "*_s",
|
|
299
|
-
instrumentType: InstrumentType.HISTOGRAM,
|
|
300
|
-
aggregation: histogramAggregation(SECONDS_BOUNDARIES),
|
|
301
|
-
},
|
|
302
|
-
];
|
|
303
|
-
|
|
304
|
-
const provider = new MeterProvider({
|
|
305
|
-
resource: options.resource,
|
|
306
|
-
readers: [reader],
|
|
307
|
-
views,
|
|
308
|
-
});
|
|
309
|
-
// See `flushHandlers` registry comment — `PeriodicExportingMetricReader`
|
|
310
|
-
// ticks every `exportIntervalMillis` (default 60s); without per-request
|
|
311
|
-
// forceFlush the metric batch typically dies with the Workers isolate
|
|
312
|
-
// before its first scheduled tick.
|
|
313
|
-
registerOtelFlushHandler(() => provider.forceFlush());
|
|
314
|
-
|
|
315
|
-
try {
|
|
316
|
-
metricsApi.setGlobalMeterProvider(provider);
|
|
317
|
-
} catch {
|
|
318
|
-
/* already set */
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
const meter = provider.getMeter(options.name ?? "@decocms/start");
|
|
322
|
-
|
|
323
|
-
// Lazy-create instruments by name so we don't pay the create cost on
|
|
324
|
-
// every recordRequestMetric / recordCacheMetric invocation.
|
|
325
|
-
const counters = new Map<string, Counter>();
|
|
326
|
-
const histograms = new Map<string, Histogram>();
|
|
327
|
-
const gauges = new Map<string, ObservableGauge>();
|
|
328
|
-
const gaugeValues = new Map<string, { value: number; labels?: Record<string, unknown> }>();
|
|
329
|
-
|
|
330
|
-
function getCounter(name: string): Counter {
|
|
331
|
-
let c = counters.get(name);
|
|
332
|
-
if (!c) {
|
|
333
|
-
c = meter.createCounter(name);
|
|
334
|
-
counters.set(name, c);
|
|
335
|
-
}
|
|
336
|
-
return c;
|
|
337
|
-
}
|
|
338
|
-
function getHistogram(name: string): Histogram {
|
|
339
|
-
let h = histograms.get(name);
|
|
340
|
-
if (!h) {
|
|
341
|
-
h = meter.createHistogram(name);
|
|
342
|
-
histograms.set(name, h);
|
|
343
|
-
}
|
|
344
|
-
return h;
|
|
345
|
-
}
|
|
346
|
-
function ensureGauge(name: string): void {
|
|
347
|
-
if (gauges.has(name)) return;
|
|
348
|
-
const g = meter.createObservableGauge(name);
|
|
349
|
-
g.addCallback((result) => {
|
|
350
|
-
const last = gaugeValues.get(name);
|
|
351
|
-
if (last)
|
|
352
|
-
result.observe(last.value, last.labels as Record<string, string | number | boolean>);
|
|
353
|
-
});
|
|
354
|
-
gauges.set(name, g);
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
return {
|
|
358
|
-
counterInc(name, value, labels) {
|
|
359
|
-
getCounter(name).add(value ?? 1, labels);
|
|
360
|
-
},
|
|
361
|
-
histogramRecord(name, value, labels) {
|
|
362
|
-
getHistogram(name).record(value, labels);
|
|
363
|
-
},
|
|
364
|
-
gaugeSet(name, value, labels) {
|
|
365
|
-
ensureGauge(name);
|
|
366
|
-
gaugeValues.set(name, { value, labels });
|
|
367
|
-
},
|
|
368
|
-
};
|
|
369
|
-
}
|
|
370
|
-
|
|
371
49
|
// ---------------------------------------------------------------------------
|
|
372
50
|
// AnalyticsEngineMeterAdapter
|
|
373
51
|
// ---------------------------------------------------------------------------
|
|
@@ -376,7 +54,7 @@ export function createOtelMeterAdapter(
|
|
|
376
54
|
* Workers Analytics Engine binding shape. Defined here so we don't need
|
|
377
55
|
* `@cloudflare/workers-types` as a dep.
|
|
378
56
|
*/
|
|
379
|
-
interface AnalyticsEngineDataset {
|
|
57
|
+
export interface AnalyticsEngineDataset {
|
|
380
58
|
writeDataPoint(point: { indexes?: string[]; blobs?: string[]; doubles?: number[] }): void;
|
|
381
59
|
}
|
|
382
60
|
|
|
@@ -456,7 +134,7 @@ export function createAnalyticsEngineMeterAdapter(
|
|
|
456
134
|
function pickIndex(metricName: string, labels?: Record<string, unknown>): string | undefined {
|
|
457
135
|
if (!labels) return metricName;
|
|
458
136
|
// For HTTP request metrics the natural index is the path (matches the
|
|
459
|
-
//
|
|
137
|
+
// schema: `indexes[0]=path`). For other metrics, fall back to the metric
|
|
460
138
|
// name so the dataset is always queryable by index.
|
|
461
139
|
if (
|
|
462
140
|
metricName === MetricNames.HTTP_REQUESTS_TOTAL ||
|
|
@@ -468,16 +146,3 @@ function pickIndex(metricName: string, labels?: Record<string, unknown>): string
|
|
|
468
146
|
}
|
|
469
147
|
return metricName;
|
|
470
148
|
}
|
|
471
|
-
|
|
472
|
-
// ---------------------------------------------------------------------------
|
|
473
|
-
// helpers
|
|
474
|
-
// ---------------------------------------------------------------------------
|
|
475
|
-
|
|
476
|
-
function joinPath(base: string, path: string): string {
|
|
477
|
-
if (!base) return path;
|
|
478
|
-
if (base.endsWith("/")) base = base.slice(0, -1);
|
|
479
|
-
if (!path.startsWith("/")) path = "/" + path;
|
|
480
|
-
// Already includes the path? Don't double-append.
|
|
481
|
-
if (base.toLowerCase().endsWith(path.toLowerCase())) return base;
|
|
482
|
-
return base + path;
|
|
483
|
-
}
|