From 229ab3062afcab8c3b213820ce94eaf5ac185822 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 3 Nov 2026 13:50:14 +0800 Subject: [PATCH 1/4] test(cloudflare): give the deferred Container build test more time The test runs two real Vite builds, which take about four seconds and come close to the default five-second timeout under load. Co-Authored-By: Claude Opus 5.5 --- packages/void/test/unit/plugin-cloudflare-v2.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/void/test/unit/plugin-cloudflare-v2.test.ts b/packages/void/test/unit/plugin-cloudflare-v2.test.ts index eae87cf60..ff155757f 100644 --- a/packages/void/test/unit/plugin-cloudflare-v2.test.ts +++ b/packages/void/test/unit/plugin-cloudflare-v2.test.ts @@ -170,7 +170,8 @@ describe('Cloudflare v2 compatibility', () => { const metadataPath = join(root, '.cloudflare/output/v0/void-containers.json'); writeFileSync(metadataPath, JSON.stringify({ workerDirectory: '../outside' })); await expect(prepareCfBuildOutput(root)).rejects.toThrow('Invalid Void Container'); - }); + // Two real Vite builds take about four seconds, near the default timeout. + }, 15_000); it('preserves resource identities, required secrets and compatibility settings', () => { const root = project(); From fc872f81d1db6e3587b75e136a02e1199a14302e Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 3 Nov 2026 13:50:15 +0800 Subject: [PATCH 2/4] feat(platform): add minimal runtime logs Add `--runtime-logs full|minimal` to platform install and upgrade. In minimal mode, platform and project Workers run with Workers Logs off, the tail Worker stores no URL, log message or exception message, and dispatch metering drops the request path. Outcome, method, status and deployment id are still recorded. The setting is saved in the deployment manifest (schema version 5) and existing project Workers are updated after each upgrade or repair while minimal. Fixes #667 Co-Authored-By: Claude Opus 5.5 --- .../platform/installation/maintenance.md | 13 ++ docs/guide/platform/installation/setup.md | 19 ++ docs/reference/cli/platform-installation.md | 5 +- packages/platform/scripts/build-runtime.mjs | 1 + packages/platform/src/index.ts | 1 + packages/platform/src/installer.ts | 6 + packages/platform/src/manifest.ts | 49 ++-- packages/platform/src/worker-config.ts | 10 +- .../platform/test/unit/runtime-logs.test.ts | 219 ++++++++++++++++++ packages/void/src/cli/client.ts | 15 +- packages/void/src/cli/help.ts | 16 +- packages/void/src/cli/logs.ts | 27 ++- packages/void/src/cli/operator-cmd.ts | 46 +++- packages/void/src/cli/platform-args.ts | 39 +++- packages/void/src/cli/platform-cloudflare.ts | 22 +- packages/void/src/cli/platform-cmd.ts | 3 + packages/void/src/cli/platform-installer.ts | 2 + packages/void/src/cli/platform-lifecycle.ts | 30 ++- packages/void/src/cli/platform-management.ts | 39 +++- packages/void/src/cli/platform-plan.ts | 40 +++- .../void/src/cli/platform-runtime-logs.ts | 102 ++++++++ packages/void/src/cli/platform-setup.ts | 2 + packages/void/test/unit/cli-help.test.ts | 6 + packages/void/test/unit/logs-filter.test.ts | 10 + .../void/test/unit/logs-rendering.test.ts | 111 +++++++-- packages/void/test/unit/operator-cli.test.ts | 96 ++++++++ packages/void/test/unit/platform-args.test.ts | 20 ++ .../void/test/unit/platform-installer.test.ts | 18 ++ .../unit/platform-management-upgrade.test.ts | 111 +++++++++ packages/void/test/unit/platform-plan.test.ts | 74 ++++++ .../void/test/unit/platform-rollback.test.ts | 32 +++ .../test/unit/platform-runtime-logs.test.ts | 146 ++++++++++++ .../test/unit/platform-saved-plans.test.ts | 8 + .../packages/api/src/admin/fate/telemetry.ts | 1 + platform/packages/api/src/admin/fate/views.ts | 6 +- .../src/admin/react/views/DeploymentLogs.tsx | 43 +++- platform/packages/api/src/deploy/shared.ts | 11 + .../packages/api/src/deploy/upload-worker.ts | 6 +- platform/packages/api/src/env.ts | 2 + .../api/src/observability/log-rows.ts | 7 + .../api/src/operator/deployment-operations.ts | 4 +- .../api/src/provisioning/durable-objects.ts | 5 + platform/packages/api/src/routes/projects.ts | 4 +- .../test/integration/admin-dashboard.test.ts | 64 +++++ .../api/test/integration/api-deploy.test.ts | 27 +++ .../api/test/integration/api-projects.test.ts | 44 ++++ .../packages/api/test/integration/helpers.ts | 1 + .../managed-durable-objects.test.ts | 23 ++ .../integration/operator-operations.test.ts | 28 +++ .../test/unit/admin-deployment-logs.test.ts | 49 ++++ .../unit/platform-deployment-manifest.test.ts | 16 ++ platform/packages/dispatch/src/env.ts | 2 + platform/packages/dispatch/src/metering.ts | 22 +- .../dispatch/test/unit/platform-log.test.ts | 54 +++++ platform/packages/tail/README.md | 7 +- platform/packages/tail/src/index.ts | 58 +++-- .../packages/tail/test/log-data-point.test.ts | 108 +++++++++ 57 files changed, 1814 insertions(+), 116 deletions(-) create mode 100644 packages/platform/test/unit/runtime-logs.test.ts create mode 100644 packages/void/src/cli/platform-runtime-logs.ts create mode 100644 packages/void/test/unit/platform-runtime-logs.test.ts create mode 100644 platform/packages/api/test/unit/admin-deployment-logs.test.ts create mode 100644 platform/packages/tail/test/log-data-point.test.ts diff --git a/docs/guide/platform/installation/maintenance.md b/docs/guide/platform/installation/maintenance.md index f81f2e5d9..92926f237 100644 --- a/docs/guide/platform/installation/maintenance.md +++ b/docs/guide/platform/installation/maintenance.md @@ -147,6 +147,19 @@ Let running rotations finish before you roll back. If an older proxy runs next t ::: +### Change runtime logs {#runtime-logs} + +Switch an installed platform between [full and minimal runtime logs](/guide/platform/installation/setup#runtime-logs) with an upgrade: + +```sh +void platform upgrade [id] --runtime-logs minimal +void platform upgrade [id] --runtime-logs full +``` + +Later upgrades keep the setting. The upgrade also changes Workers Logs on the projects' existing deployments. If that step fails, the upgrade still finishes and prints what to run next: `void platform repair [id]` after switching to minimal, or the same upgrade again after switching to full. Records stored before the change are not removed; they expire with Cloudflare's retention. + +While runtime logs are minimal, `void platform rollback` refuses a runtime that does not support the setting. Run `void platform upgrade [id] --runtime-logs full` first. + ### Platform Workers changed outside Void {#workers-changed-outside-void} Use Void commands to manage the platform's Workers. If their configuration was changed in Cloudflare, or Void cannot identify the active version, maintenance asks you to approve replacing it. Preview the affected Workers with `--plan`. diff --git a/docs/guide/platform/installation/setup.md b/docs/guide/platform/installation/setup.md index 40dd8cf20..464940c03 100644 --- a/docs/guide/platform/installation/setup.md +++ b/docs/guide/platform/installation/setup.md @@ -93,6 +93,25 @@ Keep the file’s existing `connections` configuration. For `mode: "platform"`, same-account identity and policy selections are reused. Headless setup requires an explicit app-management token; it never silently retains an installer-only token. +## Choose what runtime logs record {#runtime-logs} + +By default, the platform records each request's URL, console output, and exception messages. Project owners see them with `void project logs`, and administrators with `void platform deployment logs`. They are kept in your Cloudflare account, in Workers Logs and in the platform's log history. + +If your apps can put personal data in URLs, logs, or error messages, install with minimal runtime logs: + +```sh +void platform install --runtime-logs minimal +``` + +With minimal runtime logs: + +- Platform and project Workers do not send anything to Workers Logs. +- Log history keeps each request's time, method, status, outcome, deployment, log levels, and exception names, but not its URL, log messages, or exception messages. +- `--level` filters still work. `--filter` can match exception names, but not the messages that are no longer recorded. +- Log views say that messages and URLs are not recorded on this platform. + +The setting covers the whole platform. To change it later, see [Change runtime logs](/guide/platform/installation/maintenance#runtime-logs). + ## Finish login configuration For Google, create an OAuth client and register the printed callback URL. You diff --git a/docs/reference/cli/platform-installation.md b/docs/reference/cli/platform-installation.md index c22293478..efae6f189 100644 --- a/docs/reference/cli/platform-installation.md +++ b/docs/reference/cli/platform-installation.md @@ -25,6 +25,7 @@ void platform install [options] [--yes] | `--dedicated-zone` | Add zone-wide catch-all routes; valid only when the app domain is the whole zone | | `--no-dedicated-zone` | Skip zone-wide application catch-all routes | | `--custom-domains` | Let projects add their own domains; needs a dedicated zone | +| `--runtime-logs ` | `minimal` keeps request URLs and log and exception messages out of runtime logs | | `--control-plane-domain ` | Optional API custom hostname; defaults to `workers.dev` | | `--no-control-plane-domain` | Use the default `workers.dev` API hostname | | `--dashboard-url ` | HTTPS origin of an optional dashboard deployed separately | @@ -101,7 +102,7 @@ Use these commands to recover, update, pause, or remove an installation: ```sh void platform discover [--account ] [--installation ] -void platform upgrade [id] [--runtime ] [--dashboard-url ] [--custom-domains] [--plan] [--yes] +void platform upgrade [id] [--runtime ] [--dashboard-url ] [--custom-domains] [--runtime-logs ] [--plan] [--yes] void platform rollback [id] --runtime [--from-runtime ] [--plan] [--yes] void platform repair [id] [--runtime ] [--dashboard-url ] [--custom-domains] [--auth-config ] [--plan] [--yes] void platform disable [id] [--plan] [--yes] @@ -113,6 +114,8 @@ void platform uninstall [id] [--plan] [--purge-data | --purge-resources] [--yes] Use the installation slug shown beside its name in `void platform list`. Installation IDs and saved connection IDs also work. Lifecycle commands require local installation records; use `discover` to restore them on another machine. Omit `id` when only one installation is configured, or choose from the interactive picker. Non-interactive commands need a slug or ID when several installations exist. Commands that make changes also require `--yes`; `--plan` only previews changes. +`upgrade --runtime-logs ` changes what runtime logs record; without it, upgrades keep the current setting. See [Choose what runtime logs record](../../guide/platform/installation/setup.md#runtime-logs). + To add or explicitly change a separately deployed dashboard after installation, use `repair --dashboard-url ` or `upgrade --dashboard-url `. Preview with `--plan` first. Existing Access protection must cover the configured diff --git a/packages/platform/scripts/build-runtime.mjs b/packages/platform/scripts/build-runtime.mjs index b56ba1276..9ff23ef96 100644 --- a/packages/platform/scripts/build-runtime.mjs +++ b/packages/platform/scripts/build-runtime.mjs @@ -159,6 +159,7 @@ try { authenticationProtocolVersion: 1, deploymentExecution: 1, appAccessSetup: 1, + runtimeLogs: 1, emailProtocolVersion: 1, database, platformVersion: packageJson.version, diff --git a/packages/platform/src/index.ts b/packages/platform/src/index.ts index 8d342d410..b34b4fc1c 100644 --- a/packages/platform/src/index.ts +++ b/packages/platform/src/index.ts @@ -55,6 +55,7 @@ export { type PlatformDeploymentManifest, type PlatformDeploymentManifestInput, type PlatformResourceNames, + type PlatformRuntimeLogs, } from './manifest.ts'; export { PLATFORM_DISABLED_KEY, diff --git a/packages/platform/src/installer.ts b/packages/platform/src/installer.ts index 8550f778a..e3e309cae 100644 --- a/packages/platform/src/installer.ts +++ b/packages/platform/src/installer.ts @@ -42,6 +42,8 @@ export type PlatformRuntimeDatabaseManifest = Readonly<{ export type PlatformRuntimeManifest = Readonly<{ appAccessSetup?: 1; + /** The runtime honours `runtimeLogs: minimal` (PLATFORM_RUNTIME_LOGS). */ + runtimeLogs?: 1; /** Durable deployment Queue payload and PostgreSQL operation journal contract. */ deploymentExecution?: 1; authenticationProtocolVersion?: 1; @@ -105,6 +107,9 @@ export function parsePlatformRuntimeManifest(value: unknown): PlatformRuntimeMan if (root.appAccessSetup !== undefined && root.appAccessSetup !== 1) { throw new Error('Unsupported app access setup protocol.'); } + if (root.runtimeLogs !== undefined && root.runtimeLogs !== 1) { + throw new Error('Unsupported runtime logs protocol.'); + } if (root.deploymentExecution !== undefined && root.deploymentExecution !== 1) { throw new Error('Unsupported deployment execution protocol.'); } @@ -224,6 +229,7 @@ export function parsePlatformRuntimeManifest(value: unknown): PlatformRuntimeMan return { artifacts, ...(root.appAccessSetup === 1 ? { appAccessSetup: 1 as const } : {}), + ...(root.runtimeLogs === 1 ? { runtimeLogs: 1 as const } : {}), ...(root.deploymentExecution === 1 ? { deploymentExecution: 1 as const } : {}), ...(assets ? { assets } : {}), ...(root.authenticationProtocolVersion === 1 diff --git a/packages/platform/src/manifest.ts b/packages/platform/src/manifest.ts index 5fd8814ee..57e1644ed 100644 --- a/packages/platform/src/manifest.ts +++ b/packages/platform/src/manifest.ts @@ -46,6 +46,12 @@ export type PlatformResourceNames = Readonly<{ export type PlatformAppAccess = 'public' | 'platform' | 'custom'; +/** + * `minimal` keeps request URLs, console messages and exception messages out of + * Workers Logs and the platform's request log rows. Omitted means `full`. + */ +export type PlatformRuntimeLogs = 'minimal'; + export type PlatformDeploymentManifest = Readonly<{ /** Initial app-access choice. Omitted on installations created before guided setup. */ appAccess?: PlatformAppAccess; @@ -64,7 +70,9 @@ export type PlatformDeploymentManifest = Readonly<{ displayName: string; installationSlug: string; resources: PlatformResourceNames; - schemaVersion: typeof PLATFORM_DEPLOYMENT_SCHEMA_VERSION | 2 | 3 | 4; + /** Requires schema version 5, so an older CLI refuses the manifest instead of dropping it. */ + runtimeLogs?: PlatformRuntimeLogs; + schemaVersion: typeof PLATFORM_DEPLOYMENT_SCHEMA_VERSION | 2 | 3 | 4 | 5; }>; export type PlatformDeploymentManifestInput = Readonly<{ @@ -88,6 +96,7 @@ export type PlatformDeploymentManifestInput = Readonly<{ displayName: string; installationSlug: string; resources?: Partial; + runtimeLogs?: PlatformRuntimeLogs; }>; export type PlatformApplicationRoute = Readonly<{ @@ -267,6 +276,9 @@ export function createPlatformDeploymentManifest( ) { throw new Error('appAccess must be public, platform, or custom.'); } + if (input.runtimeLogs !== undefined && input.runtimeLogs !== 'minimal') { + throw new Error('runtimeLogs must be minimal when set.'); + } const providers = input.authentication?.providers ?? ['github']; if ( providers.some((provider) => !PLATFORM_LOGIN_KINDS.includes(provider)) || @@ -328,8 +340,10 @@ export function createPlatformDeploymentManifest( displayName: requiredString(input.displayName, 'displayName').trim(), installationSlug, resources, - schemaVersion: - input.appAccess !== undefined + ...(input.runtimeLogs ? { runtimeLogs: input.runtimeLogs } : {}), + schemaVersion: input.runtimeLogs + ? 5 + : input.appAccess !== undefined ? 4 : configurable ? 3 @@ -364,7 +378,8 @@ export function parsePlatformDeploymentManifest(value: unknown): PlatformDeploym value.schemaVersion !== PLATFORM_DEPLOYMENT_SCHEMA_VERSION && value.schemaVersion !== 2 && value.schemaVersion !== 3 && - value.schemaVersion !== 4 + value.schemaVersion !== 4 && + value.schemaVersion !== 5 ) { throw new Error( `Unsupported platform deployment schema version: ${String(value.schemaVersion)}.`, @@ -379,8 +394,7 @@ export function parsePlatformDeploymentManifest(value: unknown): PlatformDeploym let workersDev: PlatformApplication['workersDev']; if ( value.schemaVersion === 2 || - ((value.schemaVersion === 3 || value.schemaVersion === 4) && - application.workersDev !== undefined) + (Number(value.schemaVersion) >= 3 && application.workersDev !== undefined) ) { assertRecord(application.workersDev, 'application.workersDev'); workersDev = { @@ -412,18 +426,14 @@ export function parsePlatformDeploymentManifest(value: unknown): PlatformDeploym providers.some( (provider) => !PLATFORM_LOGIN_KINDS.includes(provider as PlatformLoginKind) || - (value.schemaVersion !== 3 && - value.schemaVersion !== 4 && - provider !== 'github' && - provider !== 'google'), + (Number(value.schemaVersion) < 3 && provider !== 'github' && provider !== 'google'), ) ) { throw new Error('authentication.providers must contain only github or google.'); } if ( (value.schemaVersion === 3 && authentication.configurable !== true) || - (value.schemaVersion !== 3 && - value.schemaVersion !== 4 && + (Number(value.schemaVersion) < 3 && (authentication.configurable !== undefined || authentication.accessProtection !== undefined)) || (authentication.accessProtection !== undefined && authentication.accessProtection !== true) @@ -433,7 +443,10 @@ export function parsePlatformDeploymentManifest(value: unknown): PlatformDeploym if ( value.schemaVersion === 4 ? !['public', 'platform', 'custom'].includes(String(value.appAccess)) - : value.appAccess !== undefined + : value.schemaVersion === 5 + ? value.appAccess !== undefined && + !['public', 'platform', 'custom'].includes(String(value.appAccess)) + : value.appAccess !== undefined ) { throw new Error( 'Guided app access requires deployment schema version 4 and a valid appAccess choice.', @@ -442,6 +455,11 @@ export function parsePlatformDeploymentManifest(value: unknown): PlatformDeploym if (authentication.configurable !== undefined && authentication.configurable !== true) { throw new Error('Invalid configurable authentication flag.'); } + if ( + value.schemaVersion === 5 ? value.runtimeLogs !== 'minimal' : value.runtimeLogs !== undefined + ) { + throw new Error('Minimal runtime logs require deployment schema version 5.'); + } const resourceDefaults = createPlatformResourceNames( requiredString(value.installationSlug, 'installationSlug'), ); @@ -462,7 +480,9 @@ export function parsePlatformDeploymentManifest(value: unknown): PlatformDeploym return hostname === null ? null : requiredString(hostname, `controlPlane.${key}`); }; return createPlatformDeploymentManifest({ - ...(value.schemaVersion === 4 ? { appAccess: value.appAccess as PlatformAppAccess } : {}), + ...(Number(value.schemaVersion) >= 4 && value.appAccess !== undefined + ? { appAccess: value.appAccess as PlatformAppAccess } + : {}), application: { baseDomain: requiredString(application.baseDomain, 'application.baseDomain'), dedicatedZone: application.dedicatedZone === true, @@ -496,5 +516,6 @@ export function parsePlatformDeploymentManifest(value: unknown): PlatformDeploym displayName: requiredString(value.displayName, 'displayName'), installationSlug: requiredString(value.installationSlug, 'installationSlug'), resources, + ...(value.schemaVersion === 5 ? { runtimeLogs: 'minimal' as const } : {}), }); } diff --git a/packages/platform/src/worker-config.ts b/packages/platform/src/worker-config.ts index bdbb63ac9..d8f93be99 100644 --- a/packages/platform/src/worker-config.ts +++ b/packages/platform/src/worker-config.ts @@ -79,10 +79,15 @@ export function createPlatformWorkerConfigs(input: { }), ), }); + // Sent only in minimal mode, so default Worker configs stay unchanged. + const runtimeLogs = deployment.runtimeLogs + ? { PLATFORM_RUNTIME_LOGS: deployment.runtimeLogs } + : {}; const common = { account_id: accountId, compatibility_date: '2026-02-24', - observability: { enabled: true }, + // Minimal runtime logs keep Workers Logs off on every platform Worker. + observability: { enabled: !deployment.runtimeLogs }, preview_urls: publicEndpoints && !deployment.authentication.accessProtection, workers_dev: publicEndpoints, }; @@ -201,6 +206,7 @@ export function createPlatformWorkerConfigs(input: { PLATFORM_HEALTH_PROBE_TOKEN: healthProbeToken, ...(deployment.appAccess ? { PLATFORM_APP_ACCESS: deployment.appAccess } : {}), PLATFORM_INSTALLATION_ID: installationId, + ...runtimeLogs, PLATFORM_TRAFFIC_STATE: trafficState, ...ownershipVars('api-worker', names.apiWorker), PLATFORM_VERSION: platformVersion, @@ -287,6 +293,7 @@ export function createPlatformWorkerConfigs(input: { analytics_engine_datasets: [{ binding: 'ANALYTICS', dataset: names.logsDataset }], vars: { PLATFORM_DISPATCH_WORKER_NAME: names.dispatchWorker, + ...runtimeLogs, ...ownershipVars('tail-worker', names.tailWorker), }, }, @@ -323,6 +330,7 @@ export function createPlatformWorkerConfigs(input: { PLATFORM_HEALTH_PROBE_TOKEN: healthProbeToken, PLATFORM_INSTALLATION_ID: installationId, PLATFORM_TRAFFIC_STATE: trafficState, + ...runtimeLogs, ...ownershipVars('dispatch-worker', names.dispatchWorker), }, }, diff --git a/packages/platform/test/unit/runtime-logs.test.ts b/packages/platform/test/unit/runtime-logs.test.ts new file mode 100644 index 000000000..9236efaea --- /dev/null +++ b/packages/platform/test/unit/runtime-logs.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { parsePlatformRuntimeManifest, platformDatabaseSchemaDigest } from '../../src/installer.ts'; +import { + createPlatformDeploymentManifest, + parsePlatformDeploymentManifest, + type PlatformDeploymentManifestInput, +} from '../../src/manifest.ts'; +import { deploymentInput, workerConfigs } from '../worker-config-fixture.ts'; + +const workersDevInput: PlatformDeploymentManifestInput = { + application: { + baseDomain: 'team.workers.dev', + zoneName: '', + workersDev: { subdomain: 'team', installationId: 'team-12345678' }, + }, + displayName: 'Team', + installationSlug: 'team', +}; +const configurableInput: PlatformDeploymentManifestInput = { + ...deploymentInput, + authentication: { providers: ['oidc'], configurable: true }, + capabilities: { githubOAuth: false }, +}; +const appAccessInput: PlatformDeploymentManifestInput = { + ...deploymentInput, + appAccess: 'platform', +}; + +// Each input and the schema version it gets without minimal runtime logs. +const inputs = [ + { name: 'plain', input: deploymentInput, version: 1 }, + { name: 'workers.dev', input: workersDevInput, version: 2 }, + { name: 'configurable auth', input: configurableInput, version: 3 }, + { name: 'app access', input: appAccessInput, version: 4 }, +] as const; + +function roundTrip(input: PlatformDeploymentManifestInput) { + const manifest = createPlatformDeploymentManifest(input); + return { + manifest, + parsed: parsePlatformDeploymentManifest(JSON.parse(JSON.stringify(manifest))), + }; +} + +describe('minimal runtime logs deployment manifest', () => { + it.each(inputs)('round-trips at schema version 5 with $name', ({ input }) => { + const { manifest, parsed } = roundTrip({ ...input, runtimeLogs: 'minimal' }); + expect(manifest.schemaVersion).toBe(5); + expect(manifest.runtimeLogs).toBe('minimal'); + expect(parsed).toEqual(manifest); + }); + + it('keeps every other setting at schema version 5', () => { + const minimal = createPlatformDeploymentManifest({ + ...workersDevInput, + appAccess: 'custom', + authentication: { providers: ['cloudflare-access'], accessProtection: true }, + capabilities: { githubOAuth: false }, + runtimeLogs: 'minimal', + }); + expect(parsePlatformDeploymentManifest(minimal)).toEqual(minimal); + expect(minimal).toMatchObject({ + appAccess: 'custom', + application: { workersDev: { subdomain: 'team', installationId: 'team-12345678' } }, + authentication: { + providers: ['cloudflare-access'], + configurable: true, + accessProtection: true, + }, + }); + }); + + it.each(inputs)('drops back to schema version $version when cleared ($name)', (entry) => { + const minimal = createPlatformDeploymentManifest({ ...entry.input, runtimeLogs: 'minimal' }); + const full = createPlatformDeploymentManifest(entry.input); + expect(full.schemaVersion).toBe(entry.version); + expect(full).not.toHaveProperty('runtimeLogs'); + const { runtimeLogs: _runtimeLogs, schemaVersion: _schemaVersion, ...rest } = minimal; + const { schemaVersion: _fullVersion, ...fullRest } = full; + expect(rest).toEqual(fullRest); + }); + + it.each(inputs)('rejects runtimeLogs below schema version 5 ($name)', ({ input }) => { + const manifest = createPlatformDeploymentManifest(input); + expect(() => parsePlatformDeploymentManifest({ ...manifest, runtimeLogs: 'minimal' })).toThrow( + 'schema version 5', + ); + }); + + it('rejects schema version 5 without runtimeLogs', () => { + const { runtimeLogs: _runtimeLogs, ...rest } = createPlatformDeploymentManifest({ + ...deploymentInput, + runtimeLogs: 'minimal', + }); + expect(() => parsePlatformDeploymentManifest(rest)).toThrow('schema version 5'); + }); + + it.each(['full', 'off', '', 1, null])('rejects runtimeLogs %j', (value) => { + const manifest = createPlatformDeploymentManifest({ + ...deploymentInput, + runtimeLogs: 'minimal', + }); + expect(() => parsePlatformDeploymentManifest({ ...manifest, runtimeLogs: value })).toThrow( + 'schema version 5', + ); + expect(() => + createPlatformDeploymentManifest({ ...deploymentInput, runtimeLogs: value as never }), + ).toThrow(); + }); + + it('rejects an invalid appAccess at schema version 5', () => { + const manifest = createPlatformDeploymentManifest({ + ...appAccessInput, + runtimeLogs: 'minimal', + }); + expect(() => parsePlatformDeploymentManifest({ ...manifest, appAccess: 'everyone' })).toThrow( + 'valid appAccess choice', + ); + }); + + it('keeps schema version 4 manifests unchanged', () => { + const { manifest, parsed } = roundTrip(appAccessInput); + expect(manifest.schemaVersion).toBe(4); + expect(manifest).not.toHaveProperty('runtimeLogs'); + expect(parsed).toEqual(manifest); + const { appAccess: _appAccess, ...withoutAppAccess } = manifest; + expect(() => parsePlatformDeploymentManifest(withoutAppAccess)).toThrow( + 'valid appAccess choice', + ); + }); +}); + +describe('minimal runtime logs Worker configs', () => { + const workers = ['api', 'dispatch', 'emailGateway', 'proxy', 'tail'] as const; + const minimal = () => + workerConfigs({ + deployment: createPlatformDeploymentManifest({ ...deploymentInput, runtimeLogs: 'minimal' }), + }); + + it('keeps default configs unchanged', () => { + const configs = workerConfigs(); + expect(Object.keys(configs).sort()).toEqual([...workers]); + for (const worker of workers) { + expect(configs[worker].observability).toEqual({ enabled: true }); + } + expect(JSON.stringify(configs)).not.toContain('PLATFORM_RUNTIME_LOGS'); + }); + + it('turns Workers Logs off on every platform Worker', () => { + const configs = minimal(); + for (const worker of workers) { + expect(configs[worker].observability).toEqual({ enabled: false }); + } + }); + + it('sets PLATFORM_RUNTIME_LOGS only on the api, tail and dispatch Workers', () => { + const configs = minimal(); + for (const worker of workers) { + const vars = (configs[worker].vars ?? {}) as Record; + if (worker === 'api' || worker === 'tail' || worker === 'dispatch') { + expect(vars.PLATFORM_RUNTIME_LOGS).toBe('minimal'); + } else { + expect(vars).not.toHaveProperty('PLATFORM_RUNTIME_LOGS'); + } + } + }); + + it('adds no bindings or other settings', () => { + const full = workerConfigs(); + const configs = minimal(); + for (const worker of workers) { + const { observability: _observability, vars, ...rest } = configs[worker]; + const { observability: _fullObservability, vars: fullVars, ...fullRest } = full[worker]; + expect(rest).toEqual(fullRest); + const { PLATFORM_RUNTIME_LOGS: _runtimeLogs, ...otherVars } = (vars ?? {}) as Record< + string, + unknown + >; + expect(otherVars).toEqual(fullVars ?? {}); + } + }); +}); + +describe('runtime manifest runtimeLogs protocol', () => { + const digest = '0'.repeat(64); + const artifact = (path: string) => ({ path, sha256: digest, size: 1 }); + const runtime = { + artifacts: { + api: artifact('api.mjs'), + dispatch: artifact('dispatch.mjs'), + proxy: artifact('proxy.mjs'), + tail: artifact('tail.mjs'), + }, + database: { + journalSha256: digest, + lineage: 'void', + migrations: [], + protocolVersion: 1, + rollbackSafeFrom: [], + schemaDigest: platformDatabaseSchemaDigest('void', []), + }, + platformVersion: '1.0.0', + schemaVersion: 2, + }; + + it('leaves runtimeLogs out of older runtime manifests', () => { + expect(parsePlatformRuntimeManifest(runtime)).not.toHaveProperty('runtimeLogs'); + }); + + it('accepts and keeps runtimeLogs: 1', () => { + expect(parsePlatformRuntimeManifest({ ...runtime, runtimeLogs: 1 }).runtimeLogs).toBe(1); + }); + + it.each([0, 2, '1', true, null])('rejects runtimeLogs %j', (value) => { + expect(() => parsePlatformRuntimeManifest({ ...runtime, runtimeLogs: value })).toThrow( + 'Unsupported runtime logs protocol.', + ); + }); +}); diff --git a/packages/void/src/cli/client.ts b/packages/void/src/cli/client.ts index 0ae8997cd..472a72d65 100644 --- a/packages/void/src/cli/client.ts +++ b/packages/void/src/cli/client.ts @@ -353,6 +353,12 @@ export type LogEntry = { status: string; }; +export type LogsResult = { + entries: Array; + /** Set when the platform keeps messages and URLs out of its logs. */ + runtimeLogs?: 'minimal'; +}; + export type RequestEntry = { timestamp: string; type: string; @@ -1693,7 +1699,7 @@ export class PlatformClient { limit = 200, deploymentId?: string, level?: string, - ): Promise> { + ): Promise { const params = new URLSearchParams({ since, limit: String(limit), @@ -1704,12 +1710,15 @@ export class PlatformClient { if (level && level !== 'all') { params.set('level', level); } - const json = await this.json<{ data: Array }>( + const json = await this.json<{ data: Array; runtimeLogs?: unknown }>( `${this.baseUrl}/projects/${projectId}/logs?${params}`, 'Failed to fetch logs', { permission: 'read' }, ); - return json.data; + return { + entries: json.data, + ...(json.runtimeLogs === 'minimal' ? { runtimeLogs: 'minimal' as const } : {}), + }; } async getRequests( diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index 7c8d80e92..7a0d66dfd 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -557,6 +557,11 @@ const HELP_TREE: HelpTree = { command: '--runtime ', description: 'Deploy an integrity-checked runtime directory instead of the package', }, + { + command: '--runtime-logs ', + description: + 'minimal keeps request URLs, console and exception messages out of runtime logs', + }, { command: '--yes', description: 'Acknowledge changes without an interactive prompt' }, ], }, @@ -1487,7 +1492,7 @@ function lifecycleHelp( return { summary, usage: [ - `void platform ${command} [id] [--plan]${command === 'disable' ? '' : ' [--runtime ]'}${command === 'upgrade' || command === 'repair' ? ' [--dashboard-url ] [--custom-domains]' : ''}${command === 'repair' ? ' [--auth-config ]' : ''} [--yes]`, + `void platform ${command} [id] [--plan]${command === 'disable' ? '' : ' [--runtime ]'}${command === 'upgrade' || command === 'repair' ? ' [--dashboard-url ] [--custom-domains]' : ''}${command === 'upgrade' ? ' [--runtime-logs ]' : ''}${command === 'repair' ? ' [--auth-config ]' : ''} [--yes]`, ], options: [ { command: '--plan', description: 'Print the exact plan without making changes' }, @@ -1512,6 +1517,15 @@ function lifecycleHelp( }, ] : []), + ...(command === 'upgrade' + ? [ + { + command: '--runtime-logs ', + description: + 'minimal keeps request URLs, console and exception messages out of runtime logs; omission keeps the saved setting', + }, + ] + : []), ...(command === 'repair' ? [ { diff --git a/packages/void/src/cli/logs.ts b/packages/void/src/cli/logs.ts index 5c1fe186d..62623f47f 100644 --- a/packages/void/src/cli/logs.ts +++ b/packages/void/src/cli/logs.ts @@ -1,7 +1,8 @@ import { outro, log, wrapTextWithPrefix } from '@voidzero-dev/vite-plus-prompts'; import { getToken } from './auth.ts'; -import { PlatformClient, type LogEntry } from './client.ts'; +import { PlatformClient, type LogEntry, type LogsResult } from './client.ts'; import { dim, errorStackFrames, formatCount, pc } from './output.ts'; +import { MINIMAL_RUNTIME_LOGS_NOTE } from './platform-runtime-logs.ts'; import { readDeploymentPlatform, readProjectConfig } from './project.ts'; import { sanitizeTerminalLogText } from './terminal-log-text.ts'; @@ -56,9 +57,9 @@ export async function runLogsCommand( const client = new PlatformClient(token, { root }); - let entries: Array; + let result: LogsResult; try { - entries = await client.getLogs( + result = await client.getLogs( linked.projectId, range, 200, @@ -70,7 +71,10 @@ export async function runLogsCommand( process.exit(1); } - entries = filterLogEntries(entries, level, filter); + const entries = filterLogEntries(result.entries, level, filter); + if (result.runtimeLogs === 'minimal') { + console.log(dim(MINIMAL_RUNTIME_LOGS_NOTE)); + } if (entries.length === 0) { outro(`No logs found in the last ${range}.`); @@ -78,17 +82,18 @@ export async function runLogsCommand( } for (const entry of entries) { - printEntry(entry); + printEntry(entry, result.runtimeLogs === 'minimal'); } outro(dim(formatCount(entries.length, 'log entry', 'log entries'))); } // Logs carry data from app visitors, so terminal control sequences are dropped before printing. -function printEntry(entry: LogEntry): void { +function printEntry(entry: LogEntry, minimal: boolean): void { const time = formatTime(entry.timestamp); const method = sanitizeTerminalLogText(entry.method || '-'); - const url = sanitizeTerminalLogText(entry.url || '-'); + // Minimal runtime logs record no URL or messages; their lines skip them. + const url = minimal ? '' : sanitizeTerminalLogText(entry.url || '-'); const status = sanitizeTerminalLogText(entry.status || '-'); const statusNum = parseInt(status, 10); @@ -102,7 +107,7 @@ function printEntry(entry: LogEntry): void { console.log( wrapTextWithPrefix( process.stdout, - `${method} ${url} ${coloredStatus}`, + url ? `${method} ${url} ${coloredStatus}` : `${method} ${coloredStatus}`, ' '.repeat(time.length + 1), `${dim(time)} `, ), @@ -118,7 +123,9 @@ function printEntry(entry: LogEntry): void { ? pc.yellow(`[${level}]`) : dim(`[${level}]`); console.log( - wrapTextWithPrefix(process.stdout, text, ' '.repeat(level.length + 5), ` ${marker} `), + text || !minimal + ? wrapTextWithPrefix(process.stdout, text, ' '.repeat(level.length + 5), ` ${marker} `) + : ` ${marker}`, ); } @@ -127,7 +134,7 @@ function printEntry(entry: LogEntry): void { wrapTextWithPrefix( process.stdout, [ - sanitizeTerminalLogText(`${ex.name}: ${ex.message}`), + sanitizeTerminalLogText(minimal && !ex.message ? ex.name : `${ex.name}: ${ex.message}`), ...errorStackFrames(ex).map((frame) => dim(sanitizeTerminalLogText(frame))), ].join('\n'), ' '.repeat(14), diff --git a/packages/void/src/cli/operator-cmd.ts b/packages/void/src/cli/operator-cmd.ts index 266069b28..2bb5b2ab9 100644 --- a/packages/void/src/cli/operator-cmd.ts +++ b/packages/void/src/cli/operator-cmd.ts @@ -11,7 +11,8 @@ import { saveOperatorToken, } from './operator-auth.ts'; import { OperatorClient, OperatorClientError } from './operator-client.ts'; -import { printSuccess, sanitizeTerminalText } from './output.ts'; +import { dim, printSuccess, sanitizeTerminalText } from './output.ts'; +import { MINIMAL_RUNTIME_LOGS_NOTE } from './platform-runtime-logs.ts'; const MAX_RUNTIME_FOLLOW_ROWS = 10_000; export const BUILD_LOG_DRAIN_TIMEOUT_MS: number = 2 * 60_000; @@ -165,11 +166,21 @@ function outputMutation(result: Record, args: OperatorArgs): vo printSuccess(`Platform ${args.group} ${args.action} completed.`); } -function outputLogs(result: Record, json: boolean): void { +function outputLogs( + result: Record, + json: boolean, + state: { runtimeLogsNoted?: boolean } = {}, +): void { if (json) { output(result, true); return; } + const minimal = result.runtimeLogs === 'minimal'; + if (minimal && !state.runtimeLogsNoted) { + // Follow polls print it once. + state.runtimeLogsNoted = true; + console.log(dim(MINIMAL_RUNTIME_LOGS_NOTE)); + } if (Array.isArray(result.lines)) { for (const entry of result.lines) { if (entry && typeof entry === 'object') { @@ -194,16 +205,24 @@ function outputLogs(result: Record, json: boolean): void { continue; } const row = entry as Record; - console.log( - `${printable(row.timestamp ?? row.ts)} ${printable(row.method)} ${printable(row.url)} ${printable(row.status ?? row.statusCode ?? row.outcome)}`, - ); + const parts = [ + printable(row.timestamp ?? row.ts), + printable(row.method), + printable(row.url), + printable(row.status ?? row.statusCode ?? row.outcome), + ]; + // Minimal runtime logs record no URL or messages; their lines skip them. + console.log((minimal ? parts.filter((part) => part !== '') : parts).join(' ')); if (Array.isArray(row.logs)) { for (const log of row.logs) { if (log && typeof log === 'object') { const record = log as Record; - console.log( - ` ${record.level ? `[${printable(record.level)}] ` : ''}${printable(record.message ?? log)}`, - ); + const level = record.level ? `[${printable(record.level)}] ` : ''; + if (minimal && Array.isArray(record.message) && record.message.length === 0) { + console.log(` ${level.trimEnd()}`); + } else { + console.log(` ${level}${printable(record.message ?? log)}`); + } } else { console.log(` ${printable(log)}`); } @@ -213,8 +232,12 @@ function outputLogs(result: Record, json: boolean): void { for (const exception of row.exceptions) { if (exception && typeof exception === 'object') { const record = exception as Record; - const name = record.name ? `${printable(record.name)}: ` : ''; - console.log(` ${name}${printable(record.message ?? exception)}`); + if (minimal && record.message === '' && record.name) { + console.log(` ${printable(record.name)}`); + } else { + const name = record.name ? `${printable(record.name)}: ` : ''; + console.log(` ${name}${printable(record.message ?? exception)}`); + } } else { console.log(` ${printable(exception)}`); } @@ -424,6 +447,7 @@ export async function followOperatorLogs( let previousRuntimeRows = new Map(); let currentRuntimeRows = new Map(); let currentRuntimeRowCount = 0; + const outputState = {}; try { while (!combined.aborted) { const result = await client.request(args.path, { query, signal: combined }); @@ -450,7 +474,7 @@ export async function followOperatorLogs( }); visibleResult = { ...result, data }; } - outputLogs(visibleResult, args.json); + outputLogs(visibleResult, args.json, outputState); if (args.group === 'build') { if ( typeof result.lastSeq !== 'number' || diff --git a/packages/void/src/cli/platform-args.ts b/packages/void/src/cli/platform-args.ts index d04a1c229..14d7c0d93 100644 --- a/packages/void/src/cli/platform-args.ts +++ b/packages/void/src/cli/platform-args.ts @@ -94,6 +94,8 @@ export type PlatformArgs = yes: boolean; zone: string; workersDev?: boolean; + /** Omitted keeps the recorded value; `full` clears it. */ + runtimeLogs?: PlatformRuntimeLogsChoice; } | { subcommand: 'upgrade' | 'repair' | 'disable' | 'enable'; @@ -101,6 +103,8 @@ export type PlatformArgs = authConfig?: string; dashboardUrl?: string; customDomains?: true; + /** upgrade only: omitted keeps the recorded value; `full` clears it. */ + runtimeLogs?: PlatformRuntimeLogsChoice; selector: string; plan: boolean; runtime: string; @@ -124,6 +128,15 @@ export type PlatformArgs = yes: boolean; }; +export type PlatformRuntimeLogsChoice = 'full' | 'minimal'; + +function parseRuntimeLogs(value: string): PlatformRuntimeLogsChoice { + if (value !== 'full' && value !== 'minimal') { + throw new Error('platform: --runtime-logs must be full or minimal.'); + } + return value; +} + function readValue(rest: Array, index: number, flag: string): [string, number] { const value = rest[index + 1]; if (!value || value.startsWith('-')) { @@ -171,8 +184,10 @@ function parseLifecycleSelector( authConfig?: string; dashboardUrl?: string; customDomains?: true; + runtimeLogs?: PlatformRuntimeLogsChoice; } { const positional: Array = []; + let runtimeLogs: PlatformRuntimeLogsChoice | undefined; let runtime = ''; let fromRuntime = ''; let authConfig: string | undefined; @@ -196,6 +211,10 @@ function parseLifecycleSelector( (subcommand === 'upgrade' || subcommand === 'repair') ) { customDomains = true; + } else if (arg === '--runtime-logs' && subcommand === 'upgrade') { + const [value, next] = readValue(rest, index, arg); + runtimeLogs = parseRuntimeLogs(value); + index = next; } else if (arg === '--auth-config' && subcommand === 'repair') { const [value, next] = readValue(rest, index, arg); authConfig = value; @@ -219,6 +238,7 @@ function parseLifecycleSelector( ...(authConfig !== undefined ? { authConfig } : {}), ...(dashboardUrl !== undefined ? { dashboardUrl } : {}), ...(customDomains ? { customDomains: true as const } : {}), + ...(runtimeLogs !== undefined ? { runtimeLogs } : {}), }; } @@ -329,8 +349,17 @@ export function parsePlatformArgs(args: Array): PlatformArgs { subcommand === 'disable' || subcommand === 'enable' ) { - const { selector, plan, runtime, fromRuntime, yes, authConfig, dashboardUrl, customDomains } = - parseLifecycleSelector(rest, subcommand); + const { + selector, + plan, + runtime, + fromRuntime, + yes, + authConfig, + dashboardUrl, + customDomains, + runtimeLogs, + } = parseLifecycleSelector(rest, subcommand); return subcommand === 'rollback' ? { subcommand, selector, plan, runtime, fromRuntime, yes } : { @@ -342,6 +371,7 @@ export function parsePlatformArgs(args: Array): PlatformArgs { ...(authConfig !== undefined ? { authConfig } : {}), ...(dashboardUrl !== undefined ? { dashboardUrl } : {}), ...(customDomains ? { customDomains } : {}), + ...(runtimeLogs !== undefined ? { runtimeLogs } : {}), }; } if (subcommand === 'uninstall') { @@ -381,6 +411,7 @@ export function parsePlatformArgs(args: Array): PlatformArgs { '--display-name', '--name', '--runtime', + '--runtime-logs', '--zone', ]); let workersDev = false; @@ -437,6 +468,9 @@ export function parsePlatformArgs(args: Array): PlatformArgs { if (appAccess !== undefined && !['public', 'platform', 'custom'].includes(appAccess)) { throw new Error('platform: --app-access accepts public, platform, or custom.'); } + const runtimeLogs = values.has('--runtime-logs') + ? parseRuntimeLogs(values.get('--runtime-logs')!) + : undefined; const loginMethods = values.get('--login-methods')?.split(','); if ( loginMethods && @@ -471,6 +505,7 @@ export function parsePlatformArgs(args: Array): PlatformArgs { ...(accessProtection !== undefined ? { accessProtection } : {}), ...(appAccess !== undefined ? { appAccess: appAccess as PlatformAppAccess } : {}), ...(workersDev ? { workersDev: true } : {}), + ...(runtimeLogs !== undefined ? { runtimeLogs } : {}), ...(values.has('--auth-config') ? { authConfig: values.get('--auth-config')! } : {}), account: values.get('--account') ?? '', applicationDomain: values.get('--application-domain') ?? '', diff --git a/packages/void/src/cli/platform-cloudflare.ts b/packages/void/src/cli/platform-cloudflare.ts index 27713b12c..3d3b1341b 100644 --- a/packages/void/src/cli/platform-cloudflare.ts +++ b/packages/void/src/cli/platform-cloudflare.ts @@ -319,7 +319,8 @@ export class PlatformCloudflareClient { headers: { Accept: 'application/json', Authorization: `Bearer ${this.#token}`, - ...(init?.body ? { 'Content-Type': 'application/json' } : {}), + // FormData sets its own multipart boundary. + ...(typeof init?.body === 'string' ? { 'Content-Type': 'application/json' } : {}), ...init?.headers, }, signal: init?.signal ?? AbortSignal.timeout(30_000), @@ -1290,6 +1291,25 @@ export class PlatformCloudflareClient { return result.map((entry) => requiredString(entry.id, 'Dispatch namespace script name')); } + /** Change settings of one namespace script. Omitted fields, such as bindings, are kept. */ + async patchDispatchNamespaceScriptSettings( + accountId: string, + namespace: string, + scriptName: string, + settings: Readonly>, + ): Promise { + const body = new FormData(); + body.set( + 'settings', + new Blob([JSON.stringify(settings)], { type: 'application/json' }), + 'settings.json', + ); + await this.request( + `/accounts/${encodeURIComponent(accountId)}/workers/dispatch/namespaces/${encodeURIComponent(namespace)}/scripts/${encodeURIComponent(scriptName)}/settings`, + { method: 'PATCH', body }, + ); + } + async deleteDispatchNamespaceScript( accountId: string, namespace: string, diff --git a/packages/void/src/cli/platform-cmd.ts b/packages/void/src/cli/platform-cmd.ts index a47c1b7ea..8fffa959c 100644 --- a/packages/void/src/cli/platform-cmd.ts +++ b/packages/void/src/cli/platform-cmd.ts @@ -268,6 +268,9 @@ export async function runPlatformCommand(_root: string, args: PlatformArgs): Pro function showSavedInstallationStatus(manifest: PlatformInstallationManifest): void { console.log(`Saved installation: ${manifest.id} (${manifest.status})`); console.log(`Checkpoint: ${manifest.updatedAt}`); + if (manifest.deployment.runtimeLogs) { + console.log(`Runtime logs: ${manifest.deployment.runtimeLogs}`); + } if (manifest.operation) { const operation = manifest.operation; console.log(`Saved operation: ${operation.action} — ${operation.phase}`); diff --git a/packages/void/src/cli/platform-installer.ts b/packages/void/src/cli/platform-installer.ts index 09ef67d54..bff8cce15 100644 --- a/packages/void/src/cli/platform-installer.ts +++ b/packages/void/src/cli/platform-installer.ts @@ -86,6 +86,7 @@ import { writeInstallationManifest, writePendingInstallationSecrets, } from './platform-registry.ts'; +import { assertPlatformRuntimeLogsSupported } from './platform-runtime-logs.ts'; import { assertPlatformWorkerSecretUpdateAuthority } from './platform-secret-update-authority.ts'; import { platformManagedWorkerTraffic } from './platform-traffic.ts'; @@ -239,6 +240,7 @@ export async function installPlatform( if (plan.deployment.appAccess && runtime.appAccessSetup !== 1) { throw new Error('platform: This runtime does not support the installation’s app access setup.'); } + assertPlatformRuntimeLogsSupported(plan.deployment, runtime); const hasEmailGatewayArtifact = runtime.artifacts.emailGateway !== undefined; if ( existing?.steps.some((step) => step.key === 'private-previews-initialized:v1') && diff --git a/packages/void/src/cli/platform-lifecycle.ts b/packages/void/src/cli/platform-lifecycle.ts index 4eec85089..846f2ef1a 100644 --- a/packages/void/src/cli/platform-lifecycle.ts +++ b/packages/void/src/cli/platform-lifecycle.ts @@ -71,6 +71,11 @@ import { writeInstallationManifest, writePendingInstallationSecrets, } from './platform-registry.ts'; +import { + assertPlatformRuntimeLogsSupported, + shouldUpdateProjectWorkerRuntimeLogs, + updateProjectWorkerRuntimeLogs, +} from './platform-runtime-logs.ts'; import { listSavedPlatformInstallPlans, savePlatformInstallChoices, @@ -240,7 +245,8 @@ export async function runPlatformLifecycleCommand(commandArgs: PlatformArgs): Pr args.appAccess === undefined && !args.dedicatedZoneSpecified && args.dashboardUrl === undefined && - !args.customDomains + !args.customDomains && + args.runtimeLogs === undefined ? listSavedPlatformInstallPlans().filter( (saved) => (!args.name || saved.args.name === normalizeInstallationSlug(args.name)) && @@ -743,6 +749,7 @@ export async function runPlatformLifecycleCommand(commandArgs: PlatformArgs): Pr ? { emailSenderDomain: process.env.VOID_EMAIL_SENDER_DOMAIN } : {}), installationSlug, + ...(args.runtimeLogs !== undefined ? { runtimeLogs: args.runtimeLogs } : {}), zoneName, ...(workersDev ? { workersDev: true } : {}), }; @@ -754,6 +761,7 @@ export async function runPlatformLifecycleCommand(commandArgs: PlatformArgs): Pr deploymentExecution: runtime.manifest.deploymentExecution === 1, }; let plan = await createPlatformInstallPlan(client, planInput, existing, planOptions); + assertPlatformRuntimeLogsSupported(plan.deployment, runtime.manifest); const commandOptions = { commandName: cliCommandName(), accountId, @@ -774,6 +782,8 @@ export async function runPlatformLifecycleCommand(commandArgs: PlatformArgs): Pr ? (externalHyperdrive?.id ?? 'create') : undefined, runtime: args.runtime, + // A resume keeps the recorded value, so only a new install repeats it. + ...(!existing && plan.deployment.runtimeLogs ? { runtimeLogs: 'minimal' as const } : {}), ...(existing ? { resumeId: existing.id } : {}), yes: args.yes || process.stdin.isTTY !== true, }; @@ -1353,11 +1363,29 @@ export async function runPlatformLifecycleCommand(commandArgs: PlatformArgs): Pr }); }, }); + const runtimeLogsWarning = + !result.waitingForDns && + !result.waitingForSandboxDrain && + shouldUpdateProjectWorkerRuntimeLogs({ + previous: setupManifest.deployment, + next: result.manifest.deployment, + explicit: args.runtimeLogs !== undefined, + }) + ? await updateProjectWorkerRuntimeLogs(client, { + accountId, + namespace: result.manifest.deployment.resources.dispatchNamespace, + minimal: !!result.manifest.deployment.runtimeLogs, + installationId: result.manifest.id, + }) + : undefined; progress?.stop( result.waitingForDns || result.waitingForSandboxDrain ? 'Installation paused. Progress is saved.' : 'Platform installed.', ); + if (runtimeLogsWarning) { + log.warn(runtimeLogsWarning); + } progress = undefined; if (result.waitingForDns) { note( diff --git a/packages/void/src/cli/platform-management.ts b/packages/void/src/cli/platform-management.ts index 5b1aad211..de677d30e 100644 --- a/packages/void/src/cli/platform-management.ts +++ b/packages/void/src/cli/platform-management.ts @@ -83,6 +83,11 @@ import { writeInstallationManifest, writePendingInstallationSecrets, } from './platform-registry.ts'; +import { + assertPlatformRuntimeLogsSupported, + shouldUpdateProjectWorkerRuntimeLogs, + updateProjectWorkerRuntimeLogs, +} from './platform-runtime-logs.ts'; import { assertNoPlatformProjects, checkPurgeResourcesCredentials, @@ -254,6 +259,9 @@ export function platformLifecycleResumeCommand( if ('customDomains' in args && args.customDomains) { command.push('--custom-domains'); } + if ('runtimeLogs' in args && args.runtimeLogs) { + command.push('--runtime-logs', args.runtimeLogs); + } if (args.yes) { command.push('--yes'); } @@ -431,6 +439,9 @@ export async function runInstalledPlatformCommand(args: PlatformArgs): Promise= 3, deploymentExecution: runtime!.manifest.deploymentExecution === 1, + allowRuntimeLogsChange: args.subcommand === 'upgrade', }); + assertPlatformRuntimeLogsSupported(plan.deployment, runtime!.manifest); markPhase('plan'); note( - formatPlatformInstallPlan(plan), + formatPlatformInstallPlan(plan, manifest.deployment), args.subcommand === 'upgrade' ? 'Upgrade plan' : args.subcommand === 'rollback' @@ -741,6 +754,7 @@ export async function runInstalledPlatformCommand(args: PlatformArgs): Promise ( await createPlatformInstallPlan(client, planInput, recoveryManifest, { + allowRuntimeLogsChange: args.subcommand === 'upgrade', authoritativeProvisionIntents: true, repairMissing: true, includeEmailGateway: runtime!.manifest.artifacts.emailGateway !== undefined, @@ -759,6 +773,21 @@ export async function runInstalledPlatformCommand(args: PlatformArgs): Promise { progress?.stop( result.waitingForDns || result.waitingForSandboxDrain @@ -808,6 +837,9 @@ export async function runInstalledPlatformCommand(args: PlatformArgs): Promise; @@ -114,6 +116,8 @@ export async function createPlatformInstallPlan( includeEmailGateway?: boolean; enableSandbox?: boolean; deploymentExecution?: boolean; + /** upgrade only: a changed runtime logs setting is not drift. */ + allowRuntimeLogsChange?: boolean; }, ): Promise { // Validate before even the first provider read; omission retains recorded intent. @@ -226,6 +230,12 @@ export async function createPlatformInstallPlan( 'platform: The API hostname must be outside the app Access wildcard. Use the application domain apex or workers.dev for the API, then plan again.', ); } + const runtimeLogs = + input.runtimeLogs === undefined + ? existing?.deployment.runtimeLogs + : input.runtimeLogs === 'minimal' + ? 'minimal' + : undefined; const deployment = createPlatformDeploymentManifest({ authentication: input.authentication ?? existing?.deployment.authentication, appAccess: input.appAccess ?? existing?.deployment.appAccess, @@ -250,6 +260,7 @@ export async function createPlatformInstallPlan( ...(normalizedEmailSenderDomain ? { emailSenderDomain: normalizedEmailSenderDomain } : {}), installationSlug, resources: existing?.deployment.resources ?? createPlatformResourceNames(installationSlug), + ...(runtimeLogs ? { runtimeLogs } : {}), }); const { emailSharedZoneId: configuredEmailZone, @@ -289,17 +300,27 @@ export async function createPlatformInstallPlan( capabilities: { ...existingWithDashboard.capabilities, customDomains: true }, } : existingWithDashboard; - const comparableExistingWithoutEmailZone = + const existingWithEmail = existing && deployment.capabilities.email && !existing.deployment.capabilities.email ? { ...existingWithCustomDomains, capabilities: { ...existingWithCustomDomains.capabilities, email: true }, } : existingWithCustomDomains; + // Upgrade may change runtime logs. The setting also moves the schema version, + // so leave both out of the comparison. + const comparable = ( + value: Omit, + ) => { + if (!options?.allowRuntimeLogsChange) { + return JSON.stringify(value); + } + const { runtimeLogs: _runtimeLogs, schemaVersion: _schemaVersion, ...rest } = value; + return JSON.stringify(rest); + }; if ( existing && - (JSON.stringify(comparableExistingWithoutEmailZone) !== - JSON.stringify(deploymentWithoutEmailZone) || + (comparable(existingWithEmail) !== comparable(deploymentWithoutEmailZone) || (recordedEmailZone !== undefined && recordedEmailZone !== configuredEmailZone) || (recordedSenderDomain !== undefined && recordedSenderDomain !== configuredSenderDomain)) ) { @@ -1417,7 +1438,13 @@ async function readRemoteOwnership( return null; } -export function formatPlatformInstallPlan(plan: PlatformInstallPlan): string { +/** `previous` is the installed deployment, so a changed setting shows what it was. */ +export function formatPlatformInstallPlan( + plan: PlatformInstallPlan, + previous?: PlatformDeploymentManifest, +): string { + const runtimeLogs = plan.deployment.runtimeLogs ?? 'full'; + const previousRuntimeLogs = previous ? (previous.runtimeLogs ?? 'full') : runtimeLogs; const lines = [ `${pc.bold(`${plan.deployment.displayName} in ${plan.account.name}`)}`, `${pc.bold('Account:')} ${plan.account.id}`, @@ -1438,6 +1465,11 @@ export function formatPlatformInstallPlan(plan: PlatformInstallPlan): string { : `${pc.bold('Default app access:')} ${{ public: 'Public apps', platform: 'Same as platform Access policy', custom: 'Selected Access policy' }[plan.deployment.appAccess]}`, ] : []), + ...(runtimeLogs !== previousRuntimeLogs + ? [`${pc.bold('Runtime logs:')} ${runtimeLogs} (was ${previousRuntimeLogs})`] + : plan.deployment.runtimeLogs + ? [`${pc.bold('Runtime logs:')} ${plan.deployment.runtimeLogs}`] + : []), `${pc.bold('Login callback:')} ${plan.apiUrl}/auth/callback`, `${pc.bold('Database:')} ${plan.externalHyperdrive?.database || 'PostgreSQL'} via Hyperdrive ${plan.deployment.resources.hyperdrive}`, '', diff --git a/packages/void/src/cli/platform-runtime-logs.ts b/packages/void/src/cli/platform-runtime-logs.ts new file mode 100644 index 000000000..2ff9d7a03 --- /dev/null +++ b/packages/void/src/cli/platform-runtime-logs.ts @@ -0,0 +1,102 @@ +import type { PlatformDeploymentManifest } from '@void/platform'; +import type { PlatformCloudflareClient } from './platform-cloudflare.ts'; + +/** + * Namespace scripts that run project code: one `user--` per + * deployment, and `void-do--` hosts for custom Durable Objects. + */ +const PROJECT_WORKER_PREFIXES = ['user-', 'void-do-']; +const CONCURRENCY = 4; + +/** Printed by log readers when the platform answers `runtimeLogs: 'minimal'`. */ +export const MINIMAL_RUNTIME_LOGS_NOTE = + 'Log messages, exception messages and URLs are not recorded on this platform.'; + +export type PlatformRuntimeLogsClient = Pick< + PlatformCloudflareClient, + 'listDispatchNamespaceScripts' | 'patchDispatchNamespaceScriptSettings' +>; + +/** Same `observability` the API sends when it uploads a project Worker. */ +export function projectWorkerObservability(minimal: boolean): Record { + return minimal + ? { enabled: false } + : { enabled: true, logs: { invocation_logs: true, head_sampling_rate: 1 } }; +} + +export function assertPlatformRuntimeLogsSupported( + deployment: PlatformDeploymentManifest, + runtime: Readonly<{ runtimeLogs?: 1 }>, +): void { + if (deployment.runtimeLogs && runtime.runtimeLogs !== 1) { + throw new Error( + 'platform: This platform runtime cannot keep runtime logs minimal. Upgrade Void or use a current platform runtime.', + ); + } +} + +/** + * Project Workers keep the settings they were uploaded with, so a changed + * mode is pushed to them. While minimal, every operation pushes it again, so + * a run that stopped before this step is finished by the next one. + */ +export function shouldUpdateProjectWorkerRuntimeLogs(input: { + previous: PlatformDeploymentManifest | null | undefined; + next: PlatformDeploymentManifest; + explicit: boolean; +}): boolean { + const minimal = !!input.next.runtimeLogs; + return minimal || input.explicit || minimal !== !!input.previous?.runtimeLogs; +} + +/** + * Update every project Worker's `observability` setting. Never throws: returns + * a warning to print when some Workers could not be updated. + */ +export async function updateProjectWorkerRuntimeLogs( + client: PlatformRuntimeLogsClient, + input: { accountId: string; namespace: string; minimal: boolean; installationId: string }, +): Promise { + const finish = input.minimal + ? ` Run \`void platform repair ${input.installationId}\` to finish.` + : ` Run \`void platform upgrade ${input.installationId} --runtime-logs full\` again to finish.`; + let scripts: ReadonlyArray; + try { + scripts = (await client.listDispatchNamespaceScripts(input.accountId, input.namespace)).filter( + (name) => PROJECT_WORKER_PREFIXES.some((prefix) => name.startsWith(prefix)), + ); + } catch (error) { + return `Could not list project Workers to update their Workers Logs setting: ${errorMessage(error)}.${finish}`; + } + const settings = { observability: projectWorkerObservability(input.minimal) }; + const failed: Array = []; + let next = 0; + const work = async () => { + while (next < scripts.length) { + const script = scripts[next++]!; + try { + await client.patchDispatchNamespaceScriptSettings( + input.accountId, + input.namespace, + script, + settings, + ); + } catch { + failed.push(script); + } + } + }; + await Promise.all(Array.from({ length: Math.min(CONCURRENCY, scripts.length) }, work)); + if (failed.length === 0) { + return undefined; + } + failed.sort(); + const shown = failed.slice(0, 5).join(', '); + const more = failed.length > 5 ? ` and ${failed.length - 5} more` : ''; + return `Could not update the Workers Logs setting of ${failed.length} project Worker${failed.length === 1 ? '' : 's'} (${shown}${more}).${finish}`; +} + +function errorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message.endsWith('.') ? message.slice(0, -1) : message; +} diff --git a/packages/void/src/cli/platform-setup.ts b/packages/void/src/cli/platform-setup.ts index cbc9ed392..c6fac9d9e 100644 --- a/packages/void/src/cli/platform-setup.ts +++ b/packages/void/src/cli/platform-setup.ts @@ -305,6 +305,7 @@ export type PlatformInstallCommandOptions = { appAccess?: PlatformDeploymentManifest['appAccess']; hyperdrive?: string; runtime: string; + runtimeLogs?: 'full' | 'minimal'; resumeId?: string; yes: boolean; }; @@ -357,6 +358,7 @@ export function platformInstallCommandArguments( } flag('--dashboard-url', options.dashboardUrl ?? ''); flag('--runtime', options.runtime); + flag('--runtime-logs', options.runtimeLogs ?? ''); if (options.yes) { parts.push('--yes'); } diff --git a/packages/void/test/unit/cli-help.test.ts b/packages/void/test/unit/cli-help.test.ts index ffc973737..e6cb530ea 100644 --- a/packages/void/test/unit/cli-help.test.ts +++ b/packages/void/test/unit/cli-help.test.ts @@ -124,6 +124,12 @@ describe('rendered help advertises the flags the parser honours', () => { return lines.join('\n').replace(/\u001b\[[0-9;]*m/g, ''); } + it('shows --runtime-logs only on platform install and upgrade', () => { + expect(render(['platform', 'install'])).toContain('--runtime-logs '); + expect(render(['platform', 'upgrade'])).toContain('--runtime-logs '); + expect(render(['platform', 'repair'])).not.toContain('--runtime-logs'); + }); + it('shows --provision on `deploy` with the platform it requires', () => { const page = render(['deploy']); expect(page).toContain('--provision'); diff --git a/packages/void/test/unit/logs-filter.test.ts b/packages/void/test/unit/logs-filter.test.ts index 3fc5587be..05f6632f5 100644 --- a/packages/void/test/unit/logs-filter.test.ts +++ b/packages/void/test/unit/logs-filter.test.ts @@ -203,6 +203,16 @@ describe('PlatformClient.getLogs URL', () => { expect(url).toContain('deploymentId=dpl_abc'); expect(url).toContain('level=warn'); }); + + it('keeps the minimal runtime logs marker from the response', async () => { + const client = new PlatformClient('tok', { apiUrl: 'http://localhost:1234' }); + globalThis.fetch = withPlatformDiscovery( + vi.fn(async () => Response.json({ data: [], runtimeLogs: 'minimal' })), + ); + expect(await client.getLogs('proj_1', '1h')).toEqual({ entries: [], runtimeLogs: 'minimal' }); + setFetchMock(); + expect(await client.getLogs('proj_1', '1h')).toEqual({ entries: [] }); + }); }); describe('PlatformClient.getRequests URL', () => { diff --git a/packages/void/test/unit/logs-rendering.test.ts b/packages/void/test/unit/logs-rendering.test.ts index da95e9ec1..eaa7b6eb4 100644 --- a/packages/void/test/unit/logs-rendering.test.ts +++ b/packages/void/test/unit/logs-rendering.test.ts @@ -5,7 +5,10 @@ import { afterEach, expect, it, vi } from 'vite-plus/test'; import { runLogsCommand } from '../../src/cli/logs.ts'; import { sanitizeTerminalLogText } from '../../src/cli/terminal-log-text.ts'; -const logsFixture = vi.hoisted(() => ({ entries: undefined as Array | undefined })); +const logsFixture = vi.hoisted(() => ({ + entries: undefined as Array | undefined, + runtimeLogs: undefined as 'minimal' | undefined, +})); vi.mock('../../src/cli/auth.ts', () => ({ getToken: () => 'fixture-token' })); vi.mock('../../src/cli/project.ts', () => ({ @@ -15,31 +18,35 @@ vi.mock('../../src/cli/project.ts', () => ({ vi.mock('../../src/cli/client.ts', () => ({ PlatformClient: class { async getLogs() { + const runtimeLogs = logsFixture.runtimeLogs ? { runtimeLogs: logsFixture.runtimeLogs } : {}; if (logsFixture.entries) { - return logsFixture.entries; + return { entries: logsFixture.entries, ...runtimeLogs }; } - return [ - { - timestamp: 0, - method: 'POST', - url: '/api/comments', - status: '500', - outcome: 'exception', - logs: [ - { - level: 'warn', - message: ['Cache miss. Refreshing the published page in the background.'], - }, - { - level: 'error', - message: ['Comment could not be saved.\nPlease retry after signing in.'], - }, - ], - exceptions: [ - { name: 'Error', message: 'DATABASE_URL is not configured for this deployment.' }, - ], - }, - ]; + return { + entries: [ + { + timestamp: 0, + method: 'POST', + url: '/api/comments', + status: '500', + outcome: 'exception', + logs: [ + { + level: 'warn', + message: ['Cache miss. Refreshing the published page in the background.'], + }, + { + level: 'error', + message: ['Comment could not be saved.\nPlease retry after signing in.'], + }, + ], + exceptions: [ + { name: 'Error', message: 'DATABASE_URL is not configured for this deployment.' }, + ], + }, + ], + ...runtimeLogs, + }; } }, })); @@ -60,6 +67,7 @@ vi.mock('../../src/cli/output.ts', async (original) => { afterEach(() => { logsFixture.entries = undefined; + logsFixture.runtimeLogs = undefined; vi.restoreAllMocks(); }); @@ -299,3 +307,58 @@ it('drops terminal control sequences from stack frames', async () => { expect(plain).toContain(' at f (a.js:1:1)'); expect(plain).toContain(' at g (b.js:2:2)2J'); }); + +it('notes minimal runtime logs and prints entries without a URL cleanly', async () => { + logsFixture.runtimeLogs = 'minimal'; + logsFixture.entries = [ + { + timestamp: 0, + method: 'GET', + url: '', + status: '500', + outcome: 'exception', + logs: [{ level: 'warn', message: [], timestamp: 0 }], + exceptions: [{ name: 'TypeError', message: '', timestamp: 0 }], + }, + ]; + const lines = (await printLogs(120)).map((line) => stripVTControlCharacters(line)); + expect(lines[1]).toBe( + 'Log messages, exception messages and URLs are not recorded on this platform.', + ); + // One space between each part: time, method, status. + expect(lines[2]!.split(' ').slice(1)).toEqual(['GET', '500']); + expect(lines.slice(3)).toEqual([' [warn]', ' [exception] TypeError']); +}); + +it('keeps the full-mode output for entries without a URL or message', async () => { + logsFixture.entries = [ + { + timestamp: 0, + method: 'GET', + url: '', + status: '500', + outcome: 'exception', + logs: [{ level: 'warn', message: [], timestamp: 0 }], + exceptions: [{ name: 'TypeError', message: '', timestamp: 0 }], + }, + ]; + const lines = (await printLogs(120)).map((line) => stripVTControlCharacters(line)); + expect(lines[1]!.split(' ').slice(1)).toEqual(['GET', '-', '500']); + expect(lines.slice(2)).toEqual([' [warn] ', ' [exception] TypeError: ']); +}); + +it('notes minimal runtime logs even when there are no entries', async () => { + logsFixture.runtimeLogs = 'minimal'; + logsFixture.entries = []; + const lines = (await printLogs(120)).map((line) => stripVTControlCharacters(line)); + expect(lines).toEqual([ + '', + 'Log messages, exception messages and URLs are not recorded on this platform.', + ]); + expect(vi.mocked(outro).mock.calls.at(-1)![0]).toBe('No logs found in the last 1h.'); +}); + +it('prints no note for full runtime logs', async () => { + const lines = await printLogs(120); + expect(lines.join('\n')).not.toContain('not recorded on this platform'); +}); diff --git a/packages/void/test/unit/operator-cli.test.ts b/packages/void/test/unit/operator-cli.test.ts index 89f2a1ce8..8e5e503db 100644 --- a/packages/void/test/unit/operator-cli.test.ts +++ b/packages/void/test/unit/operator-cli.test.ts @@ -1372,6 +1372,35 @@ describe('operator transport and execution', () => { expect(String(fetcher.mock.calls[1]![0])).toContain('after=2000'); expect(String(fetcher.mock.calls[1]![0])).not.toContain('since='); }); + it('notes minimal runtime logs once while following', async () => { + const controller = new AbortController(); + const write = vi.spyOn(console, 'log').mockImplementation(() => {}); + const fetcher = vi + .fn() + .mockResolvedValueOnce( + Response.json({ data: [], nextCursor: null, runtimeLogs: 'minimal', watermark: '2000' }), + ) + .mockImplementationOnce(async () => { + controller.abort(); + return Response.json({ + data: [], + nextCursor: null, + runtimeLogs: 'minimal', + watermark: '3000', + }); + }); + + await followOperatorLogs( + new OperatorClient('https://one.example', 'token', fetcher), + parseOperatorArgs(['deployment', 'logs', 'id', '--follow', '--since', '1h']), + controller.signal, + 0, + ); + + expect( + write.mock.calls.filter(([line]) => String(line).includes('not recorded on this platform')), + ).toHaveLength(1); + }); it('fails explicitly when a runtime follow window exceeds its dedupe bound', async () => { vi.spyOn(console, 'log').mockImplementation(() => {}); const row = { @@ -1432,6 +1461,73 @@ describe('operator transport and execution', () => { ]); }); + it('notes minimal runtime logs once and prints rows without a URL cleanly', async () => { + vi.stubEnv('VOID_API_URL', 'https://one.example'); + vi.stubEnv('VOID_OPERATOR_TOKEN', 'operator'); + const write = vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + Response.json({ + data: [ + { + timestamp: 1234, + method: 'GET', + url: '', + status: '503', + outcome: 'exception', + logs: [{ level: 'error', message: [], timestamp: 1 }], + exceptions: [{ name: 'TypeError', message: '', timestamp: 1 }], + }, + ], + nextCursor: null, + runtimeLogs: 'minimal', + watermark: '1235', + }), + ), + ); + + await runOperatorCommand(parseOperatorArgs(['deployment', 'logs', 'deployment-1'])); + + expect(write.mock.calls.map(([line]) => stripVTControlCharacters(String(line)))).toEqual([ + 'Log messages, exception messages and URLs are not recorded on this platform.', + '1234 GET 503', + ' [error]', + ' TypeError', + ]); + }); + it('keeps the full-mode row format for an empty URL or message', async () => { + vi.stubEnv('VOID_API_URL', 'https://one.example'); + vi.stubEnv('VOID_OPERATOR_TOKEN', 'operator'); + const write = vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + Response.json({ + data: [ + { + timestamp: 1234, + method: 'GET', + url: '', + status: '503', + logs: [{ level: 'error', message: '', timestamp: 1 }], + exceptions: [{ name: 'TypeError', message: '', timestamp: 1 }], + }, + ], + nextCursor: null, + watermark: '1235', + }), + ), + ); + + await runOperatorCommand(parseOperatorArgs(['deployment', 'logs', 'deployment-1'])); + + expect(write.mock.calls.map(([line]) => stripVTControlCharacters(String(line)))).toEqual([ + '1234 GET 503', + ' [error] ', + ' TypeError: ', + ]); + }); it('neutralizes C1 terminal controls in human operator output', async () => { vi.stubEnv('VOID_API_URL', 'https://one.example'); vi.stubEnv('VOID_OPERATOR_TOKEN', 'operator'); diff --git a/packages/void/test/unit/platform-args.test.ts b/packages/void/test/unit/platform-args.test.ts index eff18d999..416a2ea45 100644 --- a/packages/void/test/unit/platform-args.test.ts +++ b/packages/void/test/unit/platform-args.test.ts @@ -52,6 +52,26 @@ describe('platform args', () => { ).toThrow(); } }); + it('accepts --runtime-logs only in install and upgrade', () => { + for (const command of ['install', 'upgrade']) { + expect(parsePlatformArgs([command, '--runtime-logs', 'minimal'])).toMatchObject({ + runtimeLogs: 'minimal', + }); + expect(parsePlatformArgs([command, '--runtime-logs', 'full'])).toMatchObject({ + runtimeLogs: 'full', + }); + expect(parsePlatformArgs([command])).not.toHaveProperty('runtimeLogs'); + expect(() => parsePlatformArgs([command, '--runtime-logs', 'none'])).toThrow( + 'platform: --runtime-logs must be full or minimal.', + ); + expect(() => parsePlatformArgs([command, '--runtime-logs'])).toThrow('requires a value'); + } + for (const command of ['repair', 'rollback', 'enable', 'disable', 'uninstall']) { + expect(() => + parsePlatformArgs([command, '--runtime', 'dir', '--runtime-logs', 'minimal']), + ).toThrow(); + } + }); it('turns on custom domains only in install, upgrade, and repair', () => { for (const command of ['install', 'upgrade', 'repair']) { expect(parsePlatformArgs([command, '--custom-domains'])).toMatchObject({ diff --git a/packages/void/test/unit/platform-installer.test.ts b/packages/void/test/unit/platform-installer.test.ts index 4bc563a20..02c468ef2 100644 --- a/packages/void/test/unit/platform-installer.test.ts +++ b/packages/void/test/unit/platform-installer.test.ts @@ -921,6 +921,24 @@ describe('platform installer', () => { ).toBe(false); }); + it('refuses minimal runtime logs on a runtime that cannot honour them', async () => { + useRegistryDirectory(); + const plan = await examplePlan(null, { runtimeLogs: 'minimal' }); + const current = resolvePlatformRuntime(getPlatformRuntimeDirectory()); + // The built runtime declares support; only the copy below drops it. + expect(current.manifest.runtimeLogs).toBe(1); + const { runtimeLogs: _runtimeLogs, ...olderManifest } = current.manifest; + await expect( + installPlatform(plan, freshSecrets(), null, { + acquireDatabaseLifecycleLock, + claimDatabase, + client: unusedClient(), + revalidateBeforeWorkerDeploy: async () => {}, + runtime: { ...current, manifest: olderManifest }, + }), + ).rejects.toThrow('This platform runtime cannot keep runtime logs minimal'); + }); + it('requires externally custodied identity keys for a fresh installation', () => { const { jwtSecret: _jwtSecret, diff --git a/packages/void/test/unit/platform-management-upgrade.test.ts b/packages/void/test/unit/platform-management-upgrade.test.ts index 056daf8e6..01145e305 100644 --- a/packages/void/test/unit/platform-management-upgrade.test.ts +++ b/packages/void/test/unit/platform-management-upgrade.test.ts @@ -26,6 +26,8 @@ const state = vi.hoisted(() => ({ clientTokens: [] as Array, listAccounts: vi.fn(), listWorkerNames: vi.fn(), + listNamespaceScripts: vi.fn(), + patchScriptSettings: vi.fn(), request: vi.fn(), requiredGatewayToken: '', events: [] as Array, @@ -86,6 +88,8 @@ vi.mock('../../src/cli/platform-cloudflare.ts', async (importOriginal) => { } listAccounts = state.listAccounts; listWorkerNames = state.listWorkerNames; + listDispatchNamespaceScripts = state.listNamespaceScripts; + patchDispatchNamespaceScriptSettings = state.patchScriptSettings; request = state.request; getWorkerLastDeployedFrom = state.getWorkerLastDeployedFrom; async getKvValue() { @@ -287,6 +291,21 @@ describe('installed platform upgrade', () => { "void platform rollback installation --runtime '/tmp/earlier runtime' --from-runtime '/tmp/current'\\''s runtime' --yes", ); }); + it('replays --runtime-logs in the upgrade resume command', () => { + expect( + platformLifecycleResumeCommand( + { + subcommand: 'upgrade', + selector: '', + plan: false, + yes: true, + runtime: '', + runtimeLogs: 'minimal', + }, + 'installation', + ), + ).toBe('void platform upgrade installation --runtime-logs minimal --yes'); + }); beforeEach(() => { vi.clearAllMocks(); vi.stubEnv('CLOUDFLARE_API_TOKEN', 'token'); @@ -295,6 +314,10 @@ describe('installed platform upgrade', () => { state.requiredGatewayToken = ''; state.listAccounts.mockReset().mockResolvedValue([{ id: 'account', name: 'Account' }]); state.listWorkerNames.mockReset().mockResolvedValue([]); + state.listNamespaceScripts + .mockReset() + .mockResolvedValue(['user-app-dpl', 'void-do-project-hash', 'other']); + state.patchScriptSettings.mockReset().mockResolvedValue(undefined); state.request.mockReset().mockResolvedValue({ name: 'assets' }); state.getWorkerLastDeployedFrom.mockReset().mockResolvedValue('cf_cli'); state.wait.mockReset().mockResolvedValue(undefined); @@ -471,6 +494,94 @@ describe('installed platform upgrade', () => { }, ); + describe('project Worker runtime logs', () => { + const useMinimal = () => { + state.manifest = { + ...state.manifest!, + deployment: { ...state.manifest!.deployment, runtimeLogs: 'minimal', schemaVersion: 5 }, + }; + }; + const patched = () => + state.patchScriptSettings.mock.calls.map(([account, namespace, script, settings]) => ({ + account, + namespace, + script, + settings, + })); + + it.each([ + ['upgrade', { runtimeLogs: 'minimal' as const }], + ['repair', {}], + ] as const)( + 'turns Workers Logs off on project Workers after %s while minimal', + async (subcommand, extra) => { + useMinimal(); + await runInstalledPlatformCommand({ + subcommand, + selector: state.manifest!.id, + plan: false, + runtime: '', + yes: true, + ...extra, + }); + const namespace = state.manifest!.deployment.resources.dispatchNamespace; + expect(state.listNamespaceScripts).toHaveBeenCalledExactlyOnceWith('account', namespace); + expect(patched()).toEqual( + ['user-app-dpl', 'void-do-project-hash'].map((script) => ({ + account: 'account', + namespace, + script, + settings: { observability: { enabled: false } }, + })), + ); + expect(state.patchScriptSettings.mock.invocationCallOrder[0]).toBeGreaterThan( + vi.mocked(installPlatform).mock.invocationCallOrder[0]!, + ); + expect(log.warn).not.toHaveBeenCalled(); + }, + ); + + it('leaves project Workers alone on a plain full-mode upgrade', async () => { + await upgrade(); + expect(state.listNamespaceScripts).not.toHaveBeenCalled(); + expect(state.patchScriptSettings).not.toHaveBeenCalled(); + }); + + it('turns Workers Logs back on when full mode is asked for', async () => { + await runInstalledPlatformCommand({ + subcommand: 'upgrade', + selector: state.manifest!.id, + plan: false, + runtime: '', + yes: true, + runtimeLogs: 'full', + }); + expect(patched().map(({ settings }) => settings)).toEqual([ + { + observability: { enabled: true, logs: { invocation_logs: true, head_sampling_rate: 1 } }, + }, + { + observability: { enabled: true, logs: { invocation_logs: true, head_sampling_rate: 1 } }, + }, + ]); + }); + + it('warns with a repair hint when a project Worker cannot be updated', async () => { + useMinimal(); + state.patchScriptSettings.mockRejectedValueOnce(new Error('boom')); + await runInstalledPlatformCommand({ + subcommand: 'repair', + selector: state.manifest!.id, + plan: false, + runtime: '', + yes: true, + }); + expect(log.warn).toHaveBeenCalledWith( + expect.stringContaining(`Run \`void platform repair ${state.manifest!.id}\` to finish.`), + ); + }); + }); + it('borrows database preparation from the held lifecycle lock', async () => { await upgrade(); diff --git a/packages/void/test/unit/platform-plan.test.ts b/packages/void/test/unit/platform-plan.test.ts index 55d5a2686..466c6a7bc 100644 --- a/packages/void/test/unit/platform-plan.test.ts +++ b/packages/void/test/unit/platform-plan.test.ts @@ -263,6 +263,80 @@ describe('platform installation planning', () => { expect(formatPlatformInstallPlan(resumed)).toContain('https://dash.example.org:8443'); }); + it('records minimal runtime logs and changes them only on upgrade', async () => { + const full = await createPlatformInstallPlan(client(), input, null); + expect(full.deployment).not.toHaveProperty('runtimeLogs'); + const minimal = await createPlatformInstallPlan( + client(), + { ...input, runtimeLogs: 'minimal' }, + null, + ); + expect(minimal.deployment).toMatchObject({ runtimeLogs: 'minimal', schemaVersion: 5 }); + expect(minimal.actions).toEqual(full.actions); + expect(stripVTControlCharacters(formatPlatformInstallPlan(minimal))).toContain( + 'Runtime logs: minimal', + ); + expect(formatPlatformInstallPlan(full)).not.toContain('Runtime logs'); + // An upgrade shows a change in both directions. + expect(stripVTControlCharacters(formatPlatformInstallPlan(full, minimal.deployment))).toContain( + 'Runtime logs: full (was minimal)', + ); + expect(stripVTControlCharacters(formatPlatformInstallPlan(minimal, full.deployment))).toContain( + 'Runtime logs: minimal (was full)', + ); + expect(formatPlatformInstallPlan(full, full.deployment)).not.toContain('Runtime logs'); + + const recordedFull = manifestFor(full); + const recordedMinimal = manifestFor(minimal); + // Omitted keeps the recorded value. + expect( + (await createPlatformInstallPlan(client(), input, recordedMinimal)).deployment.runtimeLogs, + ).toBe('minimal'); + expect( + (await createPlatformInstallPlan(client(), input, recordedFull)).deployment, + ).not.toHaveProperty('runtimeLogs'); + // Upgrade switches it on and off. + const on = await createPlatformInstallPlan( + client(), + { ...input, runtimeLogs: 'minimal' }, + recordedFull, + { allowRuntimeLogsChange: true }, + ); + expect(on.deployment).toEqual(minimal.deployment); + const off = await createPlatformInstallPlan( + client(), + { ...input, runtimeLogs: 'full' }, + recordedMinimal, + { allowRuntimeLogsChange: true }, + ); + expect(off.deployment).toEqual(full.deployment); + // Other drift still throws on upgrade. + await expect( + createPlatformInstallPlan( + client(), + { ...input, displayName: 'Other', runtimeLogs: 'full' }, + recordedMinimal, + { allowRuntimeLogsChange: true }, + ), + ).rejects.toThrow('--resume inputs do not match'); + // An install resume with a different value throws. + await expect( + createPlatformInstallPlan(client(), { ...input, runtimeLogs: 'minimal' }, recordedFull), + ).rejects.toThrow('--resume inputs do not match'); + await expect( + createPlatformInstallPlan(client(), { ...input, runtimeLogs: 'full' }, recordedMinimal), + ).rejects.toThrow('--resume inputs do not match'); + expect( + ( + await createPlatformInstallPlan( + client(), + { ...input, runtimeLogs: 'minimal' }, + recordedMinimal, + ) + ).deployment.runtimeLogs, + ).toBe('minimal'); + }); + it('retains an existing advertised origin and rejects malformed configuration before provider reads', async () => { const recorded = setInstallationEndpoints( legacyInstallation(), diff --git a/packages/void/test/unit/platform-rollback.test.ts b/packages/void/test/unit/platform-rollback.test.ts index d882244a7..2ddd40e84 100644 --- a/packages/void/test/unit/platform-rollback.test.ts +++ b/packages/void/test/unit/platform-rollback.test.ts @@ -221,6 +221,38 @@ describe('platform post-success rollback', () => { ).toThrow('artifact digest'); }); + it('refuses rollback to a runtime without minimal runtime logs while they are on', () => { + const target = runtime({ + digest: 'a'.repeat(64), + migrations: [firstMigration], + platformVersion: '1.0.0', + schemaDigest: 'c'.repeat(64), + }); + const source = runtime({ + digest: 'b'.repeat(64), + migrations: [firstMigration, secondMigration], + platformVersion: '1.0.0', + rollbackSafeFrom: [{ platformVersion: '1.0.0', schemaDigest: 'c'.repeat(64) }], + schemaDigest: 'd'.repeat(64), + }); + const installation = installed(source); + const minimal = { + ...installation, + deployment: { ...installation.deployment, runtimeLogs: 'minimal' as const }, + }; + expect(() => assertPlatformRollbackCompatibility(minimal, target, source)).toThrow( + 'Run `void platform upgrade --runtime-logs full` first', + ); + expect(() => + assertPlatformRollbackCompatibility( + minimal, + { ...target, manifest: { ...target.manifest, runtimeLogs: 1 } }, + source, + ), + ).not.toThrow(); + expect(() => assertPlatformRollbackCompatibility(installation, target, source)).not.toThrow(); + }); + it('rejects a runtime that can reopen sandbox creation after drain', () => { const legacy = runtime({ digest: 'a'.repeat(64), diff --git a/packages/void/test/unit/platform-runtime-logs.test.ts b/packages/void/test/unit/platform-runtime-logs.test.ts new file mode 100644 index 000000000..4d9f7d29f --- /dev/null +++ b/packages/void/test/unit/platform-runtime-logs.test.ts @@ -0,0 +1,146 @@ +import { createPlatformDeploymentManifest } from '@void/platform'; +import { describe, expect, it } from 'vite-plus/test'; +import { PlatformCloudflareClient } from '../../src/cli/platform-cloudflare.ts'; +import { + assertPlatformRuntimeLogsSupported, + projectWorkerObservability, + shouldUpdateProjectWorkerRuntimeLogs, + updateProjectWorkerRuntimeLogs, + type PlatformRuntimeLogsClient, +} from '../../src/cli/platform-runtime-logs.ts'; + +const full = createPlatformDeploymentManifest({ + application: { baseDomain: 'example.com', zoneName: 'example.com' }, + displayName: 'Example', + installationSlug: 'example', +}); +const minimal = createPlatformDeploymentManifest({ + application: { baseDomain: 'example.com', zoneName: 'example.com' }, + displayName: 'Example', + installationSlug: 'example', + runtimeLogs: 'minimal', +}); + +function fakeClient(scripts: ReadonlyArray, failing: ReadonlySet = new Set()) { + const patched: Array<{ script: string; settings: unknown }> = []; + let active = 0; + let peak = 0; + const client: PlatformRuntimeLogsClient = { + listDispatchNamespaceScripts: async (accountId, namespace) => { + expect([accountId, namespace]).toEqual(['account', 'example-users']); + return scripts; + }, + patchDispatchNamespaceScriptSettings: async (_accountId, _namespace, script, settings) => { + active++; + peak = Math.max(peak, active); + await new Promise((resolve) => setTimeout(resolve, 1)); + active--; + if (failing.has(script)) { + throw new Error('rate limited'); + } + patched.push({ script, settings }); + }, + }; + return { client, patched, peak: () => peak }; +} + +const target = { accountId: 'account', namespace: 'example-users', installationId: 'example-1' }; + +describe('project Worker runtime logs', () => { + it('matches the observability the API uploads', () => { + expect(projectWorkerObservability(true)).toEqual({ enabled: false }); + expect(projectWorkerObservability(false)).toEqual({ + enabled: true, + logs: { invocation_logs: true, head_sampling_rate: 1 }, + }); + }); + + it('updates only project Workers, a few at a time', async () => { + const scripts = [ + ...Array.from({ length: 9 }, (_, index) => `user-app-dpl${index}`), + 'void-do-project1-0123456789abcdef', + ]; + const fake = fakeClient([...scripts, 'other-script']); + const warning = await updateProjectWorkerRuntimeLogs(fake.client, { ...target, minimal: true }); + expect(warning).toBeUndefined(); + expect(fake.patched.map((entry) => entry.script).sort()).toEqual(scripts); + expect(fake.patched[0]!.settings).toEqual({ observability: { enabled: false } }); + expect(fake.peak()).toBeLessThanOrEqual(4); + + const back = fakeClient(['user-app-dpl0']); + await updateProjectWorkerRuntimeLogs(back.client, { ...target, minimal: false }); + expect(back.patched[0]!.settings).toEqual({ + observability: { enabled: true, logs: { invocation_logs: true, head_sampling_rate: 1 } }, + }); + }); + + it('returns a warning instead of failing', async () => { + const fake = fakeClient(['user-a-1', 'user-b-1'], new Set(['user-b-1'])); + expect(await updateProjectWorkerRuntimeLogs(fake.client, { ...target, minimal: true })).toBe( + 'Could not update the Workers Logs setting of 1 project Worker (user-b-1). Run `void platform repair example-1` to finish.', + ); + expect(fake.patched.map((entry) => entry.script)).toEqual(['user-a-1']); + + const off = fakeClient(['user-b-1'], new Set(['user-b-1'])); + expect(await updateProjectWorkerRuntimeLogs(off.client, { ...target, minimal: false })).toBe( + 'Could not update the Workers Logs setting of 1 project Worker (user-b-1). Run `void platform upgrade example-1 --runtime-logs full` again to finish.', + ); + + const listing: PlatformRuntimeLogsClient = { + listDispatchNamespaceScripts: async () => { + throw new Error('Cloudflare API error: forbidden.'); + }, + patchDispatchNamespaceScriptSettings: async () => {}, + }; + expect(await updateProjectWorkerRuntimeLogs(listing, { ...target, minimal: true })).toBe( + 'Could not list project Workers to update their Workers Logs setting: Cloudflare API error: forbidden. Run `void platform repair example-1` to finish.', + ); + }); + + it('runs while minimal, when the mode changes, or when the flag is given', () => { + const check = (previous: typeof full | null, next: typeof full, explicit = false) => + shouldUpdateProjectWorkerRuntimeLogs({ previous, next, explicit }); + expect(check(full, minimal)).toBe(true); + expect(check(minimal, full)).toBe(true); + // An earlier run may have stopped before this step. + expect(check(minimal, minimal)).toBe(true); + expect(check(full, full)).toBe(false); + expect(check(full, full, true)).toBe(true); + expect(check(null, full)).toBe(false); + }); + + it('requires a runtime that honours minimal runtime logs', () => { + expect(() => assertPlatformRuntimeLogsSupported(minimal, {})).toThrow( + 'This platform runtime cannot keep runtime logs minimal', + ); + expect(() => assertPlatformRuntimeLogsSupported(minimal, { runtimeLogs: 1 })).not.toThrow(); + expect(() => assertPlatformRuntimeLogsSupported(full, {})).not.toThrow(); + }); + + it('sends the settings as a multipart part without replacing bindings', async () => { + let seen: { url: string; method?: string; type: string | null; part: unknown } | undefined; + const client = new PlatformCloudflareClient('token', { + apiBase: 'https://cloudflare.example.test/client/v4', + fetcher: async (input, init) => { + const body = init?.body as FormData; + const part = body.get('settings') as Blob; + seen = { + url: String(input), + method: init?.method, + type: new Headers(init?.headers).get('content-type'), + part: { type: part.type, value: JSON.parse(await part.text()) }, + }; + return Response.json({ success: true, result: {} }); + }, + }); + await client.patchDispatchNamespaceScriptSettings('account', 'example-users', 'user-a-1', { + observability: { enabled: false }, + }); + expect(seen).toEqual({ + url: 'https://cloudflare.example.test/client/v4/accounts/account/workers/dispatch/namespaces/example-users/scripts/user-a-1/settings', + method: 'PATCH', + type: null, + part: { type: 'application/json', value: { observability: { enabled: false } } }, + }); + }); +}); diff --git a/packages/void/test/unit/platform-saved-plans.test.ts b/packages/void/test/unit/platform-saved-plans.test.ts index 140fa74b8..a629b187f 100644 --- a/packages/void/test/unit/platform-saved-plans.test.ts +++ b/packages/void/test/unit/platform-saved-plans.test.ts @@ -73,6 +73,14 @@ describe('saved platform install choices', () => { } }); + it('replays minimal runtime logs in a saved plan', () => { + savePlatformInstallChoices(argumentsFor({ runtimeLogs: 'minimal' }), 'Account', directory); + expect(listSavedPlatformInstallPlans(directory)[0]!.args).toMatchObject({ + runtimeLogs: 'minimal', + }); + expect(argumentsFor()).not.toContain('--runtime-logs'); + }); + it('updates the same account and slug while retaining plans for other accounts', () => { const first = savePlatformInstallChoices(argumentsFor(), 'Account', directory); const other = savePlatformInstallChoices( diff --git a/platform/packages/api/src/admin/fate/telemetry.ts b/platform/packages/api/src/admin/fate/telemetry.ts index 1f1bf9a0b..8dc1c0479 100644 --- a/platform/packages/api/src/admin/fate/telemetry.ts +++ b/platform/packages/api/src/admin/fate/telemetry.ts @@ -34,6 +34,7 @@ export const buildStatusDataView = dataView('AdminBuildStatus export const deploymentLogsDataView = dataView('AdminDeploymentLogs')({ id: true, data: true, + runtimeLogs: true, }); export type AdminBuildLogs = Entity; diff --git a/platform/packages/api/src/admin/fate/views.ts b/platform/packages/api/src/admin/fate/views.ts index a8c0d4261..096214ada 100644 --- a/platform/packages/api/src/admin/fate/views.ts +++ b/platform/packages/api/src/admin/fate/views.ts @@ -65,7 +65,11 @@ export const BuildLogsView = view()({ terminal: true, logsComplete: true, }); -export const DeploymentLogsView = view()({ id: true, data: true }); +export const DeploymentLogsView = view()({ + id: true, + data: true, + runtimeLogs: true, +}); export const buildLogsRequest = (id: string, since: number) => ({ buildLogs: { view: BuildLogsView, args: { id, since } } }) as const; export const deploymentLogsRequest = (id: string, since: string, limit: number) => diff --git a/platform/packages/api/src/admin/react/views/DeploymentLogs.tsx b/platform/packages/api/src/admin/react/views/DeploymentLogs.tsx index 62ebf5f31..657c9c6e1 100644 --- a/platform/packages/api/src/admin/react/views/DeploymentLogs.tsx +++ b/platform/packages/api/src/admin/react/views/DeploymentLogs.tsx @@ -36,7 +36,14 @@ function formatMessage(message: unknown): string { return JSON.stringify(message ?? null).slice(0, 500); } -function LogRow({ entry }: { entry: LogEntry }) { +export const MINIMAL_RUNTIME_LOGS_NOTICE = + 'Log messages, exception messages and URLs are not recorded on this platform.'; + +function exceptionText(exception: { name: string; message: string }): string { + return exception.message ? `${exception.name}: ${exception.message}` : exception.name; +} + +export function LogRow({ entry }: { entry: LogEntry }) { const messages = [ ...(entry.logs ?? []).map((log) => ({ level: log.level ?? 'log', @@ -44,7 +51,7 @@ function LogRow({ entry }: { entry: LogEntry }) { })), ...(entry.exceptions ?? []).map((exception) => ({ level: 'error', - text: `${exception.name}: ${exception.message}`, + text: exceptionText(exception), })), ]; const statusCode = Number.parseInt(entry.status ?? '', 10); @@ -54,12 +61,8 @@ function LogRow({ entry }: { entry: LogEntry }) { {new Date(entry.timestamp).toLocaleTimeString()} - {entry.method && entry.url && ( - <> - {entry.method} - {entry.url} - - )} + {entry.method && {entry.method}} + {entry.method && entry.url && {entry.url}} {entry.status && ( {entry.status} )} @@ -67,7 +70,9 @@ function LogRow({ entry }: { entry: LogEntry }) { {messages.length > 0 && (
- message.text).join('\n')} /> + message.text || message.level).join('\n')} + />
             {messages.map((message, index) => (
@@ -101,6 +106,7 @@ export function DeploymentLogs({ deploymentId }: { deploymentId: string }) {
   const [entries, setEntries] = useState | null>(null);
   const [error, setError] = useState(null);
   const [tailing, setTailing] = useState(false);
+  const [minimal, setMinimal] = useState(false);
 
   const fetchEntries = useCallback(
     async (range: string, limit: number): Promise | null> => {
@@ -112,8 +118,9 @@ export function DeploymentLogs({ deploymentId }: { deploymentId: string }) {
         return null;
       }
       const { data } = (await fate.readView(DeploymentLogsView, ref)) as unknown as {
-        data: { data: Array };
+        data: { data: Array; runtimeLogs?: string };
       };
+      setMinimal(data.runtimeLogs === 'minimal');
       return data.data;
     },
     [deploymentId, fate],
@@ -150,7 +157,16 @@ export function DeploymentLogs({ deploymentId }: { deploymentId: string }) {
           return;
         }
         const fresh = latest.filter((entry) => {
-          const key = `${entry.timestamp}:${entry.url}:${entry.status}`;
+          // Minimal runtime logs leave url empty, so the rest of the row tells requests apart.
+          const key = JSON.stringify([
+            entry.timestamp,
+            entry.method,
+            entry.url,
+            entry.status,
+            entry.outcome,
+            entry.logs,
+            entry.exceptions,
+          ]);
           if (seen.has(key)) {
             return false;
           }
@@ -235,6 +251,11 @@ export function DeploymentLogs({ deploymentId }: { deploymentId: string }) {
           }}
         />
       
+ {minimal && ( +

+ {MINIMAL_RUNTIME_LOGS_NOTICE} +

+ )}
{error ? (

Failed to load logs: {error}

diff --git a/platform/packages/api/src/deploy/shared.ts b/platform/packages/api/src/deploy/shared.ts index 3aa81c47b..fa85cce2c 100644 --- a/platform/packages/api/src/deploy/shared.ts +++ b/platform/packages/api/src/deploy/shared.ts @@ -12,6 +12,17 @@ export const MAX_UPLOAD_URLS_BATCH = 5000; export const DEFAULT_TAIL_SERVICE_NAME = 'void-tail'; +/** + * Observability metadata for a project Worker upload. Minimal runtime logs + * turn Workers Logs off; the tail consumer still records request rows. + */ +export function projectObservability(env: Pick) { + if (env.PLATFORM_RUNTIME_LOGS === 'minimal') { + return { enabled: false }; + } + return { enabled: true, logs: { invocation_logs: true, head_sampling_rate: 1 } }; +} + // Per-module cap for Worker modules that older CLIs send inline as base64 // `workerFiles`. It bounds the memory one decoded module holds in the request; // it is not a Cloudflare limit. Modules uploaded to R2 (`workerInput`) are diff --git a/platform/packages/api/src/deploy/upload-worker.ts b/platform/packages/api/src/deploy/upload-worker.ts index 57901e563..84519c40b 100644 --- a/platform/packages/api/src/deploy/upload-worker.ts +++ b/platform/packages/api/src/deploy/upload-worker.ts @@ -63,6 +63,7 @@ import { DEFAULT_TAIL_SERVICE_NAME, CF_RETRY, DEFAULT_WORKER_MAIN_MODULE, + projectObservability, } from './shared.ts'; import { platformAppUploadUserAgent } from './user-agent.ts'; @@ -487,10 +488,7 @@ export async function uploadDeploymentWorker(input: { bindings: allBindings, ...(durableObjectMigrations ? { migrations: durableObjectMigrations } : {}), ...(completionJwt ? { assets: buildAssetMetadata(completionJwt, assetConfig) } : {}), - observability: { - enabled: true, - logs: { invocation_logs: true, head_sampling_rate: 1 }, - }, + observability: projectObservability(context.env), tail_consumers: [{ service: tailServiceName }], }; if (manifest.durableObjects?.bindings.length) { diff --git a/platform/packages/api/src/env.ts b/platform/packages/api/src/env.ts index eaab3bade..e5fb44298 100644 --- a/platform/packages/api/src/env.ts +++ b/platform/packages/api/src/env.ts @@ -100,6 +100,8 @@ export type ApiEnv = Env & { * Falls back to `void-tail` when unset. */ TAIL_SERVICE_NAME?: string; + /** Set to `minimal` when URLs, log messages and exception messages are not recorded. */ + PLATFORM_RUNTIME_LOGS?: 'minimal'; VOID_PROXY_SERVICE?: string; /** Private readiness checks, attached after the proxy Worker has been provisioned. */ PLATFORM_PROXY?: Fetcher; diff --git a/platform/packages/api/src/observability/log-rows.ts b/platform/packages/api/src/observability/log-rows.ts index f0f89e92d..ecba983c0 100644 --- a/platform/packages/api/src/observability/log-rows.ts +++ b/platform/packages/api/src/observability/log-rows.ts @@ -11,3 +11,10 @@ * selected aliases only. */ export const LOG_ROW_GROUP_BY = 'GROUP BY blob1, blob2, blob3, blob4, blob5, blob6, blob7, double1'; + +/** Log reader body field that tells clients messages and URLs are not recorded. */ +export function runtimeLogsField(env: { PLATFORM_RUNTIME_LOGS?: string }): { + runtimeLogs?: 'minimal'; +} { + return env.PLATFORM_RUNTIME_LOGS === 'minimal' ? { runtimeLogs: 'minimal' } : {}; +} diff --git a/platform/packages/api/src/operator/deployment-operations.ts b/platform/packages/api/src/operator/deployment-operations.ts index 7e59acf7f..8dd901be0 100644 --- a/platform/packages/api/src/operator/deployment-operations.ts +++ b/platform/packages/api/src/operator/deployment-operations.ts @@ -1,7 +1,7 @@ import { and, count, desc, eq, getTableColumns, ilike, or } from 'drizzle-orm'; import { cancelDeployment, getDeployment, getProject, type Db } from '../db.ts'; import { type ApiEnv, isDevBypass } from '../env.ts'; -import { LOG_ROW_GROUP_BY } from '../observability/log-rows.ts'; +import { LOG_ROW_GROUP_BY, runtimeLogsField } from '../observability/log-rows.ts'; import { deployments, projects } from '../schema.ts'; import { OperatorError } from './common.ts'; @@ -249,6 +249,7 @@ export async function deploymentLogs( data: options.order === 'newest-first' ? data : data.reverse(), nextCursor: null, watermark: String(until), + ...runtimeLogsField(env), }; } const dataset = env.LOGS_DATASET ?? 'void_logs'; @@ -303,6 +304,7 @@ export async function deploymentLogs( })), nextCursor, watermark: String(until), + ...runtimeLogsField(env), }; } diff --git a/platform/packages/api/src/provisioning/durable-objects.ts b/platform/packages/api/src/provisioning/durable-objects.ts index 03534d402..2312b8e8a 100644 --- a/platform/packages/api/src/provisioning/durable-objects.ts +++ b/platform/packages/api/src/provisioning/durable-objects.ts @@ -9,6 +9,7 @@ import { } from '../assets.ts'; import { DEFAULT_DISPATCH_NAMESPACE } from '../constants.ts'; import { getProjectResources, type Db } from '../db.ts'; +import { projectObservability } from '../deploy/shared.ts'; import type { ApiEnv } from '../env.ts'; import { generateId } from '../id.ts'; import { encryptPlatformCredential, decryptPlatformCredential } from '../project-secrets.ts'; @@ -588,6 +589,10 @@ async function restoreObject( resource.cf_id, { ...base, + // A release saved before minimal runtime logs still holds Workers Logs on. + ...(env.PLATFORM_RUNTIME_LOGS === 'minimal' + ? { observability: projectObservability(env) } + : {}), ...(assets ? { assets } : {}), main_module: ENTRY, tags: [`void-project:${snapshot.metadata.logProject}`, `void-deployment:${deploymentId}`], diff --git a/platform/packages/api/src/routes/projects.ts b/platform/packages/api/src/routes/projects.ts index c5660b336..041960718 100644 --- a/platform/packages/api/src/routes/projects.ts +++ b/platform/packages/api/src/routes/projects.ts @@ -60,7 +60,7 @@ import { type ApiEnv, isDevBypass } from '../env.ts'; import { validateSubmittedExecutionPlans } from '../migrations/execution-plan.ts'; import { ensureTrackingTable, queryD1, runRemoteMigrations } from '../migrations/remote-runner.ts'; import { getProjectDeploymentWorkerName, getProjectQueueName } from '../naming.ts'; -import { LOG_ROW_GROUP_BY } from '../observability/log-rows.ts'; +import { LOG_ROW_GROUP_BY, runtimeLogsField } from '../observability/log-rows.ts'; import { hasSqlState } from '../pg-errors.ts'; import { resolvePlan, resolvePlanLimits } from '../plans.ts'; import { PROJECT_PERMISSION_MESSAGES } from '../project-access.ts'; @@ -2584,7 +2584,7 @@ const app = new Hono() status: row.statusCode, })); - return context.json({ data }); + return context.json({ data, ...runtimeLogsField(context.env) }); }) // Fetch request-level metering for a project. Unlike /logs (which only has // user-worker tail rows), this reads the dispatch request-metering dataset, diff --git a/platform/packages/api/test/integration/admin-dashboard.test.ts b/platform/packages/api/test/integration/admin-dashboard.test.ts index 8aafb9e8f..09f02cdc9 100644 --- a/platform/packages/api/test/integration/admin-dashboard.test.ts +++ b/platform/packages/api/test/integration/admin-dashboard.test.ts @@ -3459,6 +3459,70 @@ describe('GET /admin/deployments/:id/logs', () => { vi.restoreAllMocks(); }); + test('marks admin log responses only when runtime logs are minimal', async () => { + const admin = await createTestUser(env.db, 'admin-logs3', 'github', undefined, 'free', 'admin'); + const project = await createTestProject(env.db, admin.id, 'logs-minimal'); + const deployment = await createTestDeployment(env.db, project.id, 'live'); + mockCfApi({ + analyticsQuery: () => + Response.json({ + data: [ + { + outcome: 'exception', + logData: '[{"level":"error","timestamp":1,"message":[]}]', + exceptionData: '[{"name":"Error","timestamp":1,"message":""}]', + url: '', + method: 'GET', + statusCode: '500', + ts: Date.now() - 1000, + ingestedAt: new Date(Date.now() - 500).toISOString(), + }, + ], + }), + }); + const minimal = { PLATFORM_RUNTIME_LOGS: 'minimal' } as const; + const fateLogs = (overrides?: Partial) => + req( + '/admin/fate', + admin.adminJwt, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + version: 1, + operations: [ + { + id: 'logs', + kind: 'query', + name: 'deploymentLogs', + args: { id: deployment.id, since: '1h', limit: 50 }, + select: ['id', 'data', 'runtimeLogs'], + }, + ], + }), + }, + overrides, + ); + + const full = await req(`/admin/deployments/${deployment.id}/logs`, admin.adminJwt); + expect(await full.json()).not.toHaveProperty('runtimeLogs'); + const route = await req( + `/admin/deployments/${deployment.id}/logs`, + admin.adminJwt, + undefined, + minimal, + ); + expect(await route.json()).toMatchObject({ runtimeLogs: 'minimal', data: [{ url: '' }] }); + + type FateBody = { results: Array<{ ok: boolean; data?: Record }> }; + const fullFate = (await (await fateLogs()).json()) as FateBody; + expect(fullFate.results[0]?.ok).toBe(true); + expect(fullFate.results[0]?.data?.runtimeLogs ?? null).toBeNull(); + const minimalFate = (await (await fateLogs(minimal)).json()) as FateBody; + expect(minimalFate.results[0]?.ok).toBe(true); + expect(minimalFate.results[0]?.data?.runtimeLogs).toBe('minimal'); + }); + test('returns 404 for unknown deployment', async () => { const admin = await createTestUser(env.db, 'admin-logs2', 'github', undefined, 'free', 'admin'); const res = await req('/admin/deployments/dpl_unknown/logs', admin.adminJwt); diff --git a/platform/packages/api/test/integration/api-deploy.test.ts b/platform/packages/api/test/integration/api-deploy.test.ts index b9c9f5811..046a8689a 100644 --- a/platform/packages/api/test/integration/api-deploy.test.ts +++ b/platform/packages/api/test/integration/api-deploy.test.ts @@ -6826,6 +6826,33 @@ describe('POST /projects/:id/deploy/preflight', () => { }); }); + it('turns Workers Logs off and keeps the tail consumer with minimal runtime logs', async () => { + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id, 'obs-minimal-proj'); + + let capturedMetadata: Record | undefined; + mockCfApi({ + uploadWorker: async (_url: string, init?: RequestInit) => { + const body = init?.body as FormData; + const metadataBlob = body.get('metadata') as Blob; + capturedMetadata = JSON.parse(await metadataBlob.text()); + return new Response('ok', { status: 200 }); + }, + }); + + env.PLATFORM_RUNTIME_LOGS = 'minimal'; + try { + const res = await app.fetch(await buildDeployRequest(env, project.id, user.jwt), env); + expect(res.status).toBe(200); + await res.text(); + } finally { + delete env.PLATFORM_RUNTIME_LOGS; + } + expect(capturedMetadata).toBeDefined(); + expect(capturedMetadata!.observability).toEqual({ enabled: false }); + expect(capturedMetadata!.tail_consumers).toEqual([{ service: 'void-tail' }]); + }); + it('includes explicit tail_consumers metadata in worker upload', async () => { const user = await createTestUser(env.db); const project = await createTestProject(env.db, user.id, 'tail-proj'); diff --git a/platform/packages/api/test/integration/api-projects.test.ts b/platform/packages/api/test/integration/api-projects.test.ts index 09e69f1c3..3ba1162aa 100644 --- a/platform/packages/api/test/integration/api-projects.test.ts +++ b/platform/packages/api/test/integration/api-projects.test.ts @@ -3427,6 +3427,50 @@ describe('GET /projects/:id/logs', () => { }); }); + it('marks the response only when runtime logs are minimal', async () => { + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id, 'minimal-logs'); + mockCfApi({ + analyticsQuery: () => + Response.json({ + data: [ + { + outcome: 'ok', + logData: JSON.stringify([{ level: 'error', message: [], timestamp: 1000 }]), + exceptionData: JSON.stringify([{ name: 'TypeError', message: '', timestamp: 1000 }]), + url: '', + method: 'GET', + statusCode: '500', + ts: Date.now() - 5000, + }, + ], + }), + }); + const fetchLogs = (extraEnv: Record) => + app.fetch( + new Request(`http://localhost/projects/${project.id}/logs`, { + headers: { Authorization: `Bearer ${user.jwt}` }, + }), + { ...env, ...extraEnv }, + ); + + const full = (await (await fetchLogs({})).json()) as Record; + expect(full).not.toHaveProperty('runtimeLogs'); + + const res = await fetchLogs({ PLATFORM_RUNTIME_LOGS: 'minimal' }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + runtimeLogs: 'minimal', + data: [ + expect.objectContaining({ + url: '', + logs: [{ level: 'error', message: [], timestamp: 1000 }], + exceptions: [{ name: 'TypeError', message: '', timestamp: 1000 }], + }), + ], + }); + }); + it('returns analytics error details when the log query fails', async () => { const user = await createTestUser(env.db); const project = await createTestProject(env.db, user.id, 'broken-logs'); diff --git a/platform/packages/api/test/integration/helpers.ts b/platform/packages/api/test/integration/helpers.ts index f3f164e92..0101b632e 100644 --- a/platform/packages/api/test/integration/helpers.ts +++ b/platform/packages/api/test/integration/helpers.ts @@ -90,6 +90,7 @@ export type TestEnv = { EMAIL_SENDER_DOMAIN?: string; REQUESTS_DATASET?: string; TAIL_SERVICE_NAME?: string; + PLATFORM_RUNTIME_LOGS?: 'minimal'; PLATFORM_CAPABILITIES?: string; PLATFORM_DISPLAY_NAME?: string; PLATFORM_BOOTSTRAP_GITHUB_ID?: string; diff --git a/platform/packages/api/test/integration/managed-durable-objects.test.ts b/platform/packages/api/test/integration/managed-durable-objects.test.ts index 37361956d..b3411b226 100644 --- a/platform/packages/api/test/integration/managed-durable-objects.test.ts +++ b/platform/packages/api/test/integration/managed-durable-objects.test.ts @@ -387,6 +387,29 @@ describe('managed native Durable Objects', () => { }); }); + it('keeps Workers Logs off when minimal runtime logs replay an older release', async () => { + const first = await deploy(); + expect(first.id).toBeDefined(); + const worker = await durableObjectWorkerName(project.id, binding.className); + expect(writes.filter((write) => write.name === worker).at(-1)!.metadata.observability).toEqual({ + enabled: true, + logs: { invocation_logs: true, head_sampling_rate: 1 }, + }); + env.PLATFORM_RUNTIME_LOGS = 'minimal'; + try { + const second = await deploy('two'); + expect(second.id).toBeDefined(); + // The first release was saved with Workers Logs on. + expect((await rollback(first.id!)).status).toBe(200); + expect(currentRelease(worker)).toBe(first.id); + expect( + writes.filter((write) => write.name === worker).at(-1)!.metadata.observability, + ).toEqual({ enabled: false }); + } finally { + delete env.PLATFORM_RUNTIME_LOGS; + } + }); + it('restores committed code after a provider write succeeds but loses its acknowledgement', async () => { const first = await deploy(); failPublication = true; diff --git a/platform/packages/api/test/integration/operator-operations.test.ts b/platform/packages/api/test/integration/operator-operations.test.ts index de7c0a0b0..23f4a6fd7 100644 --- a/platform/packages/api/test/integration/operator-operations.test.ts +++ b/platform/packages/api/test/integration/operator-operations.test.ts @@ -381,6 +381,34 @@ describe('operator deployment operations', () => { expect((await req(`/deployments/${deployment.id}/logs?limit=-1`)).status).toBe(400); expect(fetch).toHaveBeenCalledTimes(2); }); + test('log pages carry runtimeLogs only when runtime logs are minimal', async () => { + const project = await createTestProject(env.db, ownerId); + const deployment = await createTestDeployment(env.db, project.id, 'live'); + const row = { + ts: Date.now() - 1000, + ingestedAt: new Date(Date.now() - 500).toISOString(), + outcome: 'ok', + logData: '[{"level":"log","timestamp":1,"message":[]}]', + exceptionData: '[]', + url: '', + method: 'GET', + statusCode: '200', + }; + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => Response.json({ data: [row] })); + const full = (await (await req(`/deployments/${deployment.id}/logs`)).json()) as Record< + string, + unknown + >; + expect(full).not.toHaveProperty('runtimeLogs'); + const minimal = await req(`/deployments/${deployment.id}/logs`, undefined, { + PLATFORM_RUNTIME_LOGS: 'minimal', + }); + expect(minimal.status).toBe(200); + expect(await minimal.json()).toMatchObject({ + runtimeLogs: 'minimal', + data: [{ url: '', logs: [{ level: 'log', timestamp: 1, message: [] }] }], + }); + }); test('follow windows use ingestion time with bounded overlap after the initial history', async () => { const project = await createTestProject(env.db, ownerId); const deployment = await createTestDeployment(env.db, project.id, 'live'); diff --git a/platform/packages/api/test/unit/admin-deployment-logs.test.ts b/platform/packages/api/test/unit/admin-deployment-logs.test.ts new file mode 100644 index 000000000..83adf28f5 --- /dev/null +++ b/platform/packages/api/test/unit/admin-deployment-logs.test.ts @@ -0,0 +1,49 @@ +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { expect, it, vi } from 'vite-plus/test'; +import { LogRow } from '../../src/admin/react/views/DeploymentLogs.tsx'; + +vi.mock('react-fate', () => ({ useFateClient: () => ({}) })); +vi.mock('../../src/admin/fate/views.ts', () => ({ + DeploymentLogsView: {}, + deploymentLogsRequest: () => ({}), +})); + +it('renders a minimal row without a URL or empty exception text', () => { + const markup = renderToStaticMarkup( + createElement(LogRow, { + entry: { + timestamp: 0, + outcome: 'exception', + method: 'GET', + url: '', + status: '500', + logs: [{ level: 'error', message: [] }], + exceptions: [{ name: 'TypeError', message: '' }], + }, + }), + ); + expect(markup).toContain('GET'); + expect(markup).toContain('>error<'); + expect(markup).toContain('TypeError'); + expect(markup).not.toContain('TypeError:'); +}); + +it('keeps the full row layout when messages and URLs are recorded', () => { + const markup = renderToStaticMarkup( + createElement(LogRow, { + entry: { + timestamp: 0, + outcome: 'exception', + method: 'GET', + url: '/api/users', + status: '500', + logs: [{ level: 'error', message: ['boom'] }], + exceptions: [{ name: 'TypeError', message: 'bad input' }], + }, + }), + ); + expect(markup).toContain('/api/users'); + expect(markup).toContain('boom'); + expect(markup).toContain('TypeError: bad input'); +}); diff --git a/platform/packages/api/test/unit/platform-deployment-manifest.test.ts b/platform/packages/api/test/unit/platform-deployment-manifest.test.ts index bafe75f35..e3a692d28 100644 --- a/platform/packages/api/test/unit/platform-deployment-manifest.test.ts +++ b/platform/packages/api/test/unit/platform-deployment-manifest.test.ts @@ -74,6 +74,22 @@ describe('platform deployment manifest', () => { expect(parsePlatformDeploymentManifest(JSON.parse(JSON.stringify(manifest)))).toEqual(manifest); }); + it('round-trips minimal runtime logs at schema version 5', () => { + const input = { + application: { baseDomain: 'apps.example.com', zoneName: 'example.com' }, + displayName: 'My Platform', + installationSlug: 'my-platform', + }; + const manifest = createPlatformDeploymentManifest({ ...input, runtimeLogs: 'minimal' }); + expect(manifest).toMatchObject({ runtimeLogs: 'minimal', schemaVersion: 5 }); + expect(parsePlatformDeploymentManifest(JSON.parse(JSON.stringify(manifest)))).toEqual(manifest); + const full = createPlatformDeploymentManifest(input); + expect(full.schemaVersion).toBe(1); + expect(() => parsePlatformDeploymentManifest({ ...full, runtimeLogs: 'minimal' })).toThrow( + 'schema version 5', + ); + }); + it('keeps paid container capabilities opt-in', () => { const manifest = createPlatformDeploymentManifest({ application: { baseDomain: 'example.com', zoneName: 'example.com' }, diff --git a/platform/packages/dispatch/src/env.ts b/platform/packages/dispatch/src/env.ts index 269c75a88..9b4b3f5bc 100644 --- a/platform/packages/dispatch/src/env.ts +++ b/platform/packages/dispatch/src/env.ts @@ -15,5 +15,7 @@ export type Env = Readonly<{ DISPATCH_TIMEOUT_MS?: string; PLATFORM_HEALTH_PROBE_TOKEN?: string; PLATFORM_INSTALLATION_ID?: string; + // Set by `--runtime-logs minimal`: keep URLs out of `void_logs` rows. + PLATFORM_RUNTIME_LOGS?: 'minimal'; PLATFORM_TRAFFIC_STATE?: string; }>; diff --git a/platform/packages/dispatch/src/metering.ts b/platform/packages/dispatch/src/metering.ts index 5cf8dcc95..f1986989e 100644 --- a/platform/packages/dispatch/src/metering.ts +++ b/platform/packages/dispatch/src/metering.ts @@ -124,6 +124,9 @@ export function truncateUtf8(value: string, maxBytes: number): string { * writes, so the existing read path needs no change. `outcome` is `canceled` to * match the tail row for the same invocation rather than invent a status. * + * With `PLATFORM_RUNTIME_LOGS=minimal` the row carries no URL: blob4 is empty + * and the message names only the method, budget and status. + * * Best effort by construction: every call site is already returning a 503, and * an uncaught throw there would surface to the client as a 500. A dropped log * line is strictly better than that, so all failures are swallowed. @@ -146,16 +149,21 @@ export function recordPlatformLog( return; } - let pathname = data.url; - try { - pathname = new URL(data.url).pathname; - } catch { - // Keep the raw string — a message is worth more than a thrown URL parse. + const minimal = env.PLATFORM_RUNTIME_LOGS === 'minimal'; + let target = ''; + if (!minimal) { + let pathname = data.url; + try { + pathname = new URL(data.url).pathname; + } catch { + // Keep the raw string — a message is worth more than a thrown URL parse. + } + target = ` ${truncateUtf8(pathname, MAX_LOG_PATH_BYTES)}`; } const message = `Void platform: your worker did not respond within ${data.timeoutMs} ms. ` + - `${data.method} ${truncateUtf8(pathname, MAX_LOG_PATH_BYTES)} was cut short and ` + + `${data.method}${target} was cut short and ` + `returned ${data.status}. The operation may still have side effects; ` + `check its outcome before retrying.`; @@ -170,7 +178,7 @@ export function recordPlatformLog( 'canceled', JSON.stringify([{ level: 'error', message: [message], timestamp: Date.now() }]), '[]', - truncateUtf8(data.url, MAX_LOG_URL_BYTES), + minimal ? '' : truncateUtf8(data.url, MAX_LOG_URL_BYTES), data.method, String(data.status), parsed.deployId, diff --git a/platform/packages/dispatch/test/unit/platform-log.test.ts b/platform/packages/dispatch/test/unit/platform-log.test.ts index 9b49e79d1..e8c03bed1 100644 --- a/platform/packages/dispatch/test/unit/platform-log.test.ts +++ b/platform/packages/dispatch/test/unit/platform-log.test.ts @@ -207,6 +207,60 @@ describe('recordPlatformLog', () => { }); }); +describe('recordPlatformLog with minimal runtime logs', () => { + function writeMinimal(): DataPoint { + const writes: Array = []; + const env = { + LOGS: { writeDataPoint: (d: unknown) => writes.push(d) }, + PLATFORM_RUNTIME_LOGS: 'minimal', + } as unknown as Env; + recordPlatformLog(env, { + workerName: 'user-my-app-dpl_abc123', + method: 'GET', + url: 'https://my-app.void.app/private/path?token=secret', + status: 503, + timeoutMs: 10000, + }); + expect(writes).toHaveLength(1); + return writes[0] as DataPoint; + } + + it('keeps the request path and query out of every blob', () => { + const dp = writeMinimal(); + expect(dp.blobs[3]).toBe(''); + for (const blob of dp.blobs) { + expect(blob).not.toContain('/private'); + expect(blob).not.toContain('token'); + expect(blob).not.toContain('my-app.void.app'); + } + }); + + it('still explains the timeout with the method, budget and status', () => { + const dp = writeMinimal(); + const logs = JSON.parse(dp.blobs[1]) as Array<{ level: string; message: Array }>; + expect(logs).toHaveLength(1); + expect(logs[0].level).toBe('error'); + const text = logs[0].message.join(' '); + expect(text).toContain('10000'); + expect(text).toContain('GET was cut short'); + expect(text).toContain('503'); + }); + + it('keeps the row shape and the --level error match', () => { + const dp = writeMinimal(); + const [outcome, logData, exceptionData, , method, statusCode, deployId] = dp.blobs; + expect(dp.indexes).toEqual(['user-my-app']); + expect(dp.blobs).toHaveLength(7); + expect(outcome).toBe('canceled'); + expect(JSON.parse(exceptionData)).toEqual([]); + expect(method).toBe('GET'); + expect(statusCode).toBe('503'); + expect(deployId).toBe('dpl_abc123'); + expect(dp.doubles[1]).toBe(503); + expect(logData.includes('"level":"error"')).toBe(true); + }); +}); + // Analytics Engine caps the SUM of a data point's blobs at 16 KB (raised from // 5 KB in June 2025 — `void_logs` holds rows of 13,695 bytes today, which // proves the higher cap is live). There is NO per-blob limit. A data point over diff --git a/platform/packages/tail/README.md b/platform/packages/tail/README.md index 82b83bddb..90b57cf5b 100644 --- a/platform/packages/tail/README.md +++ b/platform/packages/tail/README.md @@ -31,11 +31,16 @@ Versioned deploys use script names like `user-my-app-dpl_abc123`. Legacy unversi | `doubles[0]` | event timestamp | | `doubles[1]` | response status as number | +With `PLATFORM_RUNTIME_LOGS=minimal` (set by `--runtime-logs minimal`), +`blobs[3]` is `""`, each log is `{ level, timestamp, message: [] }` and each +exception is `{ name, timestamp, message: "" }`. Other slots are unchanged. + ## Source Layout ```text -src/index.ts # tail() handler + parseScriptName() +src/index.ts # tail() handler, parseScriptName(), buildLogDataPoint() test/parse-script-name.test.ts +test/log-data-point.test.ts ``` ## Configuration and Deployment diff --git a/platform/packages/tail/src/index.ts b/platform/packages/tail/src/index.ts index d92ce0687..f641b57e4 100644 --- a/platform/packages/tail/src/index.ts +++ b/platform/packages/tail/src/index.ts @@ -1,6 +1,8 @@ type Env = Readonly<{ ANALYTICS: AnalyticsEngineDataset; PLATFORM_DISPATCH_WORKER_NAME?: string; + // Set by `--runtime-logs minimal`: keep URLs and message text out of rows. + PLATFORM_RUNTIME_LOGS?: 'minimal'; }>; function isFetchEvent(event: TraceItem['event']): event is TraceItemFetchEventInfo { @@ -50,6 +52,46 @@ export function parseScriptName( return null; } +/** + * Build the `void_logs` row for one trace event. + * + * Minimal mode drops the request URL and all message text. It keeps each log + * level and exception name, so `--level` filters still match. + */ +export function buildLogDataPoint( + event: TraceItem, + parsed: { projectKey: string; deployId: string }, + runtimeLogs?: 'minimal', +): AnalyticsEngineDataPoint { + const fetchEvent = isFetchEvent(event.event) ? event.event : null; + const minimal = runtimeLogs === 'minimal'; + return { + indexes: [parsed.projectKey], + blobs: [ + event.outcome, + JSON.stringify(minimal ? minimalLogs(event.logs) : event.logs), + JSON.stringify(minimal ? minimalExceptions(event.exceptions) : event.exceptions), + minimal ? '' : (fetchEvent?.request.url ?? ''), + fetchEvent?.request.method ?? '', + String(fetchEvent?.response?.status ?? ''), + parsed.deployId, + ], + doubles: [event.eventTimestamp ?? Date.now(), fetchEvent?.response?.status ?? 0], + }; +} + +function minimalLogs(logs: ReadonlyArray | undefined) { + return (logs ?? []).map((log) => ({ level: log.level, timestamp: log.timestamp, message: [] })); +} + +function minimalExceptions(exceptions: ReadonlyArray | undefined) { + return (exceptions ?? []).map((exception) => ({ + name: exception.name, + timestamp: exception.timestamp, + message: '', + })); +} + export default { async tail(events: ReadonlyArray, env: Env) { for (const event of events) { @@ -67,21 +109,7 @@ export default { continue; } - const fetchEvent = isFetchEvent(event.event) ? event.event : null; - - env.ANALYTICS.writeDataPoint({ - indexes: [parsed.projectKey], - blobs: [ - event.outcome, - JSON.stringify(event.logs), - JSON.stringify(event.exceptions), - fetchEvent?.request.url ?? '', - fetchEvent?.request.method ?? '', - String(fetchEvent?.response?.status ?? ''), - parsed.deployId, - ], - doubles: [event.eventTimestamp ?? Date.now(), fetchEvent?.response?.status ?? 0], - }); + env.ANALYTICS.writeDataPoint(buildLogDataPoint(event, parsed, env.PLATFORM_RUNTIME_LOGS)); } }, } satisfies ExportedHandler; diff --git a/platform/packages/tail/test/log-data-point.test.ts b/platform/packages/tail/test/log-data-point.test.ts new file mode 100644 index 000000000..54c14498e --- /dev/null +++ b/platform/packages/tail/test/log-data-point.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vite-plus/test'; +import worker, { buildLogDataPoint } from '../src/index.ts'; + +const parsed = { projectKey: 'user-my-app', deployId: 'dpl_abc123' }; + +function fetchTrace(): TraceItem { + return { + scriptName: 'user-my-app-dpl_abc123', + outcome: 'exception', + eventTimestamp: 1_700_000_000_000, + event: { + request: { + url: 'https://my-app.void.app/account?token=secret', + method: 'POST', + headers: {}, + }, + response: { status: 500 }, + }, + logs: [ + { level: 'log', timestamp: 1, message: ['user email', 'a@b.c'] }, + { level: 'error', timestamp: 2, message: ['failed for /account?token=secret'] }, + ], + exceptions: [{ name: 'TypeError', timestamp: 3, message: 'bad token secret', stack: 'at x' }], + diagnosticsChannelEvents: [], + scriptTags: [], + } as unknown as TraceItem; +} + +describe('buildLogDataPoint', () => { + it('writes the full row unchanged by default', () => { + const event = fetchTrace(); + expect(buildLogDataPoint(event, parsed)).toEqual({ + indexes: ['user-my-app'], + blobs: [ + 'exception', + JSON.stringify(event.logs), + JSON.stringify(event.exceptions), + 'https://my-app.void.app/account?token=secret', + 'POST', + '500', + 'dpl_abc123', + ], + doubles: [1_700_000_000_000, 500], + }); + }); + + it('drops the URL and all message text in minimal mode', () => { + const dp = buildLogDataPoint(fetchTrace(), parsed, 'minimal'); + const blobs = dp.blobs as Array; + + expect(dp.indexes).toEqual(['user-my-app']); + expect(blobs[0]).toBe('exception'); + expect(blobs[3]).toBe(''); + expect(blobs.slice(4)).toEqual(['POST', '500', 'dpl_abc123']); + expect(dp.doubles).toEqual([1_700_000_000_000, 500]); + expect(JSON.parse(blobs[1])).toEqual([ + { level: 'log', timestamp: 1, message: [] }, + { level: 'error', timestamp: 2, message: [] }, + ]); + expect(JSON.parse(blobs[2])).toEqual([{ name: 'TypeError', timestamp: 3, message: '' }]); + for (const blob of blobs) { + expect(blob).not.toContain('secret'); + expect(blob).not.toContain('/account'); + expect(blob).not.toContain('a@b.c'); + } + }); + + it('keeps the level text the --level error filter looks for', () => { + const dp = buildLogDataPoint(fetchTrace(), parsed, 'minimal'); + expect((dp.blobs as Array)[1]).toContain('"level":"error"'); + }); + + it('writes valid empty arrays when the event has no logs or exceptions', () => { + const event = { + ...fetchTrace(), + event: null, + logs: undefined, + exceptions: undefined, + } as unknown as TraceItem; + const blobs = buildLogDataPoint(event, parsed, 'minimal').blobs as Array; + + expect(JSON.parse(blobs[1])).toEqual([]); + expect(JSON.parse(blobs[2])).toEqual([]); + expect(blobs[3]).toBe(''); + }); +}); + +describe('tail handler', () => { + async function run(env: Record) { + const writes: Array = []; + await worker.tail([fetchTrace()], { + ANALYTICS: { writeDataPoint: (dp: AnalyticsEngineDataPoint) => writes.push(dp) }, + ...env, + } as never); + return writes; + } + + it('writes the full row when the setting is absent', async () => { + const [dp] = await run({}); + expect((dp.blobs as Array)[3]).toBe('https://my-app.void.app/account?token=secret'); + }); + + it('writes the minimal row when PLATFORM_RUNTIME_LOGS is minimal', async () => { + const [dp] = await run({ PLATFORM_RUNTIME_LOGS: 'minimal' }); + expect(dp).toEqual(buildLogDataPoint(fetchTrace(), parsed, 'minimal')); + expect(JSON.stringify(dp)).not.toContain('secret'); + }); +}); From 32398d37d5c2a48c2fc3eb19c97ad95f3ec8777f Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 3 Nov 2026 13:56:30 +0800 Subject: [PATCH 3/4] fix(platform): keep the current runtime logs setting on restore and tell minimal rows apart A Durable Object restore now always applies the installation's current Workers Logs setting, so a release saved in minimal mode cannot turn logs off after a switch back to full. Minimal log rows store no URL, so two requests in the same millisecond could look identical and be merged by the readers. Minimal rows now keep the request Ray ID in blob8, and the readers group by it. Co-Authored-By: Claude Opus 5.5 --- .../api/src/observability/log-rows.ts | 6 +++-- .../api/src/provisioning/durable-objects.ts | 7 +++--- .../test/integration/admin-dashboard.test.ts | 2 +- .../api/test/integration/api-projects.test.ts | 4 +++- .../managed-durable-objects.test.ts | 23 +++++++++++++++++++ .../integration/operator-operations.test.ts | 6 +++-- platform/packages/tail/README.md | 4 +++- platform/packages/tail/src/index.ts | 4 +++- .../packages/tail/test/log-data-point.test.ts | 16 +++++++++++-- 9 files changed, 58 insertions(+), 14 deletions(-) diff --git a/platform/packages/api/src/observability/log-rows.ts b/platform/packages/api/src/observability/log-rows.ts index ecba983c0..999748eb9 100644 --- a/platform/packages/api/src/observability/log-rows.ts +++ b/platform/packages/api/src/observability/log-rows.ts @@ -6,11 +6,13 @@ * Worker's own consumer. Cloudflare delivers each copy in its own tail * invocation, so the tail Worker cannot drop one, and both copies are stored as * identical rows. Grouping by every stored column returns one row per - * invocation. A grouped query must select `min(timestamp)` (aliased, since an + * invocation. Minimal runtime logs store no URL, so `blob8` keeps the Ray ID + * that tells requests apart. A grouped query must select `min(timestamp)` (aliased, since an * alias named `timestamp` would shadow the `WHERE` filter) and order by * selected aliases only. */ -export const LOG_ROW_GROUP_BY = 'GROUP BY blob1, blob2, blob3, blob4, blob5, blob6, blob7, double1'; +export const LOG_ROW_GROUP_BY = + 'GROUP BY blob1, blob2, blob3, blob4, blob5, blob6, blob7, blob8, double1'; /** Log reader body field that tells clients messages and URLs are not recorded. */ export function runtimeLogsField(env: { PLATFORM_RUNTIME_LOGS?: string }): { diff --git a/platform/packages/api/src/provisioning/durable-objects.ts b/platform/packages/api/src/provisioning/durable-objects.ts index 2312b8e8a..ef802ce62 100644 --- a/platform/packages/api/src/provisioning/durable-objects.ts +++ b/platform/packages/api/src/provisioning/durable-objects.ts @@ -589,10 +589,9 @@ async function restoreObject( resource.cf_id, { ...base, - // A release saved before minimal runtime logs still holds Workers Logs on. - ...(env.PLATFORM_RUNTIME_LOGS === 'minimal' - ? { observability: projectObservability(env) } - : {}), + // A saved release keeps the Workers Logs setting it was deployed with; + // the installation's current setting wins. + observability: projectObservability(env), ...(assets ? { assets } : {}), main_module: ENTRY, tags: [`void-project:${snapshot.metadata.logProject}`, `void-deployment:${deploymentId}`], diff --git a/platform/packages/api/test/integration/admin-dashboard.test.ts b/platform/packages/api/test/integration/admin-dashboard.test.ts index 09f02cdc9..5945a820d 100644 --- a/platform/packages/api/test/integration/admin-dashboard.test.ts +++ b/platform/packages/api/test/integration/admin-dashboard.test.ts @@ -3402,7 +3402,7 @@ describe('GET /admin/deployments/:id/logs', () => { ); expect(query).not.toMatch(/AND double1 >= \d+/); expect(query).toContain( - 'GROUP BY blob1, blob2, blob3, blob4, blob5, blob6, blob7, double1', + 'GROUP BY blob1, blob2, blob3, blob4, blob5, blob6, blob7, blob8, double1', ); expect(query).toContain('ORDER BY ingestedAt DESC, ts DESC'); return Response.json({ diff --git a/platform/packages/api/test/integration/api-projects.test.ts b/platform/packages/api/test/integration/api-projects.test.ts index 3ba1162aa..6eaaf7bdb 100644 --- a/platform/packages/api/test/integration/api-projects.test.ts +++ b/platform/packages/api/test/integration/api-projects.test.ts @@ -3403,7 +3403,9 @@ describe('GET /projects/:id/logs', () => { expect(body).toContain('user-my-app'); // The dispatch Worker's tail consumer and the user Worker's own consumer // each deliver a dispatched request, so identical rows are collapsed. - expect(body).toContain('GROUP BY blob1, blob2, blob3, blob4, blob5, blob6, blob7, double1'); + expect(body).toContain( + 'GROUP BY blob1, blob2, blob3, blob4, blob5, blob6, blob7, blob8, double1', + ); expect(body).toContain('min(timestamp) as ingestedAt'); expect(body).toContain('ORDER BY ingestedAt DESC'); return Response.json(mockData); diff --git a/platform/packages/api/test/integration/managed-durable-objects.test.ts b/platform/packages/api/test/integration/managed-durable-objects.test.ts index b3411b226..84ca84628 100644 --- a/platform/packages/api/test/integration/managed-durable-objects.test.ts +++ b/platform/packages/api/test/integration/managed-durable-objects.test.ts @@ -410,6 +410,29 @@ describe('managed native Durable Objects', () => { } }); + it('turns Workers Logs back on when full runtime logs replay a minimal release', async () => { + await deploy(); + const worker = await durableObjectWorkerName(project.id, binding.className); + env.PLATFORM_RUNTIME_LOGS = 'minimal'; + let minimal: Awaited>; + try { + minimal = await deploy('two'); + expect( + writes.filter((write) => write.name === worker).at(-1)!.metadata.observability, + ).toEqual({ enabled: false }); + } finally { + delete env.PLATFORM_RUNTIME_LOGS; + } + await deploy('three'); + // The second release was saved with Workers Logs off. + expect((await rollback(minimal.id!)).status).toBe(200); + expect(currentRelease(worker)).toBe(minimal.id); + expect(writes.filter((write) => write.name === worker).at(-1)!.metadata.observability).toEqual({ + enabled: true, + logs: { invocation_logs: true, head_sampling_rate: 1 }, + }); + }); + it('restores committed code after a provider write succeeds but loses its acknowledgement', async () => { const first = await deploy(); failPublication = true; diff --git a/platform/packages/api/test/integration/operator-operations.test.ts b/platform/packages/api/test/integration/operator-operations.test.ts index 23f4a6fd7..e6b39ae84 100644 --- a/platform/packages/api/test/integration/operator-operations.test.ts +++ b/platform/packages/api/test/integration/operator-operations.test.ts @@ -372,7 +372,7 @@ describe('operator deployment operations', () => { expect(last).toMatchObject({ nextCursor: null, watermark: page.watermark }); expect(last.data).toHaveLength(1); expect(fetch.mock.calls[0][1]?.body).toContain( - 'GROUP BY blob1, blob2, blob3, blob4, blob5, blob6, blob7, double1', + 'GROUP BY blob1, blob2, blob3, blob4, blob5, blob6, blob7, blob8, double1', ); expect(fetch.mock.calls[0][1]?.body).toContain('ORDER BY ts ASC, ingestedAt ASC'); expect(fetch.mock.calls[0][1]?.body).toMatch(/double1 >= \d+ AND double1 < \d+/); @@ -447,7 +447,9 @@ describe('operator deployment operations', () => { expect(query).toContain( "timestamp >= toDateTime('1970-01-01 00:03:00') AND timestamp < toDateTime('1970-01-01 00:10:00')", ); - expect(query).toContain('GROUP BY blob1, blob2, blob3, blob4, blob5, blob6, blob7, double1'); + expect(query).toContain( + 'GROUP BY blob1, blob2, blob3, blob4, blob5, blob6, blob7, blob8, double1', + ); expect(query).toContain('ORDER BY ingestedAt ASC, ts ASC'); expect(query).not.toContain('double1 >= 480000'); expect((await req(`/deployments/${deployment.id}/logs?after=garbage`)).status).toBe(400); diff --git a/platform/packages/tail/README.md b/platform/packages/tail/README.md index 90b57cf5b..443fff745 100644 --- a/platform/packages/tail/README.md +++ b/platform/packages/tail/README.md @@ -33,7 +33,9 @@ Versioned deploys use script names like `user-my-app-dpl_abc123`. Legacy unversi With `PLATFORM_RUNTIME_LOGS=minimal` (set by `--runtime-logs minimal`), `blobs[3]` is `""`, each log is `{ level, timestamp, message: [] }` and each -exception is `{ name, timestamp, message: "" }`. Other slots are unchanged. +exception is `{ name, timestamp, message: "" }`, and `blobs[7]` holds the +request Ray ID (or `""`) so distinct requests stay distinct rows. Other slots +are unchanged. ## Source Layout diff --git a/platform/packages/tail/src/index.ts b/platform/packages/tail/src/index.ts index f641b57e4..f6f87876d 100644 --- a/platform/packages/tail/src/index.ts +++ b/platform/packages/tail/src/index.ts @@ -56,7 +56,8 @@ export function parseScriptName( * Build the `void_logs` row for one trace event. * * Minimal mode drops the request URL and all message text. It keeps each log - * level and exception name, so `--level` filters still match. + * level and exception name, so `--level` filters still match, and adds the + * Ray ID so readers do not merge two requests that now look the same. */ export function buildLogDataPoint( event: TraceItem, @@ -75,6 +76,7 @@ export function buildLogDataPoint( fetchEvent?.request.method ?? '', String(fetchEvent?.response?.status ?? ''), parsed.deployId, + ...(minimal ? [fetchEvent?.request.headers['cf-ray'] ?? ''] : []), ], doubles: [event.eventTimestamp ?? Date.now(), fetchEvent?.response?.status ?? 0], }; diff --git a/platform/packages/tail/test/log-data-point.test.ts b/platform/packages/tail/test/log-data-point.test.ts index 54c14498e..415f6957f 100644 --- a/platform/packages/tail/test/log-data-point.test.ts +++ b/platform/packages/tail/test/log-data-point.test.ts @@ -12,7 +12,7 @@ function fetchTrace(): TraceItem { request: { url: 'https://my-app.void.app/account?token=secret', method: 'POST', - headers: {}, + headers: { 'cf-ray': '8f1d2c3b4a5e6f70-SJC' }, }, response: { status: 500 }, }, @@ -51,7 +51,7 @@ describe('buildLogDataPoint', () => { expect(dp.indexes).toEqual(['user-my-app']); expect(blobs[0]).toBe('exception'); expect(blobs[3]).toBe(''); - expect(blobs.slice(4)).toEqual(['POST', '500', 'dpl_abc123']); + expect(blobs.slice(4)).toEqual(['POST', '500', 'dpl_abc123', '8f1d2c3b4a5e6f70-SJC']); expect(dp.doubles).toEqual([1_700_000_000_000, 500]); expect(JSON.parse(blobs[1])).toEqual([ { level: 'log', timestamp: 1, message: [] }, @@ -82,6 +82,18 @@ describe('buildLogDataPoint', () => { expect(JSON.parse(blobs[1])).toEqual([]); expect(JSON.parse(blobs[2])).toEqual([]); expect(blobs[3]).toBe(''); + expect(blobs[7]).toBe(''); + }); + + it('keeps two requests in the same millisecond apart in minimal mode', () => { + const other = fetchTrace() as unknown as { + event: { request: { url: string; headers: Record } }; + }; + other.event.request.url = 'https://my-app.void.app/other'; + other.event.request.headers = { 'cf-ray': '8f1d2c3b4a5e6f71-SJC' }; + expect(buildLogDataPoint(other as unknown as TraceItem, parsed, 'minimal')).not.toEqual( + buildLogDataPoint(fetchTrace(), parsed, 'minimal'), + ); }); }); From 14fafb0cefcd86b74af3e25bf110088da7d311fb Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 3 Nov 2026 14:00:49 +0800 Subject: [PATCH 4/4] fix(admin): keep identical minimal rows in the deployment live tail The live tail dropped any row it had already shown by shape. With minimal runtime logs, two requests in the same millisecond can share a shape, so the tail now counts each shape and shows only rows beyond the count it has already seen. Co-Authored-By: Claude Opus 5.5 --- .../src/admin/react/views/DeploymentLogs.tsx | 50 ++++++++++++------- .../test/unit/admin-deployment-logs.test.ts | 12 ++++- 2 files changed, 43 insertions(+), 19 deletions(-) diff --git a/platform/packages/api/src/admin/react/views/DeploymentLogs.tsx b/platform/packages/api/src/admin/react/views/DeploymentLogs.tsx index 657c9c6e1..ce630578c 100644 --- a/platform/packages/api/src/admin/react/views/DeploymentLogs.tsx +++ b/platform/packages/api/src/admin/react/views/DeploymentLogs.tsx @@ -36,6 +36,36 @@ function formatMessage(message: unknown): string { return JSON.stringify(message ?? null).slice(0, 500); } +/** + * Entries in a tail poll that earlier polls did not show. `seen` counts each + * row shape: with minimal runtime logs two requests in the same millisecond + * can look the same, and the server still returns one row for each. + */ +export function takeFreshTailEntries( + latest: ReadonlyArray, + seen: Map, +): Array { + const counts = new Map(); + const fresh = latest.filter((entry) => { + const key = JSON.stringify([ + entry.timestamp, + entry.method, + entry.url, + entry.status, + entry.outcome, + entry.logs, + entry.exceptions, + ]); + const count = (counts.get(key) ?? 0) + 1; + counts.set(key, count); + return count > (seen.get(key) ?? 0); + }); + for (const [key, count] of counts) { + seen.set(key, Math.max(seen.get(key) ?? 0, count)); + } + return fresh; +} + export const MINIMAL_RUNTIME_LOGS_NOTICE = 'Log messages, exception messages and URLs are not recorded on this platform.'; @@ -149,30 +179,14 @@ export function DeploymentLogs({ deploymentId }: { deploymentId: string }) { return; } let active = true; - const seen = new Set(); + const seen = new Map(); const poll = async () => { try { const latest = await fetchEntries('1m', 50); if (!latest || !active) { return; } - const fresh = latest.filter((entry) => { - // Minimal runtime logs leave url empty, so the rest of the row tells requests apart. - const key = JSON.stringify([ - entry.timestamp, - entry.method, - entry.url, - entry.status, - entry.outcome, - entry.logs, - entry.exceptions, - ]); - if (seen.has(key)) { - return false; - } - seen.add(key); - return true; - }); + const fresh = takeFreshTailEntries(latest, seen); if (fresh.length > 0) { setEntries((current) => [...fresh, ...(current ?? [])]); } diff --git a/platform/packages/api/test/unit/admin-deployment-logs.test.ts b/platform/packages/api/test/unit/admin-deployment-logs.test.ts index 83adf28f5..821f53c26 100644 --- a/platform/packages/api/test/unit/admin-deployment-logs.test.ts +++ b/platform/packages/api/test/unit/admin-deployment-logs.test.ts @@ -1,7 +1,7 @@ import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { expect, it, vi } from 'vite-plus/test'; -import { LogRow } from '../../src/admin/react/views/DeploymentLogs.tsx'; +import { LogRow, takeFreshTailEntries } from '../../src/admin/react/views/DeploymentLogs.tsx'; vi.mock('react-fate', () => ({ useFateClient: () => ({}) })); vi.mock('../../src/admin/fate/views.ts', () => ({ @@ -47,3 +47,13 @@ it('keeps the full row layout when messages and URLs are recorded', () => { expect(markup).toContain('boom'); expect(markup).toContain('TypeError: bad input'); }); + +it('keeps two identical minimal rows from one tail poll and drops them when polled again', () => { + const row = { timestamp: 1, outcome: 'ok', method: 'GET', url: '', status: '200' }; + const seen = new Map(); + expect(takeFreshTailEntries([row, { ...row }], seen)).toHaveLength(2); + expect(takeFreshTailEntries([row, { ...row }], seen)).toHaveLength(0); + // A third identical request lands in the next poll. + expect(takeFreshTailEntries([row, { ...row }, { ...row }], seen)).toHaveLength(1); + expect(takeFreshTailEntries([{ ...row, status: '500' }], seen)).toHaveLength(1); +});