From 2d49e3b05523ac1539e3aa83a729a3620d9060c4 Mon Sep 17 00:00:00 2001 From: Alexander Lichter Date: Thu, 29 Oct 2026 17:03:00 +0800 Subject: [PATCH 1/2] fix(cli): reconcile Cloudflare Worker logging settings --- packages/deploy-cloudflare/src/version.ts | 14 ++- .../test/unit/version.test.ts | 53 ++++++++++ packages/void/AGENTS.md | 2 +- packages/void/src/cli/cf-version-runner.ts | 10 +- packages/void/src/cli/cf-worker-settings.ts | 21 +++- .../src/cli/cloudflare-operation-process.ts | 7 +- .../void/test/unit/cf-version-runner.test.ts | 17 ++-- .../void/test/unit/cf-worker-settings.test.ts | 99 +++++++++++++++++++ .../unit/cloudflare-operation-process.test.ts | 19 ++-- 9 files changed, 214 insertions(+), 28 deletions(-) create mode 100644 packages/void/test/unit/cf-worker-settings.test.ts diff --git a/packages/deploy-cloudflare/src/version.ts b/packages/deploy-cloudflare/src/version.ts index db6ade285..8f26adb64 100644 --- a/packages/deploy-cloudflare/src/version.ts +++ b/packages/deploy-cloudflare/src/version.ts @@ -43,6 +43,8 @@ export type CloudflareTriggerConfig = Readonly<{ settingsHash?: string; /** Explicit Worker-wide observability settings, published after version activation. */ observability?: Readonly>; + /** Explicit Worker-wide Logpush setting, published after version activation. */ + logpush?: boolean; workers_dev: boolean; preview_urls: boolean; routes: ReadonlyArray; @@ -208,7 +210,11 @@ export function readCloudflareTriggerConfig( const config = parseRootWranglerConfig(configPath); // Build tooling can supply defaults; only authored settings may change the live Worker. const source = sourceConfigPath ? parseRootWranglerConfig(sourceConfigPath) : config; - const result = triggerConfigFromWrangler({ ...config, observability: source.observability }); + const result = triggerConfigFromWrangler({ + ...config, + observability: source.observability, + logpush: source.logpush, + }); if (!sourceConfigPath) { return result; } @@ -597,6 +603,7 @@ function triggerConfigFromWrangler(config: Record): CloudflareT ...(config.preview_urls === undefined ? { preservePreviewUrls: true } : {}), settingsHash: nonVersionedSettingsHash(config), ...(isRecord(config.observability) ? { observability: config.observability } : {}), + ...(typeof config.logpush === 'boolean' ? { logpush: config.logpush } : {}), workers_dev: workersDev, preview_urls: typeof config.preview_urls === 'boolean' ? config.preview_urls : workersDev, routes, @@ -639,7 +646,7 @@ function parseTriggerConfig(value: unknown): CloudflareTriggerConfig | null { return null; } if ( - ['preserve', 'preserveRoutes', 'preserveWorkersDev', 'preservePreviewUrls'].some( + ['preserve', 'preserveRoutes', 'preserveWorkersDev', 'preservePreviewUrls', 'logpush'].some( (key) => value[key] !== undefined && typeof value[key] !== 'boolean', ) || (value.settingsHash !== undefined && typeof value.settingsHash !== 'string') || @@ -661,6 +668,7 @@ function parseTriggerConfig(value: unknown): CloudflareTriggerConfig | null { ...(value.preservePreviewUrls === true ? { preservePreviewUrls: true } : {}), ...(typeof value.settingsHash === 'string' ? { settingsHash: value.settingsHash } : {}), ...(isRecord(value.observability) ? { observability: value.observability } : {}), + ...(typeof value.logpush === 'boolean' ? { logpush: value.logpush } : {}), workers_dev: value.workers_dev, preview_urls: value.preview_urls, routes: value.routes, @@ -685,6 +693,7 @@ function triggerWranglerConfig( ...(triggerConfig.observability === undefined ? {} : { observability: triggerConfig.observability }), + ...(triggerConfig.logpush === undefined ? {} : { logpush: triggerConfig.logpush }), triggers, queues, workflows: [...triggerConfig.workflows], @@ -722,6 +731,7 @@ function resolvePreservedTraffic( ...(desired.preserve ? { preserve: true } : {}), ...(desired.settingsHash ? { settingsHash: desired.settingsHash } : {}), ...(desired.observability === undefined ? {} : { observability: desired.observability }), + ...(desired.logpush === undefined ? {} : { logpush: desired.logpush }), workers_dev: desired.preserveWorkersDev ? (previous?.workers_dev ?? desired.workers_dev) : desired.workers_dev, diff --git a/packages/deploy-cloudflare/test/unit/version.test.ts b/packages/deploy-cloudflare/test/unit/version.test.ts index c132592a7..b97f09710 100644 --- a/packages/deploy-cloudflare/test/unit/version.test.ts +++ b/packages/deploy-cloudflare/test/unit/version.test.ts @@ -1774,6 +1774,55 @@ describe('Cloudflare version activation', () => { } }); + it.each([false, true])( + 'carries explicit Logpush through the version snapshot and trigger config: %s', + async (logpush) => { + const directory = mkdtempSync(join(tmpdir(), 'void-version-logpush-')); + const configPath = join(directory, 'wrangler.json'); + const sourcePath = join(directory, 'source.json'); + try { + writeFileSync(configPath, JSON.stringify({ name: 'app', logpush })); + const snapshot = readCloudflareTriggerConfig(configPath); + expect(snapshot.logpush).toBe(logpush); + expect( + parseCloudflareVersionMessage(encodeCloudflareVersionMessage([], snapshot)), + ).toMatchObject({ + triggerConfig: { logpush }, + }); + let applied: Record | undefined; + const publish = (triggerConfig: CloudflareTriggerConfig) => + deployCloudflareVersionTriggers({ + runWrangler: operationFixture(async (args) => { + applied = readConfigArg(args); + return ok(); + }), + workerName: 'app', + configPath, + triggerConfig, + }); + await publish(snapshot); + expect(applied?.logpush).toBe(logpush); + writeFileSync(sourcePath, JSON.stringify({ name: 'app' })); + const omitted = readCloudflareTriggerConfig(configPath, sourcePath); + expect(omitted).not.toHaveProperty('logpush'); + await publish(omitted); + expect(applied).not.toHaveProperty('logpush'); + writeFileSync(sourcePath, JSON.stringify({ name: 'app', logpush: !logpush })); + expect(readCloudflareTriggerConfig(configPath, sourcePath).logpush).toBe(!logpush); + applied = undefined; + await publish({ ...snapshot, preserve: true }); + expect(applied).toBeUndefined(); + expect( + parseCloudflareVersionMessage( + `void:${JSON.stringify({ schema: 2, migrations: [], triggerConfig: { ...snapshot, logpush: 'false' } })}`, + ), + ).toBeNull(); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }, + ); + it('carries Email Routing addresses through the trigger snapshot and the trigger config', async () => { const directory = mkdtempSync(join(tmpdir(), 'void-version-addresses-')); const configPath = join(directory, 'wrangler.json'); @@ -2919,6 +2968,7 @@ describe('Cloudflare version activation', () => { routes: [{ pattern: 'old.example.com', custom_domain: true }], crons: ['0 * * * *'], observability: { enabled: false }, + logpush: true, }); const liveTraffic = { workers_dev: false, @@ -2959,6 +3009,7 @@ describe('Cloudflare version activation', () => { preservePreviewUrls: true, crons: ['*/10 * * * *'], observability: { enabled: true, traces: { enabled: true } }, + logpush: false, }), fetch: async () => Response.json({ ready: true }), }); @@ -2968,11 +3019,13 @@ describe('Cloudflare version activation', () => { ...liveTraffic, triggers: { crons: ['*/10 * * * *'] }, observability: { enabled: true, traces: { enabled: true } }, + logpush: false, }); expect(publications[1]).toMatchObject({ ...liveTraffic, triggers: { crons: ['0 * * * *'] }, observability: { enabled: false }, + logpush: true, }); expect(publications[1].queues).toEqual({ consumers: [] }); }); diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index e872d248b..eb66a1102 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -110,7 +110,7 @@ generated entries must preserve the module-boundary rules below. - **Direct deploy package boundary**: target-neutral intents, artifacts, resources, migrations, and Durable Object plans live in public `@void/deploy-core`; root Wrangler config parsing and verified Cloudflare upload primitives live in public `@void/deploy-cloudflare`. The CLI consumes both packages directly. Hosted platform packages consume `@void/deploy-core` and must not be imported by public SDK packages. - **Direct activation invariant**: native Cloudflare deploy uploads a Worker Version without traffic, records the validated migration filenames in its annotation, probes that version directly, activates it at 100%, and only then synchronizes triggers. A trigger-sync failure restores the prior active version. Database migrations are forward-only; rollback warns before activating code whose recorded migration set is older or unknown. - Worker-wide observability requires a separate settings update after activation. Replay only the target version's explicit observability snapshot during trigger synchronization and rollback; omitted settings and binding-preserving handoffs retain the live configuration. + Worker-wide Logpush and observability require a separate settings update after activation. Replay only the target version's explicit settings snapshot during trigger synchronization and rollback; omitted settings and binding-preserving handoffs skip that settings update. - **Platform Worker reuse**: An installed platform may reuse an active immutable Worker Version only when its annotation proves the complete upload input matches: Worker config, managed routes, runtime files and assets, and the pinned uploader toolchain. Secret updates always upload. Reused versions still reconcile mutable triggers and traffic, check live version lineage, and fail closed on drift or incomplete proof. - **Edge-routing ownership**: managed builds set `VOID_DEPLOY_PROJECT_ID` and omit the generated redirects, rewrites, headers, and fallbacks because platform dispatch applies them. Native Cloudflare, local dev, and non-CF builds compile the same ordered rules into the application runtime. Only managed bundles may trust dispatch-authored `X-Void-Original-URL` metadata. diff --git a/packages/void/src/cli/cf-version-runner.ts b/packages/void/src/cli/cf-version-runner.ts index 15e43e249..a798045d3 100644 --- a/packages/void/src/cli/cf-version-runner.ts +++ b/packages/void/src/cli/cf-version-runner.ts @@ -24,7 +24,7 @@ import { uploadPinnedCloudflareVersion, type CfCommand, } from './cf-version-api.ts'; -import { publishCloudflareObservability } from './cf-worker-settings.ts'; +import { publishCloudflareWorkerSettings } from './cf-worker-settings.ts'; import { isCleanCloudflareExit } from './cloudflare-process.ts'; function failed(message: string): CloudflareCommandResult { @@ -780,7 +780,10 @@ export function createCfVersionRunner(options: { command.capture, directory, ); - if (isCleanCloudflareExit(deployed) && raw.observability !== undefined) { + if ( + isCleanCloudflareExit(deployed) && + (raw.observability !== undefined || raw.logpush !== undefined) + ) { const credentials = options.pinnedUpload; if (!credentials || !credentials.verifyUploader()) { return failed('The Cloudflare settings publisher is unavailable.'); @@ -790,11 +793,12 @@ export function createCfVersionRunner(options: { if (!token || !credentials.verifyUploader()) { return failed('Cloudflare session is unavailable for publishing Worker settings.'); } - await publishCloudflareObservability({ + await publishCloudflareWorkerSettings({ apiBaseUrl: credentials.apiBaseUrl, accountId: credentials.accountId, workerName, observability: raw.observability, + logpush: raw.logpush, headers: { Authorization: `Bearer ${token}` }, }); } catch (cause) { diff --git a/packages/void/src/cli/cf-worker-settings.ts b/packages/void/src/cli/cf-worker-settings.ts index bfc3667ce..737ba9fb7 100644 --- a/packages/void/src/cli/cf-worker-settings.ts +++ b/packages/void/src/cli/cf-worker-settings.ts @@ -2,16 +2,27 @@ import { isRecord } from '@void/deploy-core'; import { getVoidUserAgent } from './cf-user-agent.ts'; /** Version uploads leave Worker-wide settings untouched; publish only explicit configuration. */ -export async function publishCloudflareObservability(options: { +export async function publishCloudflareWorkerSettings(options: { apiBaseUrl: string; accountId: string; workerName: string; - observability: unknown; + observability?: unknown; + logpush?: unknown; headers: Record; }): Promise { - if (!isRecord(options.observability)) { + if (options.observability !== undefined && !isRecord(options.observability)) { throw new Error('The Cloudflare Worker observability settings were not readable.'); } + if (options.logpush !== undefined && typeof options.logpush !== 'boolean') { + throw new Error('The Cloudflare Worker Logpush setting was not readable.'); + } + const settings = { + ...(options.observability === undefined ? {} : { observability: options.observability }), + ...(options.logpush === undefined ? {} : { logpush: options.logpush }), + }; + if (Object.keys(settings).length === 0) { + return; + } const endpoint = `${options.apiBaseUrl.replace(/\/$/, '')}/accounts/${encodeURIComponent(options.accountId)}/workers/scripts/${encodeURIComponent(options.workerName)}/script-settings`; const response = await fetch(endpoint, { method: 'PATCH', @@ -20,12 +31,12 @@ export async function publishCloudflareObservability(options: { ...options.headers, 'Content-Type': 'application/json', }, - body: JSON.stringify({ observability: options.observability }), + body: JSON.stringify(settings), redirect: 'error', signal: AbortSignal.timeout(30_000), }); const body: unknown = await response.json(); if (!response.ok || !isRecord(body) || body.success !== true) { - throw new Error(`Cloudflare did not update Worker observability (HTTP ${response.status}).`); + throw new Error(`Cloudflare did not update Worker settings (HTTP ${response.status}).`); } } diff --git a/packages/void/src/cli/cloudflare-operation-process.ts b/packages/void/src/cli/cloudflare-operation-process.ts index 9a9b36a1b..ad16c0548 100644 --- a/packages/void/src/cli/cloudflare-operation-process.ts +++ b/packages/void/src/cli/cloudflare-operation-process.ts @@ -22,7 +22,7 @@ import { } from './cf-command-values.ts'; import { persistCfKeyringPreference, readCfSessionCredential } from './cf-session.ts'; import { getCfCliVersion, getVoidUserAgent } from './cf-user-agent.ts'; -import { publishCloudflareObservability } from './cf-worker-settings.ts'; +import { publishCloudflareWorkerSettings } from './cf-worker-settings.ts'; import { cloudflareDeviceCode, cloudflareVerificationUrl, @@ -1136,12 +1136,13 @@ async function workerChanges(): Promise { const deployed = await withBuildOutput(raw, null, (directory) => runCf(['workers', 'triggers', 'deploy', '--prebuilt'], directory), ); - if (deployed.status === 0 && raw.observability !== undefined) { - await publishCloudflareObservability({ + if (deployed.status === 0 && (raw.observability !== undefined || raw.logpush !== undefined)) { + await publishCloudflareWorkerSettings({ apiBaseUrl: apiBaseUrl(), accountId: accountId(), workerName: name, observability: raw.observability, + logpush: raw.logpush, headers: await apiHeaders(), }); } diff --git a/packages/void/test/unit/cf-version-runner.test.ts b/packages/void/test/unit/cf-version-runner.test.ts index a14733c99..66e08ceed 100644 --- a/packages/void/test/unit/cf-version-runner.test.ts +++ b/packages/void/test/unit/cf-version-runner.test.ts @@ -341,12 +341,15 @@ it( // This exercises multiple fresh cf subprocesses. Windows CI has exceeded 30s // across those startups; bound each child and give the complete protocol a // separate Windows budget, including assertion and cleanup overhead. -it( - 'uploads an ordinary API-origin revision with genuine cf reads and UUID-pinned inheritance', - async () => { +it.each([ + { observability: { enabled: true, logs: { enabled: true }, traces: { enabled: true } } }, + { logpush: false }, + { logpush: true }, +])( + 'uploads an API-origin revision and publishes its explicit Worker settings: %j', + async (settings) => { const current = '11111111-1111-4111-8111-111111111111'; const next = '22222222-2222-4222-8222-222222222222'; - const observability = { enabled: true, logs: { enabled: true }, traces: { enabled: true } }; const triggerConfig = { workers_dev: true, preview_urls: true, @@ -354,11 +357,11 @@ it( crons: [], queueConsumers: [], workflows: [], - observability, + ...settings, }; const message = encodeCloudflareVersionMessage([], triggerConfig); const prepared = await output({ - observability, + ...settings, secrets: { required: ['AUTH_SECRET'] }, version_metadata: { binding: 'APP_VERSION' }, send_email: [ @@ -531,7 +534,7 @@ it( { method: 'PATCH', path: '/client/v4/accounts/account-id/workers/scripts/void-test/script-settings', - body: { observability }, + body: settings, }, ]); expect(events.indexOf('activate')).toBeLessThan(events.indexOf('settings')); diff --git a/packages/void/test/unit/cf-worker-settings.test.ts b/packages/void/test/unit/cf-worker-settings.test.ts new file mode 100644 index 000000000..148293ec0 --- /dev/null +++ b/packages/void/test/unit/cf-worker-settings.test.ts @@ -0,0 +1,99 @@ +import { createServer } from 'node:http'; +import { expect, it } from 'vite-plus/test'; +import { publishCloudflareWorkerSettings } from '../../src/cli/cf-worker-settings.ts'; + +async function fixture( + run: ( + options: Parameters[0], + requests: Array, + ) => Promise, + status = 200, + success = true, +) { + const requests: Array = []; + const server = createServer(async (request, response) => { + let body = ''; + for await (const chunk of request) { + body += chunk; + } + requests.push({ method: request.method, path: request.url, body: JSON.parse(body) }); + response.writeHead(status, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ success, result: {} })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Test server did not bind'); + } + try { + await run( + { + apiBaseUrl: `http://127.0.0.1:${address.port}`, + accountId: 'synthetic-account', + workerName: 'synthetic-worker', + headers: { Authorization: 'Bearer synthetic-token' }, + }, + requests, + ); + } finally { + await new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }); + } +} + +it.each([ + { logpush: false }, + { logpush: true }, + { observability: { enabled: false } }, + { logpush: false, observability: { enabled: true, logs: { destinations: [] } } }, +])('publishes only explicitly configured Worker settings: %j', async (settings) => { + await fixture(async (options, requests) => { + await publishCloudflareWorkerSettings({ ...options, ...settings }); + expect(requests).toEqual([ + { + method: 'PATCH', + path: '/accounts/synthetic-account/workers/scripts/synthetic-worker/script-settings', + body: settings, + }, + ]); + }); +}); + +it('skips publication when both settings are omitted', async () => { + await fixture(async (options, requests) => { + await publishCloudflareWorkerSettings(options); + expect(requests).toEqual([]); + }); +}); + +it.each([{ logpush: 'false' }, { observability: null }])( + 'rejects invalid explicit settings before publication: %j', + async (settings) => { + await fixture(async (options, requests) => { + await expect(publishCloudflareWorkerSettings({ ...options, ...settings })).rejects.toThrow( + /not readable/, + ); + expect(requests).toEqual([]); + }); + }, +); + +it.each([ + [403, false], + [200, false], +] as const)( + 'reports an unsuccessful settings update (HTTP %s, success=%s)', + async (status, success) => { + await fixture( + async (options) => { + await expect( + publishCloudflareWorkerSettings({ ...options, logpush: false }), + ).rejects.toThrow(/did not update Worker settings/); + }, + status, + success, + ); + }, +); diff --git a/packages/void/test/unit/cloudflare-operation-process.test.ts b/packages/void/test/unit/cloudflare-operation-process.test.ts index ca6f68f04..d9255c868 100644 --- a/packages/void/test/unit/cloudflare-operation-process.test.ts +++ b/packages/void/test/unit/cloudflare-operation-process.test.ts @@ -105,19 +105,24 @@ function runOperation( }); } -it.each([true, false])( - 'publishes observability from the trigger operation (enabled=%s)', - async (enabled) => { +it.each([ + { observability: { enabled: true } }, + { observability: { enabled: false } }, + { logpush: true }, + { logpush: false }, + { observability: { enabled: false }, logpush: false }, +])( + 'publishes explicit Worker settings from the trigger operation: %j', + async (settings) => { const directory = temporaryDirectory('void-cf-observability-'); const configPath = join(directory, 'wrangler.json'); - const observability = { enabled, logs: { enabled }, traces: { enabled } }; writeFileSync( configPath, JSON.stringify({ name: 'test-worker', workers_dev: false, preview_urls: false, - observability, + ...settings, }), ); const patches: Array<{ path: string; body: unknown; authorization?: string }> = []; @@ -140,7 +145,7 @@ it.each([true, false])( success: true, result: request.url?.endsWith('/workers/subdomain') ? { subdomain: 'fixture' } - : { subdomain: { enabled: false, previews_enabled: false }, observability }, + : { subdomain: { enabled: false, previews_enabled: false }, ...settings }, }), ); }), @@ -158,7 +163,7 @@ it.each([true, false])( expect(patches).toEqual([ { path: '/client/v4/accounts/test-account/workers/scripts/test-worker/script-settings', - body: { observability }, + body: settings, authorization: 'Bearer synthetic-token', }, ]); From 867b0f9446cbb4c36bc055b607b9e0441c8d03ee Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 29 Oct 2026 17:23:20 +0800 Subject: [PATCH 2/2] docs(cli): note that Logpush is published after activation Co-Authored-By: Claude Opus 5.5 --- packages/void/src/cli/cf-version-api.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/void/src/cli/cf-version-api.ts b/packages/void/src/cli/cf-version-api.ts index f8331690b..808757f19 100644 --- a/packages/void/src/cli/cf-version-api.ts +++ b/packages/void/src/cli/cf-version-api.ts @@ -1149,8 +1149,9 @@ export async function uploadPinnedCloudflareVersion(options: { 'limits', 'placement', 'cache', - // Worker-level settings that version uploads cannot set. Observability is - // published separately during post-activation trigger synchronization. + // Worker-level settings that version uploads cannot set. Observability and + // Logpush are published separately during post-activation trigger + // synchronization; tail consumers are not applied. 'logpush', 'tailConsumers', ]).has(key),