@decocms/start 2.28.2 → 2.30.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/.agents/skills/deco-to-tanstack-migration/SKILL.md +1 -1
- package/.agents/skills/deco-to-tanstack-migration/references/vtex-commerce.md +5 -1
- package/.agents/skills/deco-to-tanstack-migration/references/worker-cloudflare.md +107 -10
- package/.agents/skills/deco-to-tanstack-migration/templates/package-json.md +5 -1
- package/.cursor/rules/migration-tooling-policy.mdc +22 -2
- package/.github/workflows/deploy.yml +115 -0
- package/.github/workflows/preview.yml +143 -0
- package/.github/workflows/regen-blocks.yml +56 -0
- package/.github/workflows/release.yml +26 -0
- package/.github/workflows/sync-secrets.yml +173 -0
- package/CODEOWNERS +16 -0
- package/MIGRATION_TOOLING_PLAN.md +16 -4
- package/README.md +178 -79
- package/deploy/README.md +85 -0
- package/deploy/sites/als-tanstack.jsonc +7 -0
- package/deploy/sites/americanas-tanstack.jsonc +4 -0
- package/deploy/sites/baggagio-tanstack.jsonc +4 -0
- package/deploy/sites/casaevideo-storefront.jsonc +11 -0
- package/deploy/sites/lebiscuit-tanstack.jsonc +19 -0
- package/deploy/sites/miess-01-tanstack.jsonc +8 -0
- package/deploy/wrangler-template.jsonc +28 -0
- package/package.json +18 -15
- package/scripts/deploy/build-wrangler-config.mjs +49 -0
- package/scripts/deploy/jsonc.mjs +76 -0
- package/scripts/deploy/resolve-site.mjs +58 -0
- package/scripts/deploy/site-registry.mjs +142 -0
- package/scripts/deploy/wrangler-wrapper.mjs +126 -0
- package/scripts/migrate/phase-scaffold.ts +13 -3
- package/scripts/migrate/phase-verify.ts +6 -1
- package/scripts/migrate/templates/github-workflows.ts +98 -0
- package/scripts/migrate/templates/package-json.ts +9 -2
- 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/scripts/migrate/templates/wrangler.ts +0 -30
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composite adapters — fan out to multiple backends with try/catch isolation.
|
|
3
|
+
*
|
|
4
|
+
* Used by `instrumentWorker()` so the same logger/meter can dual-emit to
|
|
5
|
+
* (e.g.) console-JSON + OTLP, or AE + OTLP, without any one backend's
|
|
6
|
+
* failure taking down the others.
|
|
7
|
+
*
|
|
8
|
+
* The "always include console-JSON logger" guarantee in the observability
|
|
9
|
+
* plan relies on this: even if HyperDX is down, the local console adapter
|
|
10
|
+
* still records the line.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { MeterAdapter } from "../middleware/observability";
|
|
14
|
+
import type { LoggerAdapter, LogLevel } from "./logger";
|
|
15
|
+
|
|
16
|
+
type Labels = Record<string, string | number | boolean>;
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Logger
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Returns a single LoggerAdapter that fans every call out to all
|
|
24
|
+
* provided adapters. Falsy entries (e.g. `otelEnabled ? otelAdapter : null`)
|
|
25
|
+
* are filtered out so call sites can write:
|
|
26
|
+
*
|
|
27
|
+
* ```ts
|
|
28
|
+
* createCompositeLogger([defaultLoggerAdapter, otelEnabled ? otelLogger : null]);
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* Each downstream `.log()` call is isolated in try/catch so a thrown
|
|
32
|
+
* error in one backend cannot suppress the others.
|
|
33
|
+
*/
|
|
34
|
+
export function createCompositeLogger(
|
|
35
|
+
adapters: Array<LoggerAdapter | null | undefined | false>,
|
|
36
|
+
): LoggerAdapter {
|
|
37
|
+
const list = adapters.filter((a): a is LoggerAdapter => Boolean(a));
|
|
38
|
+
|
|
39
|
+
if (list.length === 1) return list[0];
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
log(level: LogLevel, msg: string, attrs?: Record<string, unknown>) {
|
|
43
|
+
for (const adapter of list) {
|
|
44
|
+
try {
|
|
45
|
+
adapter.log(level, msg, attrs);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
// Use console.error directly — calling logger here would recurse.
|
|
48
|
+
try {
|
|
49
|
+
console.error("[composite-logger] adapter failed", String(error));
|
|
50
|
+
} catch {
|
|
51
|
+
/* swallow */
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Meter
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Returns a single MeterAdapter that fans counter/gauge/histogram writes
|
|
65
|
+
* out to all provided meters. Same semantics as `createCompositeLogger`:
|
|
66
|
+
* falsy entries are dropped, each downstream call is try/catch isolated.
|
|
67
|
+
*/
|
|
68
|
+
export function createCompositeMeter(
|
|
69
|
+
adapters: Array<MeterAdapter | null | undefined | false>,
|
|
70
|
+
): MeterAdapter {
|
|
71
|
+
const list = adapters.filter((a): a is MeterAdapter => Boolean(a));
|
|
72
|
+
|
|
73
|
+
if (list.length === 1) return list[0];
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
counterInc(name: string, value?: number, labels?: Labels) {
|
|
77
|
+
for (const m of list) {
|
|
78
|
+
try {
|
|
79
|
+
m.counterInc(name, value, labels);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
warn("counterInc", name, error);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
gaugeSet(name: string, value: number, labels?: Labels) {
|
|
86
|
+
for (const m of list) {
|
|
87
|
+
if (!m.gaugeSet) continue;
|
|
88
|
+
try {
|
|
89
|
+
m.gaugeSet(name, value, labels);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
warn("gaugeSet", name, error);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
histogramRecord(name: string, value: number, labels?: Labels) {
|
|
96
|
+
for (const m of list) {
|
|
97
|
+
if (!m.histogramRecord) continue;
|
|
98
|
+
try {
|
|
99
|
+
m.histogramRecord(name, value, labels);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
warn("histogramRecord", name, error);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function warn(op: string, metric: string, error: unknown) {
|
|
109
|
+
try {
|
|
110
|
+
console.error(`[composite-meter] ${op}(${metric}) adapter failed`, String(error));
|
|
111
|
+
} catch {
|
|
112
|
+
/* swallow */
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -16,6 +16,26 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { getTracer } from "../middleware/observability";
|
|
19
|
+
import { logger } from "./logger";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Cloudflare / VTEX response headers that operators want to see as span
|
|
23
|
+
* attributes when debugging cache behavior. Mirrors `applyCustomAttributesOnSpan`
|
|
24
|
+
* in `deco-cx/deco/observability/otel/`.
|
|
25
|
+
*/
|
|
26
|
+
const CACHE_HEADERS_TO_SPAN: Array<{ header: string; attr: string }> = [
|
|
27
|
+
{ header: "cf-cache-status", attr: "cf.cache.status" },
|
|
28
|
+
{ header: "cf-ray", attr: "cf.ray" },
|
|
29
|
+
{ header: "x-vtex-io-cluster-id", attr: "vtex.io.cluster.id" },
|
|
30
|
+
{ header: "x-edge-cache-status", attr: "edge.cache.status" },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const TRUE_LITERAL = "true";
|
|
34
|
+
|
|
35
|
+
function envFlag(name: string): boolean {
|
|
36
|
+
const env = typeof globalThis.process !== "undefined" ? globalThis.process.env : undefined;
|
|
37
|
+
return env?.[name] === TRUE_LITERAL;
|
|
38
|
+
}
|
|
19
39
|
|
|
20
40
|
export interface FetchInstrumentationOptions {
|
|
21
41
|
/** Tag for log/trace grouping (e.g., "vtex", "shopify"). */
|
|
@@ -85,6 +105,32 @@ export function createInstrumentedFetch(
|
|
|
85
105
|
);
|
|
86
106
|
}
|
|
87
107
|
|
|
108
|
+
// Structured outgoing-fetch breadcrumb. Same field shape as the Fresh
|
|
109
|
+
// `@deco/deco/o11y` impl so log pipelines built off the old stack
|
|
110
|
+
// keep working unchanged. Off by default to avoid log explosion;
|
|
111
|
+
// enable with `OTEL_LOG_OUTGOING_FETCH=true`.
|
|
112
|
+
if (envFlag("OTEL_LOG_OUTGOING_FETCH")) {
|
|
113
|
+
let host = "";
|
|
114
|
+
let path = "";
|
|
115
|
+
try {
|
|
116
|
+
const u = new URL(url);
|
|
117
|
+
host = u.host;
|
|
118
|
+
path = u.pathname;
|
|
119
|
+
} catch {
|
|
120
|
+
/* unparseable URL — leave host/path blank */
|
|
121
|
+
}
|
|
122
|
+
logger.info("outgoing fetch", {
|
|
123
|
+
app: name,
|
|
124
|
+
host,
|
|
125
|
+
path,
|
|
126
|
+
method,
|
|
127
|
+
status: response.status,
|
|
128
|
+
ok: response.ok,
|
|
129
|
+
durationMs: Math.round(durationMs),
|
|
130
|
+
cached,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
88
134
|
onComplete?.({
|
|
89
135
|
name,
|
|
90
136
|
url,
|
|
@@ -108,6 +154,16 @@ export function createInstrumentedFetch(
|
|
|
108
154
|
|
|
109
155
|
try {
|
|
110
156
|
const response = await doFetch();
|
|
157
|
+
// Promote CF / VTEX cache headers as span attributes — the plan
|
|
158
|
+
// calls out these four. `@microlabs/otel-cf-workers` does not
|
|
159
|
+
// expose the response inside its own fetch span lifecycle, so
|
|
160
|
+
// capturing them here on our wrapper span is the practical
|
|
161
|
+
// place to do it.
|
|
162
|
+
for (const { header, attr } of CACHE_HEADERS_TO_SPAN) {
|
|
163
|
+
const value = response.headers.get(header);
|
|
164
|
+
if (value) span.setAttribute?.(attr, value);
|
|
165
|
+
}
|
|
166
|
+
span.setAttribute?.("http.status_code", response.status);
|
|
111
167
|
span.end();
|
|
112
168
|
return response;
|
|
113
169
|
} catch (error) {
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
configureLogger,
|
|
4
|
+
defaultLoggerAdapter,
|
|
5
|
+
getLoggerAdapter,
|
|
6
|
+
getLogLevel,
|
|
7
|
+
type LoggerAdapter,
|
|
8
|
+
logger,
|
|
9
|
+
setLogLevel,
|
|
10
|
+
} from "./logger";
|
|
11
|
+
|
|
12
|
+
describe("defaultLoggerAdapter", () => {
|
|
13
|
+
let logSpy: ReturnType<typeof vi.spyOn>;
|
|
14
|
+
let warnSpy: ReturnType<typeof vi.spyOn>;
|
|
15
|
+
let errorSpy: ReturnType<typeof vi.spyOn>;
|
|
16
|
+
let debugSpy: ReturnType<typeof vi.spyOn>;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
20
|
+
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
21
|
+
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
22
|
+
debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.restoreAllMocks();
|
|
27
|
+
configureLogger(defaultLoggerAdapter);
|
|
28
|
+
setLogLevel("debug"); // permissive default for the rest of the suite
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("emits one JSON line per call", () => {
|
|
32
|
+
defaultLoggerAdapter.log("info", "hello", { foo: 1 });
|
|
33
|
+
expect(logSpy).toHaveBeenCalledOnce();
|
|
34
|
+
const arg = logSpy.mock.calls[0]?.[0] as string;
|
|
35
|
+
const parsed = JSON.parse(arg);
|
|
36
|
+
expect(parsed).toMatchObject({ level: "info", msg: "hello", foo: 1 });
|
|
37
|
+
expect(typeof parsed.timestamp).toBe("string");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("routes by level so CF Logs colorises correctly", () => {
|
|
41
|
+
defaultLoggerAdapter.log("error", "boom");
|
|
42
|
+
defaultLoggerAdapter.log("warn", "careful");
|
|
43
|
+
defaultLoggerAdapter.log("debug", "details");
|
|
44
|
+
expect(errorSpy).toHaveBeenCalledOnce();
|
|
45
|
+
expect(warnSpy).toHaveBeenCalledOnce();
|
|
46
|
+
expect(debugSpy).toHaveBeenCalledOnce();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("never throws on circular refs (last-resort fallback)", () => {
|
|
50
|
+
const circ: any = { name: "x" };
|
|
51
|
+
circ.self = circ;
|
|
52
|
+
expect(() => defaultLoggerAdapter.log("info", "circ", { circ })).not.toThrow();
|
|
53
|
+
expect(logSpy).toHaveBeenCalledOnce();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("does not let attrs override level/msg/timestamp keys", () => {
|
|
57
|
+
defaultLoggerAdapter.log("info", "real-msg", {
|
|
58
|
+
level: "tampered",
|
|
59
|
+
msg: "tampered",
|
|
60
|
+
timestamp: "tampered",
|
|
61
|
+
});
|
|
62
|
+
const parsed = JSON.parse(logSpy.mock.calls[0]?.[0] as string);
|
|
63
|
+
expect(parsed.level).toBe("info");
|
|
64
|
+
expect(parsed.msg).toBe("real-msg");
|
|
65
|
+
expect(parsed.timestamp).not.toBe("tampered");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("level filtering", () => {
|
|
70
|
+
let logSpy: ReturnType<typeof vi.spyOn>;
|
|
71
|
+
let warnSpy: ReturnType<typeof vi.spyOn>;
|
|
72
|
+
let errorSpy: ReturnType<typeof vi.spyOn>;
|
|
73
|
+
let debugSpy: ReturnType<typeof vi.spyOn>;
|
|
74
|
+
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
77
|
+
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
78
|
+
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
79
|
+
debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
afterEach(() => {
|
|
83
|
+
vi.restoreAllMocks();
|
|
84
|
+
configureLogger(defaultLoggerAdapter);
|
|
85
|
+
setLogLevel("info");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("drops calls below the active min level", () => {
|
|
89
|
+
setLogLevel("warn");
|
|
90
|
+
expect(getLogLevel()).toBe("warn");
|
|
91
|
+
logger.debug("d");
|
|
92
|
+
logger.info("i");
|
|
93
|
+
logger.warn("w");
|
|
94
|
+
logger.error("e");
|
|
95
|
+
expect(debugSpy).not.toHaveBeenCalled();
|
|
96
|
+
expect(logSpy).not.toHaveBeenCalled(); // info routes to console.log
|
|
97
|
+
expect(warnSpy).toHaveBeenCalledOnce();
|
|
98
|
+
expect(errorSpy).toHaveBeenCalledOnce();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("configureLogger", () => {
|
|
103
|
+
afterEach(() => {
|
|
104
|
+
configureLogger(defaultLoggerAdapter);
|
|
105
|
+
setLogLevel("info");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("dispatches to the configured adapter", () => {
|
|
109
|
+
const calls: Array<[string, string, unknown]> = [];
|
|
110
|
+
const test: LoggerAdapter = {
|
|
111
|
+
log(level, msg, attrs) {
|
|
112
|
+
calls.push([level, msg, attrs]);
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
configureLogger(test);
|
|
116
|
+
expect(getLoggerAdapter()).toBe(test);
|
|
117
|
+
|
|
118
|
+
logger.info("hello", { x: 1 });
|
|
119
|
+
expect(calls).toEqual([["info", "hello", { x: 1 }]]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("falls back to default adapter if active adapter throws", () => {
|
|
123
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
124
|
+
const broken: LoggerAdapter = {
|
|
125
|
+
log() {
|
|
126
|
+
throw new Error("nope");
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
configureLogger(broken);
|
|
130
|
+
expect(() => logger.info("survives", { x: 1 })).not.toThrow();
|
|
131
|
+
// Default adapter was invoked as the fallback
|
|
132
|
+
expect(logSpy).toHaveBeenCalledOnce();
|
|
133
|
+
logSpy.mockRestore();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -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
|
+
});
|