@decocms/blocks 7.30.0 → 7.31.1
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/package.json +1 -1
- package/src/cms/draftSource.test.ts +88 -29
- package/src/cms/draftSource.ts +24 -8
- package/src/sdk/observability.ts +7 -1
- package/src/sdk/otel.test.ts +111 -1
- package/src/sdk/otel.ts +93 -18
- package/src/sdk/otelHttpLog.ts +4 -4
- package/src/sdk/otelHttpMeter.ts +8 -5
- package/src/sdk/otelHttpTracer.ts +4 -4
package/package.json
CHANGED
|
@@ -75,27 +75,45 @@ describe("previewApiOriginForHost", () => {
|
|
|
75
75
|
expect(previewApiOriginForHost("abc.local.studio.decocms.com", {})).toBe(
|
|
76
76
|
"https://abc.local.studio.decocms.com",
|
|
77
77
|
);
|
|
78
|
-
expect(previewApiOriginForHost("abc.localhost:60534", {})).toBe(
|
|
78
|
+
expect(previewApiOriginForHost("abc.localhost:60534", {})).toBe(
|
|
79
|
+
"http://abc.localhost:60534",
|
|
80
|
+
);
|
|
81
|
+
// -stg is its own suffix, not a substring match of .preview-studio.decocms.com —
|
|
82
|
+
// both must be listed explicitly (regressed once: staging draft pointers
|
|
83
|
+
// silently fell back to published content with no visible error).
|
|
84
|
+
expect(
|
|
85
|
+
previewApiOriginForHost("abc.preview-studio-stg.decocms.com", {}),
|
|
86
|
+
).toBe("https://abc.preview-studio-stg.decocms.com");
|
|
79
87
|
});
|
|
80
88
|
|
|
81
89
|
it("rejects hosts outside the domains — the token proposes, config disposes", () => {
|
|
82
90
|
expect(previewApiOriginForHost("evil.example", {})).toBeNull();
|
|
83
91
|
// Dot-prefixed suffixes guarantee a label boundary: a lookalike domain
|
|
84
92
|
// that merely ends with the same characters cannot pass.
|
|
85
|
-
expect(
|
|
93
|
+
expect(
|
|
94
|
+
previewApiOriginForHost("evil-preview-studio.decocms.com", {}),
|
|
95
|
+
).toBeNull();
|
|
86
96
|
// The domain itself (no label in front) is not a draft host.
|
|
87
|
-
expect(
|
|
97
|
+
expect(
|
|
98
|
+
previewApiOriginForHost("preview-studio.decocms.com", {}),
|
|
99
|
+
).toBeNull();
|
|
88
100
|
});
|
|
89
101
|
|
|
90
102
|
it("allows an explicit port only under localhost-ish domains", () => {
|
|
91
103
|
// A public-domain token must not steer the fetch at odd ports.
|
|
92
|
-
expect(
|
|
104
|
+
expect(
|
|
105
|
+
previewApiOriginForHost("abc.preview-studio.decocms.com:8500", {}),
|
|
106
|
+
).toBeNull();
|
|
93
107
|
});
|
|
94
108
|
|
|
95
109
|
it("honours a configured override instead of the defaults", () => {
|
|
96
110
|
const env = { DECO_PREVIEW_API_DOMAINS: ".staging.example" };
|
|
97
|
-
expect(previewApiOriginForHost("abc.staging.example", env)).toBe(
|
|
98
|
-
|
|
111
|
+
expect(previewApiOriginForHost("abc.staging.example", env)).toBe(
|
|
112
|
+
"https://abc.staging.example",
|
|
113
|
+
);
|
|
114
|
+
expect(
|
|
115
|
+
previewApiOriginForHost("abc.preview-studio.decocms.com", env),
|
|
116
|
+
).toBeNull();
|
|
99
117
|
});
|
|
100
118
|
});
|
|
101
119
|
|
|
@@ -129,7 +147,9 @@ describe("resolveDraftDecofile", () => {
|
|
|
129
147
|
});
|
|
130
148
|
|
|
131
149
|
expect(blocks).toEqual({ "pages-home": { title: "draft" } });
|
|
132
|
-
expect(calls).toEqual([
|
|
150
|
+
expect(calls).toEqual([
|
|
151
|
+
"https://abc.preview-studio.decocms.com/_sandbox/decofile",
|
|
152
|
+
]);
|
|
133
153
|
});
|
|
134
154
|
|
|
135
155
|
it("is inert without a host allowlist — no fetch at all", async () => {
|
|
@@ -168,8 +188,16 @@ describe("resolveDraftDecofile", () => {
|
|
|
168
188
|
}) as unknown as typeof fetch;
|
|
169
189
|
const P = "abc.preview-studio.decocms.com";
|
|
170
190
|
|
|
171
|
-
const a = await resolveDraftDecofile({
|
|
172
|
-
|
|
191
|
+
const a = await resolveDraftDecofile({
|
|
192
|
+
pointer: `${P}@v1`,
|
|
193
|
+
env: ENV_ON,
|
|
194
|
+
fetchImpl,
|
|
195
|
+
});
|
|
196
|
+
const b = await resolveDraftDecofile({
|
|
197
|
+
pointer: `${P}@v1`,
|
|
198
|
+
env: ENV_ON,
|
|
199
|
+
fetchImpl,
|
|
200
|
+
});
|
|
173
201
|
expect(fetches).toBe(1);
|
|
174
202
|
expect(b).toBe(a);
|
|
175
203
|
|
|
@@ -186,7 +214,11 @@ describe("resolveDraftDecofile", () => {
|
|
|
186
214
|
const P = "abc.preview-studio.decocms.com";
|
|
187
215
|
|
|
188
216
|
for (const v of ["v1", "v2", "v3", "v4"]) {
|
|
189
|
-
await resolveDraftDecofile({
|
|
217
|
+
await resolveDraftDecofile({
|
|
218
|
+
pointer: `${P}@${v}`,
|
|
219
|
+
env: ENV_ON,
|
|
220
|
+
fetchImpl,
|
|
221
|
+
});
|
|
190
222
|
}
|
|
191
223
|
expect(fetches).toBe(4);
|
|
192
224
|
await resolveDraftDecofile({ pointer: `${P}@v1`, env: ENV_ON, fetchImpl });
|
|
@@ -225,6 +257,7 @@ describe("DEFAULT_PREVIEW_API_DOMAINS", () => {
|
|
|
225
257
|
it("ships the deco-operated origins, dot-prefixed", () => {
|
|
226
258
|
expect(DEFAULT_PREVIEW_API_DOMAINS).toEqual([
|
|
227
259
|
".preview-studio.decocms.com",
|
|
260
|
+
".preview-studio-stg.decocms.com",
|
|
228
261
|
".local.studio.decocms.com",
|
|
229
262
|
".localhost",
|
|
230
263
|
]);
|
|
@@ -261,10 +294,17 @@ describe("site-block preview hosts", () => {
|
|
|
261
294
|
|
|
262
295
|
describe("draftPointerFromRequest", () => {
|
|
263
296
|
it("reads the pointer from the __deco_draft cookie (in-preview navigation)", () => {
|
|
264
|
-
const req = new Request(
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
297
|
+
const req = new Request(
|
|
298
|
+
"https://preview.example/deco/invoke/site/loaders/x.ts",
|
|
299
|
+
{
|
|
300
|
+
headers: {
|
|
301
|
+
cookie: "a=1; __deco_draft=abc.preview-studio.decocms.com@v1; b=2",
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
);
|
|
305
|
+
expect(draftPointerFromRequest(req)).toBe(
|
|
306
|
+
"abc.preview-studio.decocms.com@v1",
|
|
307
|
+
);
|
|
268
308
|
});
|
|
269
309
|
|
|
270
310
|
it("lets ?__draft= win over the cookie", () => {
|
|
@@ -282,26 +322,35 @@ describe("draftPointerFromRequest", () => {
|
|
|
282
322
|
});
|
|
283
323
|
|
|
284
324
|
it("returns null with neither param nor cookie", () => {
|
|
285
|
-
expect(
|
|
325
|
+
expect(
|
|
326
|
+
draftPointerFromRequest(new Request("https://preview.example/p")),
|
|
327
|
+
).toBeNull();
|
|
286
328
|
});
|
|
287
329
|
});
|
|
288
330
|
|
|
289
331
|
describe("resolveDraftForRequest", () => {
|
|
290
332
|
const ENV = { DECO_ALLOWED_PREVIEW_HOSTS: "preview.example" };
|
|
291
333
|
function invokeReq(host = "preview.example"): Request {
|
|
292
|
-
return new Request(
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
"
|
|
296
|
-
|
|
334
|
+
return new Request(
|
|
335
|
+
"https://preview.example/deco/invoke/site/loaders/x.ts",
|
|
336
|
+
{
|
|
337
|
+
method: "POST",
|
|
338
|
+
headers: {
|
|
339
|
+
"x-forwarded-host": host,
|
|
340
|
+
cookie: "__deco_draft=abc.preview-studio.decocms.com@v1",
|
|
341
|
+
},
|
|
297
342
|
},
|
|
298
|
-
|
|
343
|
+
);
|
|
299
344
|
}
|
|
300
345
|
|
|
301
346
|
it("binds the draft when host is allowed and the pointer resolves", async () => {
|
|
302
347
|
const fetchImpl = (async () =>
|
|
303
|
-
jsonResponse({
|
|
304
|
-
|
|
348
|
+
jsonResponse({
|
|
349
|
+
"site/x": { value: "draft" },
|
|
350
|
+
})) as unknown as typeof fetch;
|
|
351
|
+
expect(
|
|
352
|
+
await resolveDraftForRequest(invokeReq(), { env: ENV, fetchImpl }),
|
|
353
|
+
).toEqual({
|
|
305
354
|
"site/x": { value: "draft" },
|
|
306
355
|
});
|
|
307
356
|
});
|
|
@@ -312,7 +361,9 @@ describe("resolveDraftForRequest", () => {
|
|
|
312
361
|
called = true;
|
|
313
362
|
return jsonResponse({});
|
|
314
363
|
}) as unknown as typeof fetch;
|
|
315
|
-
expect(
|
|
364
|
+
expect(
|
|
365
|
+
await resolveDraftForRequest(invokeReq(), { env: {}, fetchImpl }),
|
|
366
|
+
).toBeNull();
|
|
316
367
|
expect(called).toBe(false);
|
|
317
368
|
});
|
|
318
369
|
|
|
@@ -323,21 +374,29 @@ describe("resolveDraftForRequest", () => {
|
|
|
323
374
|
return jsonResponse({});
|
|
324
375
|
}) as unknown as typeof fetch;
|
|
325
376
|
expect(
|
|
326
|
-
await resolveDraftForRequest(invokeReq("prod.example"), {
|
|
377
|
+
await resolveDraftForRequest(invokeReq("prod.example"), {
|
|
378
|
+
env: ENV,
|
|
379
|
+
fetchImpl,
|
|
380
|
+
}),
|
|
327
381
|
).toBeNull();
|
|
328
382
|
expect(called).toBe(false);
|
|
329
383
|
});
|
|
330
384
|
|
|
331
385
|
it("returns null when the request carries no draft pointer", async () => {
|
|
332
|
-
const req = new Request(
|
|
333
|
-
|
|
334
|
-
|
|
386
|
+
const req = new Request(
|
|
387
|
+
"https://preview.example/deco/invoke/site/loaders/x.ts",
|
|
388
|
+
{
|
|
389
|
+
headers: { "x-forwarded-host": "preview.example" },
|
|
390
|
+
},
|
|
391
|
+
);
|
|
335
392
|
let called = false;
|
|
336
393
|
const fetchImpl = (async () => {
|
|
337
394
|
called = true;
|
|
338
395
|
return jsonResponse({});
|
|
339
396
|
}) as unknown as typeof fetch;
|
|
340
|
-
expect(
|
|
397
|
+
expect(
|
|
398
|
+
await resolveDraftForRequest(req, { env: ENV, fetchImpl }),
|
|
399
|
+
).toBeNull();
|
|
341
400
|
expect(called).toBe(false);
|
|
342
401
|
});
|
|
343
402
|
});
|
package/src/cms/draftSource.ts
CHANGED
|
@@ -39,7 +39,8 @@ export interface DraftPointer {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/** Lowercase DNS hostname, at least two labels (a bare label can't match any domain). */
|
|
42
|
-
const HOST_RE =
|
|
42
|
+
const HOST_RE =
|
|
43
|
+
/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
|
|
43
44
|
const PORT_RE = /^[0-9]{1,5}$/;
|
|
44
45
|
const VERSION_RE = /^[A-Za-z0-9._-]{1,64}$/;
|
|
45
46
|
|
|
@@ -48,7 +49,9 @@ const VERSION_RE = /^[A-Za-z0-9._-]{1,64}$/;
|
|
|
48
49
|
* back to published content. Requires EXACTLY one `@`: a naive split accepts
|
|
49
50
|
* `a@b@c` and silently uses the first two segments.
|
|
50
51
|
*/
|
|
51
|
-
export function parseDraftPointer(
|
|
52
|
+
export function parseDraftPointer(
|
|
53
|
+
raw: string | null | undefined,
|
|
54
|
+
): DraftPointer | null {
|
|
52
55
|
if (!raw) return null;
|
|
53
56
|
const parts = raw.split("@");
|
|
54
57
|
if (parts.length !== 2) return null;
|
|
@@ -71,6 +74,7 @@ export function parseDraftPointer(raw: string | null | undefined): DraftPointer
|
|
|
71
74
|
*/
|
|
72
75
|
export const DEFAULT_PREVIEW_API_DOMAINS = [
|
|
73
76
|
".preview-studio.decocms.com",
|
|
77
|
+
".preview-studio-stg.decocms.com",
|
|
74
78
|
".local.studio.decocms.com",
|
|
75
79
|
".localhost",
|
|
76
80
|
];
|
|
@@ -161,7 +165,9 @@ export function isDraftHostAllowed(
|
|
|
161
165
|
env?: Record<string, string | undefined>,
|
|
162
166
|
): boolean {
|
|
163
167
|
if (!host) return false;
|
|
164
|
-
return readAllowedHosts(envOrProcess(env)).includes(
|
|
168
|
+
return readAllowedHosts(envOrProcess(env)).includes(
|
|
169
|
+
host.trim().toLowerCase(),
|
|
170
|
+
);
|
|
165
171
|
}
|
|
166
172
|
|
|
167
173
|
/**
|
|
@@ -170,7 +176,9 @@ export function isDraftHostAllowed(
|
|
|
170
176
|
* unconfigured site never loses static/ISR rendering. The per-request host
|
|
171
177
|
* match happens later, in `isDraftHostAllowed`.
|
|
172
178
|
*/
|
|
173
|
-
export function isDraftPreviewEnabled(
|
|
179
|
+
export function isDraftPreviewEnabled(
|
|
180
|
+
env?: Record<string, string | undefined>,
|
|
181
|
+
): boolean {
|
|
174
182
|
return readAllowedHosts(envOrProcess(env)).length > 0;
|
|
175
183
|
}
|
|
176
184
|
|
|
@@ -179,7 +187,8 @@ function envOrProcess(
|
|
|
179
187
|
): Record<string, string | undefined> {
|
|
180
188
|
return (
|
|
181
189
|
env ??
|
|
182
|
-
(globalThis as { process?: { env?: Record<string, string | undefined> } })
|
|
190
|
+
(globalThis as { process?: { env?: Record<string, string | undefined> } })
|
|
191
|
+
.process?.env ??
|
|
183
192
|
{}
|
|
184
193
|
);
|
|
185
194
|
}
|
|
@@ -277,7 +286,10 @@ export const DRAFT_COOKIE_NAME = "__deco_draft";
|
|
|
277
286
|
export const DRAFT_QUERY_PARAM = "__draft";
|
|
278
287
|
|
|
279
288
|
/** Read one cookie value out of a raw `Cookie:` header. */
|
|
280
|
-
function readCookieValue(
|
|
289
|
+
function readCookieValue(
|
|
290
|
+
cookieHeader: string | null | undefined,
|
|
291
|
+
name: string,
|
|
292
|
+
): string | null {
|
|
281
293
|
if (!cookieHeader) return null;
|
|
282
294
|
for (const part of cookieHeader.split(";")) {
|
|
283
295
|
const eq = part.indexOf("=");
|
|
@@ -331,7 +343,8 @@ export async function resolveDraftForRequest(
|
|
|
331
343
|
if (readAllowedHosts(env).length === 0) return null;
|
|
332
344
|
const pointer = draftPointerFromRequest(request);
|
|
333
345
|
if (!pointer) return null;
|
|
334
|
-
const host =
|
|
346
|
+
const host =
|
|
347
|
+
request.headers.get("x-forwarded-host") ?? request.headers.get("host");
|
|
335
348
|
if (!isDraftHostAllowed(host, env)) return null;
|
|
336
349
|
return resolveDraftDecofile({ pointer, env, fetchImpl: options.fetchImpl });
|
|
337
350
|
}
|
|
@@ -357,6 +370,9 @@ export function setDraftOverrideGetter(getter: DraftOverrideGetter): void {
|
|
|
357
370
|
}
|
|
358
371
|
|
|
359
372
|
/** The current request's draft blocks, if a binding registered one. */
|
|
360
|
-
export function getRequestDraftOverride():
|
|
373
|
+
export function getRequestDraftOverride():
|
|
374
|
+
| Record<string, unknown>
|
|
375
|
+
| null
|
|
376
|
+
| undefined {
|
|
361
377
|
return getDraftOverride();
|
|
362
378
|
}
|
package/src/sdk/observability.ts
CHANGED
|
@@ -78,7 +78,13 @@ export {
|
|
|
78
78
|
withTracing,
|
|
79
79
|
} from "../middleware/observability";
|
|
80
80
|
// Worker-entry wrapper + adapter wiring
|
|
81
|
-
export {
|
|
81
|
+
export {
|
|
82
|
+
bootObservability,
|
|
83
|
+
flushObservability,
|
|
84
|
+
instrumentWorker,
|
|
85
|
+
instrumentWorkflowRun,
|
|
86
|
+
type OtelOptions,
|
|
87
|
+
} from "./otel";
|
|
82
88
|
// Direct-POST OTLP trace exporter (Phase 3 / D-12). Exported for sites
|
|
83
89
|
// that need to wire a custom traces endpoint outside `instrumentWorker`,
|
|
84
90
|
// and for the audit tooling that asserts framework spans are flowing.
|
package/src/sdk/otel.test.ts
CHANGED
|
@@ -14,7 +14,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
14
14
|
import * as composite from "./composite";
|
|
15
15
|
import * as logger from "./logger";
|
|
16
16
|
import * as observability from "./observability";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
_resetBootStateForTests,
|
|
19
|
+
flushObservability,
|
|
20
|
+
instrumentWorker,
|
|
21
|
+
instrumentWorkflowRun,
|
|
22
|
+
} from "./otel";
|
|
18
23
|
import * as adapters from "./otelAdapters";
|
|
19
24
|
import { createClickhouseCollectorAdapter } from "./otelAdapters/clickhouseCollector";
|
|
20
25
|
|
|
@@ -524,3 +529,108 @@ describe("instrumentWorker — OTLP/HTTP error-log channel wiring", () => {
|
|
|
524
529
|
expect(calls).toHaveLength(0);
|
|
525
530
|
});
|
|
526
531
|
});
|
|
532
|
+
|
|
533
|
+
describe("instrumentWorkflowRun / flushObservability — Workflow entrypoints", () => {
|
|
534
|
+
beforeEach(() => {
|
|
535
|
+
_resetBootStateForTests();
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
afterEach(() => {
|
|
539
|
+
_resetBootStateForTests();
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("flushObservability resolves without throwing when no adapters are configured", async () => {
|
|
543
|
+
await expect(flushObservability()).resolves.toBeUndefined();
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it("instrumentWorkflowRun returns fn's result and does not throw with no OTel env vars set", async () => {
|
|
547
|
+
const fn = vi.fn().mockResolvedValue("step-result");
|
|
548
|
+
const result = await instrumentWorkflowRun({}, undefined, fn);
|
|
549
|
+
|
|
550
|
+
expect(result).toBe("step-result");
|
|
551
|
+
expect(fn).toHaveBeenCalledOnce();
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
function makeFetchSpy() {
|
|
555
|
+
const calls: Array<{ url: string; body: string }> = [];
|
|
556
|
+
const impl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
|
557
|
+
calls.push({ url: String(input), body: String(init?.body ?? "") });
|
|
558
|
+
return new Response("{}", { status: 200 });
|
|
559
|
+
});
|
|
560
|
+
return { impl: impl as unknown as typeof fetch, calls };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
it("rethrows fn's rejection but still flushes buffered metrics", async () => {
|
|
564
|
+
const { impl, calls } = makeFetchSpy();
|
|
565
|
+
const fn = vi.fn(async () => {
|
|
566
|
+
observability.recordRequestMetric("WORKFLOW", "/batch-product", 500, 9);
|
|
567
|
+
throw new Error("workflow step failed");
|
|
568
|
+
});
|
|
569
|
+
const env = { DECO_OTEL_METRICS_ENDPOINT: "https://ingest.test/v1/metrics" };
|
|
570
|
+
|
|
571
|
+
await expect(
|
|
572
|
+
instrumentWorkflowRun(env, { otlpMetricsFetchImpl: impl }, fn),
|
|
573
|
+
).rejects.toThrow("workflow step failed");
|
|
574
|
+
|
|
575
|
+
expect(calls).toHaveLength(1);
|
|
576
|
+
expect(calls[0].url).toBe("https://ingest.test/v1/metrics");
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it("still flushes when fn throws synchronously instead of returning a rejected promise", async () => {
|
|
580
|
+
const { impl, calls } = makeFetchSpy();
|
|
581
|
+
const fn = vi.fn(() => {
|
|
582
|
+
observability.recordRequestMetric("WORKFLOW", "/batch-product", 500, 9);
|
|
583
|
+
throw new Error("sync boom");
|
|
584
|
+
}) as unknown as () => Promise<string>;
|
|
585
|
+
const env = { DECO_OTEL_METRICS_ENDPOINT: "https://ingest.test/v1/metrics" };
|
|
586
|
+
|
|
587
|
+
await expect(
|
|
588
|
+
instrumentWorkflowRun(env, { otlpMetricsFetchImpl: impl }, fn),
|
|
589
|
+
).rejects.toThrow("sync boom");
|
|
590
|
+
|
|
591
|
+
expect(calls).toHaveLength(1);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it("wires and flushes the OTLP metrics exporter, same as instrumentWorker's channel", async () => {
|
|
595
|
+
const { impl, calls } = makeFetchSpy();
|
|
596
|
+
const fn = vi.fn(async () => {
|
|
597
|
+
observability.recordRequestMetric("WORKFLOW", "/batch-product", 200, 12);
|
|
598
|
+
return "done";
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
const env = {
|
|
602
|
+
DECO_OTEL_METRICS_ENDPOINT: "https://ingest.test/v1/metrics",
|
|
603
|
+
} as unknown as Record<string, unknown>;
|
|
604
|
+
|
|
605
|
+
const result = await instrumentWorkflowRun(
|
|
606
|
+
env,
|
|
607
|
+
{ serviceName: "workflow-smoke", otlpMetricsFetchImpl: impl },
|
|
608
|
+
fn,
|
|
609
|
+
);
|
|
610
|
+
|
|
611
|
+
expect(result).toBe("done");
|
|
612
|
+
expect(calls).toHaveLength(1);
|
|
613
|
+
expect(calls[0].url).toBe("https://ingest.test/v1/metrics");
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it("forces the flush past the per-isolate cooldown across consecutive replay legs", async () => {
|
|
617
|
+
// Regression test: instrumentWorkflowRun forces its exit-time flush past
|
|
618
|
+
// the ~5s default cooldown each OTLP adapter otherwise enforces, since a
|
|
619
|
+
// Workflow run() only gets one flush attempt per leg — no next request
|
|
620
|
+
// to retry on like a fetch handler has.
|
|
621
|
+
const { impl, calls } = makeFetchSpy();
|
|
622
|
+
const env = { DECO_OTEL_METRICS_ENDPOINT: "https://ingest.test/v1/metrics" };
|
|
623
|
+
const opts = { otlpMetricsFetchImpl: impl };
|
|
624
|
+
|
|
625
|
+
await instrumentWorkflowRun(env, opts, async () => {
|
|
626
|
+
observability.recordRequestMetric("WORKFLOW", "/leg-1", 200, 5);
|
|
627
|
+
});
|
|
628
|
+
// A second replay leg immediately after — well within the default
|
|
629
|
+
// cooldown window — must still flush, not get silently skipped.
|
|
630
|
+
await instrumentWorkflowRun(env, opts, async () => {
|
|
631
|
+
observability.recordRequestMetric("WORKFLOW", "/leg-2", 200, 5);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
expect(calls).toHaveLength(2);
|
|
635
|
+
});
|
|
636
|
+
});
|
package/src/sdk/otel.ts
CHANGED
|
@@ -446,34 +446,98 @@ export function instrumentWorker(
|
|
|
446
446
|
// ctx.waitUntil so no POST blocks the response. Each exporter
|
|
447
447
|
// throttles itself per isolate — calling on every request is
|
|
448
448
|
// cheap; the network only fires when the cooldown elapses or
|
|
449
|
-
// the buffer fills.
|
|
449
|
+
// the buffer fills. Skip the waitUntil call entirely when no
|
|
450
|
+
// adapter is configured (nothing to flush).
|
|
450
451
|
const state = getBootState();
|
|
451
|
-
if (state.otlpMeter) {
|
|
452
|
+
if (state.otlpMeter || state.otlpLog || state.otlpTracer) {
|
|
452
453
|
try {
|
|
453
|
-
ctx.waitUntil(
|
|
454
|
+
ctx.waitUntil(flushObservability());
|
|
454
455
|
} catch {
|
|
455
456
|
/* ctx.waitUntil throwing is benign — never block the response */
|
|
456
457
|
}
|
|
457
458
|
}
|
|
458
|
-
if (state.otlpLog) {
|
|
459
|
-
try {
|
|
460
|
-
ctx.waitUntil(state.otlpLog.flush());
|
|
461
|
-
} catch {
|
|
462
|
-
/* swallow */
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
if (state.otlpTracer) {
|
|
466
|
-
try {
|
|
467
|
-
ctx.waitUntil(state.otlpTracer.flush());
|
|
468
|
-
} catch {
|
|
469
|
-
/* swallow */
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
459
|
}
|
|
473
460
|
},
|
|
474
461
|
};
|
|
475
462
|
}
|
|
476
463
|
|
|
464
|
+
/**
|
|
465
|
+
* Drains the OTLP metrics + error-log + traces buffers (whichever are
|
|
466
|
+
* configured for this isolate). Extracted out of `instrumentWorker`'s
|
|
467
|
+
* `finally` block so callers without a `{fetch}`/`ctx.waitUntil` lifecycle —
|
|
468
|
+
* e.g. Cloudflare Workflow `run()` handlers — can flush explicitly too. See
|
|
469
|
+
* `instrumentWorkflowRun` below.
|
|
470
|
+
*
|
|
471
|
+
* Each adapter's `flush()` is throttled by a per-isolate cooldown (default
|
|
472
|
+
* 5s) so a busy fetch handler doesn't fire a POST per request — pass
|
|
473
|
+
* `force: true` to bypass that cooldown for callers that only get one flush
|
|
474
|
+
* attempt with no next request to retry on (Workflow `run()` exit).
|
|
475
|
+
*/
|
|
476
|
+
export async function flushObservability(force = false): Promise<void> {
|
|
477
|
+
const state = getBootState();
|
|
478
|
+
await Promise.allSettled([
|
|
479
|
+
state.otlpMeter?.flush(force),
|
|
480
|
+
state.otlpLog?.flush(force),
|
|
481
|
+
state.otlpTracer?.flush(force),
|
|
482
|
+
]);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Boots observability (same env-var-driven wiring `instrumentWorker` uses)
|
|
487
|
+
* and runs `fn`, flushing on the way out — the Workflow-entrypoint
|
|
488
|
+
* equivalent of `instrumentWorker`. Cloudflare Workflows have no `{fetch}` /
|
|
489
|
+
* `ctx.waitUntil` lifecycle, so this awaits the flush directly instead.
|
|
490
|
+
*
|
|
491
|
+
* Deliberately does NOT open a root span around `fn()`. Code in a
|
|
492
|
+
* `WorkflowEntrypoint.run(event, step)` outside `step.do`/`step.sleep`
|
|
493
|
+
* re-executes on every hibernate/wake replay — a span opened here would get
|
|
494
|
+
* a fresh random trace ID on every replay leg, producing disconnected
|
|
495
|
+
* traces instead of one coherent run. Create spans for individual
|
|
496
|
+
* `step.do(...)` callbacks with `withTracing` instead (each step only truly
|
|
497
|
+
* runs once, since `step.do` memoizes across replays), tagging them with
|
|
498
|
+
* `event.instanceId` so a backend can group replay legs under one logical
|
|
499
|
+
* run.
|
|
500
|
+
*
|
|
501
|
+
* The exit-time flush is `force`d (bypasses the per-isolate cooldown other
|
|
502
|
+
* `flush()` callers respect) — a Workflow `run()` invocation gets exactly
|
|
503
|
+
* one flush attempt on the way out, unlike a fetch handler where the next
|
|
504
|
+
* request's `ctx.waitUntil(flush())` can pick up whatever a cooldown-skipped
|
|
505
|
+
* flush left behind. Without forcing, a workflow with several replay legs in
|
|
506
|
+
* quick succession could have its final leg's flush silently skipped by the
|
|
507
|
+
* cooldown, losing that leg's buffered spans/metrics if the isolate is torn
|
|
508
|
+
* down before the next wake.
|
|
509
|
+
*
|
|
510
|
+
* @example
|
|
511
|
+
* ```ts
|
|
512
|
+
* async run(event: WorkflowEvent<Params>, step: WorkflowStep) {
|
|
513
|
+
* return instrumentWorkflowRun(this.env, undefined, async () => {
|
|
514
|
+
* await step.do("fetch-product", () =>
|
|
515
|
+
* withTracing("deco.workflow.step", () => fetchProduct(), {
|
|
516
|
+
* "workflow.instance_id": event.instanceId,
|
|
517
|
+
* "workflow.step": "fetch-product",
|
|
518
|
+
* }),
|
|
519
|
+
* );
|
|
520
|
+
* });
|
|
521
|
+
* }
|
|
522
|
+
* ```
|
|
523
|
+
*/
|
|
524
|
+
export async function instrumentWorkflowRun<T>(
|
|
525
|
+
env: Record<string, unknown>,
|
|
526
|
+
options: OtelOptions | undefined,
|
|
527
|
+
fn: () => Promise<T>,
|
|
528
|
+
): Promise<T> {
|
|
529
|
+
configureTracer(buildOtelApiTracer());
|
|
530
|
+
bootObservability(options ?? {}, env);
|
|
531
|
+
try {
|
|
532
|
+
// Awaiting inside this try (rather than `return fn().finally(...)`)
|
|
533
|
+
// ensures a synchronous throw from `fn` — not just a rejected promise —
|
|
534
|
+
// still reaches the `finally` below instead of skipping it.
|
|
535
|
+
return await fn();
|
|
536
|
+
} finally {
|
|
537
|
+
await flushObservability(true);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
477
541
|
/**
|
|
478
542
|
* Build the legacy `@opentelemetry/api` global-tracer bridge. Stays a
|
|
479
543
|
* no-op when no global TracerProvider is registered — same outcome as
|
|
@@ -639,7 +703,18 @@ function patchConsole(state: BootState): void {
|
|
|
639
703
|
console.debug = (...args: unknown[]) => forward("debug", args);
|
|
640
704
|
}
|
|
641
705
|
|
|
642
|
-
|
|
706
|
+
/**
|
|
707
|
+
* Boots the observability stack for the current isolate: resolves the
|
|
708
|
+
* `DECO_OTEL` on/off/auto switch, builds the resource-attribute floor
|
|
709
|
+
* (service name/version/instance, deployment environment, cloud platform),
|
|
710
|
+
* and — gated per env var — wires the AE meter and OTLP metrics/logs/traces
|
|
711
|
+
* adapters. Idempotent per isolate (`BootState.booted` guards re-entry).
|
|
712
|
+
*
|
|
713
|
+
* Exported so callers with a lifecycle `instrumentWorker` doesn't fit
|
|
714
|
+
* (Cloudflare Workflows — see `instrumentWorkflowRun`) can boot the same
|
|
715
|
+
* stack directly.
|
|
716
|
+
*/
|
|
717
|
+
export function bootObservability(opts: OtelOptions, env: Record<string, unknown>): void {
|
|
643
718
|
const state = getBootState();
|
|
644
719
|
if (state.booted) return;
|
|
645
720
|
|
package/src/sdk/otelHttpLog.ts
CHANGED
|
@@ -107,8 +107,8 @@ export interface OtlpHttpLogOptions {
|
|
|
107
107
|
|
|
108
108
|
export interface OtlpHttpLog {
|
|
109
109
|
adapter: LoggerAdapter;
|
|
110
|
-
/**
|
|
111
|
-
flush(): Promise<void>;
|
|
110
|
+
/** Flush, subject to the per-isolate cooldown unless `force` is true. */
|
|
111
|
+
flush(force?: boolean): Promise<void>;
|
|
112
112
|
/** Pending log record count. For tests + audit. */
|
|
113
113
|
pendingRecordCount(): number;
|
|
114
114
|
}
|
|
@@ -324,12 +324,12 @@ export function createOtlpHttpLogAdapter(options: OtlpHttpLogOptions): OtlpHttpL
|
|
|
324
324
|
}
|
|
325
325
|
}
|
|
326
326
|
|
|
327
|
-
async function flush(): Promise<void> {
|
|
327
|
+
async function flush(force = false): Promise<void> {
|
|
328
328
|
if (inflight) return inflight;
|
|
329
329
|
|
|
330
330
|
const elapsed = now() - lastFlushAt;
|
|
331
331
|
const overCap = buffer.length >= maxBuffer;
|
|
332
|
-
if (!overCap && elapsed < minFlushIntervalMs) return;
|
|
332
|
+
if (!force && !overCap && elapsed < minFlushIntervalMs) return;
|
|
333
333
|
|
|
334
334
|
inflight = doFlush().finally(() => {
|
|
335
335
|
lastFlushAt = now();
|
package/src/sdk/otelHttpMeter.ts
CHANGED
|
@@ -93,8 +93,8 @@ export interface OtlpHttpMeter extends MeterAdapter {
|
|
|
93
93
|
gaugeSet(name: string, value: number, labels?: Labels): void;
|
|
94
94
|
/** Always defined on this adapter — declared required to drop the `?.` at call sites. */
|
|
95
95
|
histogramRecord(name: string, value: number, labels?: Labels): void;
|
|
96
|
-
/**
|
|
97
|
-
flush(): Promise<void>;
|
|
96
|
+
/** Flush, subject to the per-isolate cooldown unless `force` is true. */
|
|
97
|
+
flush(force?: boolean): Promise<void>;
|
|
98
98
|
/** Pending datapoint count across all metric kinds. For tests + audit. */
|
|
99
99
|
pendingDatapointCount(): number;
|
|
100
100
|
}
|
|
@@ -341,7 +341,7 @@ export function createOtlpHttpMeterAdapter(options: OtlpHttpMeterOptions): OtlpH
|
|
|
341
341
|
}
|
|
342
342
|
}
|
|
343
343
|
|
|
344
|
-
async function flush(): Promise<void> {
|
|
344
|
+
async function flush(force = false): Promise<void> {
|
|
345
345
|
// If a flush is in flight, reuse it — concurrent requests should not
|
|
346
346
|
// pile up POSTs. The in-flight POST already snapshotted the buffer at
|
|
347
347
|
// its enqueue time; new datapoints land in the buffer and will go out
|
|
@@ -350,8 +350,11 @@ export function createOtlpHttpMeterAdapter(options: OtlpHttpMeterOptions): OtlpH
|
|
|
350
350
|
|
|
351
351
|
const elapsed = now() - lastFlushAt;
|
|
352
352
|
const overCap = pendingDatapointCount() >= maxBuffer;
|
|
353
|
-
if (!overCap && elapsed < minFlushIntervalMs) {
|
|
354
|
-
// Cooldown not elapsed and buffer is not at the cap — skip.
|
|
353
|
+
if (!force && !overCap && elapsed < minFlushIntervalMs) {
|
|
354
|
+
// Cooldown not elapsed and buffer is not at the cap — skip. `force`
|
|
355
|
+
// bypasses this for callers with no next-request opportunity to
|
|
356
|
+
// retry the flush (e.g. a Cloudflare Workflow's `run()` exit — see
|
|
357
|
+
// `instrumentWorkflowRun` in `otel.ts`).
|
|
355
358
|
return;
|
|
356
359
|
}
|
|
357
360
|
|
|
@@ -233,8 +233,8 @@ export interface OtlpHttpTracerOptions {
|
|
|
233
233
|
}
|
|
234
234
|
|
|
235
235
|
export interface OtlpHttpTracer extends TracerAdapter {
|
|
236
|
-
/** Drain the buffer
|
|
237
|
-
flush(): Promise<void>;
|
|
236
|
+
/** Drain the buffer, subject to cooldown unless `force` is true. */
|
|
237
|
+
flush(force?: boolean): Promise<void>;
|
|
238
238
|
/** For tests + the audit channel. */
|
|
239
239
|
pendingSpanCount(): number;
|
|
240
240
|
/**
|
|
@@ -433,11 +433,11 @@ export function createOtlpHttpTracerAdapter(options: OtlpHttpTracerOptions): Otl
|
|
|
433
433
|
}
|
|
434
434
|
}
|
|
435
435
|
|
|
436
|
-
async function flush(): Promise<void> {
|
|
436
|
+
async function flush(force = false): Promise<void> {
|
|
437
437
|
if (inflight) return inflight;
|
|
438
438
|
const elapsed = now() - lastFlushAt;
|
|
439
439
|
const overCap = spans.length >= maxBuffer;
|
|
440
|
-
if (!overCap && elapsed < minFlushIntervalMs) return;
|
|
440
|
+
if (!force && !overCap && elapsed < minFlushIntervalMs) return;
|
|
441
441
|
inflight = doFlush().finally(() => {
|
|
442
442
|
lastFlushAt = now();
|
|
443
443
|
inflight = null;
|