From aaa9cf54a7f57d86996f8f77a112c24b85bd324f Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Fri, 7 Aug 2026 21:06:25 +0100 Subject: [PATCH] fix(fs): disable the drain when its directory is not writable --- .changeset/fs-drain-readonly-directory.md | 14 +++++++ .../4.integrate/adapters/self-hosted/01.fs.md | 2 +- packages/evlog/src/adapters/fs.ts | 35 +++++++++++++++++- packages/evlog/test/adapters/fs.test.ts | 37 +++++++++++++++++++ 4 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 .changeset/fs-drain-readonly-directory.md diff --git a/.changeset/fs-drain-readonly-directory.md b/.changeset/fs-drain-readonly-directory.md new file mode 100644 index 00000000..8649c576 --- /dev/null +++ b/.changeset/fs-drain-readonly-directory.md @@ -0,0 +1,14 @@ +--- +"evlog": patch +--- + +The file system drain disables itself when its directory is not writable. + +`createFsDrain()` guarded neither its `mkdir` nor its `appendFile`, so attaching it on a serverless host — where everything outside the temp directory is read-only — threw once per batch for the lifetime of the deployment, and the events went nowhere regardless. Callers had to guess at the environment to avoid it: + +```ts +// no longer needed +const drain = process.env.VERCEL !== '1' ? createFsDrain() : undefined +``` + +The drain now probes the directory once, and on `EROFS`, `EACCES` or `EPERM` warns a single time and stops, the same way it already bows out of the Edge runtime. Attach it unconditionally. Any other failure — a full disk, a genuine bug — still propagates. diff --git a/apps/docs/content/4.integrate/adapters/self-hosted/01.fs.md b/apps/docs/content/4.integrate/adapters/self-hosted/01.fs.md index 9d81c99d..41eca29f 100644 --- a/apps/docs/content/4.integrate/adapters/self-hosted/01.fs.md +++ b/apps/docs/content/4.integrate/adapters/self-hosted/01.fs.md @@ -82,7 +82,7 @@ export const { withEvlog, useLogger, log, createError } = createEvlog({ ``` ::callout{icon="i-lucide-info" color="info"} -The FS adapter requires Node.js (`node:fs`). On the Edge runtime it logs a one-time `[evlog/fs]` warning and skips writes. Use `evlog/memory` or an HTTP adapter for Edge routes. +The FS adapter requires Node.js (`node:fs`). On the Edge runtime, or when its directory is not writable, it logs a one-time `[evlog/fs]` warning and skips writes — so attaching it on a serverless host is safe but pointless, since only the temp directory is writable there and it does not outlive the instance. Use `evlog/memory` or an HTTP adapter for those. :: ```typescript [Hono] import { createFsDrain } from 'evlog/fs' diff --git a/packages/evlog/src/adapters/fs.ts b/packages/evlog/src/adapters/fs.ts index 15e6d5be..0459485c 100644 --- a/packages/evlog/src/adapters/fs.ts +++ b/packages/evlog/src/adapters/fs.ts @@ -26,6 +26,7 @@ const FS_FIELDS: ConfigField[] = [ ] const gitignoreWritten = new Set() +const writableDirs = new Map() let warnedFsEdgeRuntime = false function isEdgeRuntime(): boolean { @@ -38,6 +39,36 @@ function warnFsEdgeRuntimeOnce(): void { console.warn('[evlog/fs] File system drain is not available on the Edge runtime. Use evlog/memory or a HTTP adapter instead.') } +/** Read-only or permission-denied, as opposed to a full disk or a real bug. */ +function isUnwritableError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | null)?.code + return code === 'EROFS' || code === 'EACCES' || code === 'EPERM' +} + +/** + * Whether `dir` can be created and written to, probed once per directory. + * + * Serverless hosts mount everything outside the temp directory read-only, so a + * drain attached there would otherwise throw on every batch for the lifetime of + * the deployment. Anything other than a permission failure still propagates. + */ +async function isDirWritable(dir: string): Promise { + const cached = writableDirs.get(dir) + if (cached !== undefined) return cached + + try { + await mkdir(dir, { recursive: true }) + } catch (error) { + if (!isUnwritableError(error)) throw error + writableDirs.set(dir, false) + console.warn(`[evlog/fs] "${dir}" is not writable, so the file system drain is disabled. This is expected on a serverless host, where only the temp directory is writable and does not outlive the instance — send events to a HTTP adapter instead, or set the drain's \`dir\` to a writable path.`) + return false + } + + writableDirs.set(dir, true) + return true +} + async function ensureGitignore(dir: string): Promise { const normalized = dir.replace(/[\\/]/g, sep) const segments = normalized.split(sep) @@ -155,8 +186,10 @@ export function createFsDrain(overrides?: Partial) { return null } const resolved = await resolveAdapterConfig('fs', FS_FIELDS, overrides) + const dir = resolved.dir ?? '.evlog/logs' + if (!await isDirWritable(dir)) return null return { - dir: resolved.dir ?? '.evlog/logs', + dir, pretty: resolved.pretty ?? false, maxFiles: resolved.maxFiles, maxSizePerFile: resolved.maxSizePerFile, diff --git a/packages/evlog/test/adapters/fs.test.ts b/packages/evlog/test/adapters/fs.test.ts index de9d26d5..163a5cca 100644 --- a/packages/evlog/test/adapters/fs.test.ts +++ b/packages/evlog/test/adapters/fs.test.ts @@ -336,5 +336,42 @@ describe('fs adapter', () => { warnSpy.mockRestore() } }) + + it('warns once and disables itself when the directory is read-only', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const readOnly = Object.assign(new Error('EROFS'), { code: 'EROFS' }) + + try { + vi.resetModules() + mockedMkdir.mockRejectedValue(readOnly) + const { createFsDrain: createFsDrainFresh } = await import('../../src/adapters/fs') + const drain = createFsDrainFresh({ dir: '/var/task/.evlog/logs' }) + + await drain(createDrainContext({ action: 'readonly' })) + await drain(createDrainContext({ action: 'readonly_again' })) + + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy.mock.calls[0]?.[0]).toContain('not writable') + expect(mockedAppendFile).not.toHaveBeenCalled() + // Probed once, then answered from cache. + expect(mockedMkdir).toHaveBeenCalledTimes(1) + } finally { + warnSpy.mockRestore() + } + }) + + it('propagates a write failure that is not a permission problem', async () => { + vi.resetModules() + mockedMkdir.mockRejectedValue(Object.assign(new Error('ENOSPC'), { code: 'ENOSPC' })) + const { createFsDrain: createFsDrainFresh } = await import('../../src/adapters/fs') + const drain = createFsDrainFresh({ dir: '.evlog/logs' }) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + try { + await expect(drain(createDrainContext())).rejects.toThrow('ENOSPC') + } finally { + errorSpy.mockRestore() + } + }) }) })