@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
package/src/index.ts
CHANGED
|
@@ -3,4 +3,7 @@ export * from "./admin/index";
|
|
|
3
3
|
export * from "./cms/index";
|
|
4
4
|
export * from "./hooks/index";
|
|
5
5
|
export * from "./middleware/index";
|
|
6
|
+
// Observability surface — logger + instrumentWorker live behind their own
|
|
7
|
+
// granular imports too (see `@decocms/start/sdk/logger`, `.../observability`).
|
|
8
|
+
export { type Logger, type LogLevel, logger, setLogLevel } from "./sdk/logger";
|
|
6
9
|
export * from "./types/index";
|
package/src/sdk/cachedLoader.ts
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* (e.g. "product") which derives timing from the unified profile system.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import { recordCacheMetric, withTracing } from "../middleware/observability";
|
|
15
|
+
import { type CacheProfileName, loaderCacheOptions } from "./cacheHeaders";
|
|
15
16
|
|
|
16
17
|
export type CachePolicy = "no-store" | "no-cache" | "stale-while-revalidate";
|
|
17
18
|
|
|
@@ -85,12 +86,7 @@ export function createCachedLoader<TProps, TResult>(
|
|
|
85
86
|
optionsOrProfile: CachedLoaderOptions | CacheProfileName,
|
|
86
87
|
): (props: TProps) => Promise<TResult> {
|
|
87
88
|
const resolved = resolveOptions(optionsOrProfile);
|
|
88
|
-
const {
|
|
89
|
-
policy,
|
|
90
|
-
maxAge = DEFAULT_MAX_AGE,
|
|
91
|
-
staleIfError = 0,
|
|
92
|
-
keyFn = JSON.stringify,
|
|
93
|
-
} = resolved;
|
|
89
|
+
const { policy, maxAge = DEFAULT_MAX_AGE, staleIfError = 0, keyFn = JSON.stringify } = resolved;
|
|
94
90
|
|
|
95
91
|
const env = typeof globalThis.process !== "undefined" ? globalThis.process.env : undefined;
|
|
96
92
|
const isDev = env?.DECO_CACHE_DISABLE === "true" || env?.NODE_ENV === "development";
|
|
@@ -101,10 +97,20 @@ export function createCachedLoader<TProps, TResult>(
|
|
|
101
97
|
const cacheKey = `${name}::${keyFn(props)}`;
|
|
102
98
|
|
|
103
99
|
const inflight = inflightRequests.get(cacheKey);
|
|
104
|
-
if (inflight)
|
|
100
|
+
if (inflight) {
|
|
101
|
+
// Treat in-flight dedup as a cache hit — avoided the origin call.
|
|
102
|
+
recordCacheMetric(true, name);
|
|
103
|
+
return inflight as Promise<TResult>;
|
|
104
|
+
}
|
|
105
105
|
|
|
106
106
|
if (isDev) {
|
|
107
|
-
|
|
107
|
+
// Dev mode: no caching, but still useful to count attempts.
|
|
108
|
+
recordCacheMetric(false, name);
|
|
109
|
+
const promise = withTracing(
|
|
110
|
+
"deco.cachedLoader",
|
|
111
|
+
() => loaderFn(props).finally(() => inflightRequests.delete(cacheKey)),
|
|
112
|
+
{ "deco.loader": name, "deco.cache.policy": "no-cache-dev" },
|
|
113
|
+
);
|
|
108
114
|
inflightRequests.set(cacheKey, promise);
|
|
109
115
|
return promise;
|
|
110
116
|
}
|
|
@@ -114,13 +120,21 @@ export function createCachedLoader<TProps, TResult>(
|
|
|
114
120
|
const isStale = entry ? now - entry.createdAt > maxAge : true;
|
|
115
121
|
|
|
116
122
|
if (policy === "no-cache") {
|
|
117
|
-
if (entry && !isStale)
|
|
123
|
+
if (entry && !isStale) {
|
|
124
|
+
recordCacheMetric(true, name);
|
|
125
|
+
return entry.value;
|
|
126
|
+
}
|
|
118
127
|
}
|
|
119
128
|
|
|
120
129
|
if (policy === "stale-while-revalidate") {
|
|
121
|
-
if (entry && !isStale)
|
|
130
|
+
if (entry && !isStale) {
|
|
131
|
+
recordCacheMetric(true, name);
|
|
132
|
+
return entry.value;
|
|
133
|
+
}
|
|
122
134
|
|
|
123
135
|
if (entry && isStale && !entry.refreshing) {
|
|
136
|
+
// Stale-while-revalidate hit: serve stale, refresh in background.
|
|
137
|
+
recordCacheMetric(true, name);
|
|
124
138
|
entry.refreshing = true;
|
|
125
139
|
loaderFn(props)
|
|
126
140
|
.then((result) => {
|
|
@@ -141,10 +155,19 @@ export function createCachedLoader<TProps, TResult>(
|
|
|
141
155
|
return entry.value;
|
|
142
156
|
}
|
|
143
157
|
|
|
144
|
-
if (entry)
|
|
158
|
+
if (entry) {
|
|
159
|
+
recordCacheMetric(true, name);
|
|
160
|
+
return entry.value;
|
|
161
|
+
}
|
|
145
162
|
}
|
|
146
163
|
|
|
147
|
-
|
|
164
|
+
// Cache miss — emit metric, then run loader inside a span so individual
|
|
165
|
+
// slow loaders are visible in traces.
|
|
166
|
+
recordCacheMetric(false, name);
|
|
167
|
+
const promise = withTracing("deco.cachedLoader", () => loaderFn(props), {
|
|
168
|
+
"deco.loader": name,
|
|
169
|
+
"deco.cache.policy": policy,
|
|
170
|
+
})
|
|
148
171
|
.then((result) => {
|
|
149
172
|
cache.set(cacheKey, {
|
|
150
173
|
value: result,
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { MeterAdapter } from "../middleware/observability";
|
|
3
|
+
import { createCompositeLogger, createCompositeMeter } from "./composite";
|
|
4
|
+
import type { LoggerAdapter } from "./logger";
|
|
5
|
+
|
|
6
|
+
describe("createCompositeLogger", () => {
|
|
7
|
+
afterEach(() => vi.restoreAllMocks());
|
|
8
|
+
|
|
9
|
+
it("fans calls out to every adapter", () => {
|
|
10
|
+
const aCalls: any[][] = [];
|
|
11
|
+
const bCalls: any[][] = [];
|
|
12
|
+
const a: LoggerAdapter = { log: (...args) => void aCalls.push(args) };
|
|
13
|
+
const b: LoggerAdapter = { log: (...args) => void bCalls.push(args) };
|
|
14
|
+
const composite = createCompositeLogger([a, b]);
|
|
15
|
+
|
|
16
|
+
composite.log("info", "hello", { foo: 1 });
|
|
17
|
+
|
|
18
|
+
expect(aCalls).toEqual([["info", "hello", { foo: 1 }]]);
|
|
19
|
+
expect(bCalls).toEqual([["info", "hello", { foo: 1 }]]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("filters falsy entries (null/undefined/false)", () => {
|
|
23
|
+
const calls: any[] = [];
|
|
24
|
+
const a: LoggerAdapter = { log: (...args) => void calls.push(args) };
|
|
25
|
+
const composite = createCompositeLogger([null, a, undefined, false]);
|
|
26
|
+
composite.log("info", "ok");
|
|
27
|
+
// adapter receives the call with no third arg (undefined is omitted)
|
|
28
|
+
expect(calls.length).toBe(1);
|
|
29
|
+
expect(calls[0][0]).toBe("info");
|
|
30
|
+
expect(calls[0][1]).toBe("ok");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("isolates errors so one bad adapter does not block others", () => {
|
|
34
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
35
|
+
const okCalls: any[][] = [];
|
|
36
|
+
const broken: LoggerAdapter = {
|
|
37
|
+
log() {
|
|
38
|
+
throw new Error("boom");
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
const ok: LoggerAdapter = { log: (...args) => void okCalls.push(args) };
|
|
42
|
+
const composite = createCompositeLogger([broken, ok]);
|
|
43
|
+
|
|
44
|
+
expect(() => composite.log("error", "still goes through")).not.toThrow();
|
|
45
|
+
expect(okCalls.length).toBe(1);
|
|
46
|
+
expect(okCalls[0][0]).toBe("error");
|
|
47
|
+
expect(okCalls[0][1]).toBe("still goes through");
|
|
48
|
+
expect(errSpy).toHaveBeenCalled(); // composite reports the failure
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("returns the single adapter directly when only one is provided", () => {
|
|
52
|
+
const a: LoggerAdapter = { log: () => {} };
|
|
53
|
+
expect(createCompositeLogger([a])).toBe(a);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("createCompositeMeter", () => {
|
|
58
|
+
afterEach(() => vi.restoreAllMocks());
|
|
59
|
+
|
|
60
|
+
function recorder() {
|
|
61
|
+
const counter: any[] = [];
|
|
62
|
+
const gauge: any[] = [];
|
|
63
|
+
const histo: any[] = [];
|
|
64
|
+
const meter: MeterAdapter = {
|
|
65
|
+
counterInc: (n, v, l) => void counter.push([n, v, l]),
|
|
66
|
+
gaugeSet: (n, v, l) => void gauge.push([n, v, l]),
|
|
67
|
+
histogramRecord: (n, v, l) => void histo.push([n, v, l]),
|
|
68
|
+
};
|
|
69
|
+
return { meter, counter, gauge, histo };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
it("fans counter/gauge/histogram across meters", () => {
|
|
73
|
+
const a = recorder();
|
|
74
|
+
const b = recorder();
|
|
75
|
+
const composite = createCompositeMeter([a.meter, b.meter]);
|
|
76
|
+
|
|
77
|
+
composite.counterInc("c", 1, { p: "/" });
|
|
78
|
+
composite.gaugeSet?.("g", 5);
|
|
79
|
+
composite.histogramRecord?.("h", 100, { route: "/p" });
|
|
80
|
+
|
|
81
|
+
expect(a.counter[0].slice(0, 2)).toEqual(["c", 1]);
|
|
82
|
+
expect(a.counter[0][2]).toEqual({ p: "/" });
|
|
83
|
+
expect(b.counter[0].slice(0, 2)).toEqual(["c", 1]);
|
|
84
|
+
expect(a.gauge[0].slice(0, 2)).toEqual(["g", 5]);
|
|
85
|
+
expect(b.gauge[0].slice(0, 2)).toEqual(["g", 5]);
|
|
86
|
+
expect(a.histo[0]).toEqual(["h", 100, { route: "/p" }]);
|
|
87
|
+
expect(b.histo[0]).toEqual(["h", 100, { route: "/p" }]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("isolates errors per meter and per call type", () => {
|
|
91
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
92
|
+
const broken: MeterAdapter = {
|
|
93
|
+
counterInc: () => {
|
|
94
|
+
throw new Error("c");
|
|
95
|
+
},
|
|
96
|
+
gaugeSet: () => {
|
|
97
|
+
throw new Error("g");
|
|
98
|
+
},
|
|
99
|
+
histogramRecord: () => {
|
|
100
|
+
throw new Error("h");
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
const ok = recorder();
|
|
104
|
+
const composite = createCompositeMeter([broken, ok.meter]);
|
|
105
|
+
|
|
106
|
+
composite.counterInc("c", 1);
|
|
107
|
+
composite.gaugeSet?.("g", 5);
|
|
108
|
+
composite.histogramRecord?.("h", 100);
|
|
109
|
+
|
|
110
|
+
expect(ok.counter[0].slice(0, 2)).toEqual(["c", 1]);
|
|
111
|
+
expect(ok.gauge[0].slice(0, 2)).toEqual(["g", 5]);
|
|
112
|
+
expect(ok.histo[0].slice(0, 2)).toEqual(["h", 100]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("skips meters that don't implement optional ops", () => {
|
|
116
|
+
const onlyCounter: MeterAdapter = { counterInc: () => {} };
|
|
117
|
+
const composite = createCompositeMeter([onlyCounter]);
|
|
118
|
+
expect(() => composite.gaugeSet?.("g", 1)).not.toThrow();
|
|
119
|
+
expect(() => composite.histogramRecord?.("h", 1)).not.toThrow();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -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
|
+
});
|