diff --git a/.changeset/transport-credentials.md b/.changeset/transport-credentials.md new file mode 100644 index 0000000..41a2b68 --- /dev/null +++ b/.changeset/transport-credentials.md @@ -0,0 +1,5 @@ +--- +"evlog": minor +--- + +Add configurable `credentials` (`RequestCredentials`, default `same-origin`) for the client log transport and browser drain `fetch` calls. The Nuxt module forwards `transport.credentials` into `runtimeConfig.public.evlog` so client `initLog()` receives it. diff --git a/apps/docs/content/3.core-concepts/6.client-logging.md b/apps/docs/content/3.core-concepts/6.client-logging.md index 309fb89..e0644b7 100644 --- a/apps/docs/content/3.core-concepts/6.client-logging.md +++ b/apps/docs/content/3.core-concepts/6.client-logging.md @@ -150,7 +150,7 @@ export default defineNuxtPlugin(() => { service: 'web', transport: { enabled: true, - endpoint: '/api/_evlog/ingest', // default + endpoint: '/api/_evlog/ingest', }, }) }) diff --git a/apps/docs/content/3.core-concepts/7.configuration.md b/apps/docs/content/3.core-concepts/7.configuration.md index da182ad..b58942d 100644 --- a/apps/docs/content/3.core-concepts/7.configuration.md +++ b/apps/docs/content/3.core-concepts/7.configuration.md @@ -147,6 +147,7 @@ The Nuxt module accepts all global options and middleware options in `nuxt.confi | `console` | `boolean` | `true` | Enable/disable browser console output (client-side only) | | `transport.enabled` | `boolean` | `false` | Send client logs to the server via API endpoint | | `transport.endpoint` | `string` | `'/api/_evlog/ingest'` | Custom transport endpoint | +| `transport.credentials` | `RequestCredentials` | `'same-origin'` | Fetch credentials mode (`'include'` for cross-origin endpoints) | See the full [Nuxt configuration](/frameworks/nuxt#configuration). diff --git a/apps/docs/content/4.adapters/11.browser.md b/apps/docs/content/4.adapters/11.browser.md index f985d57..ecce318 100644 --- a/apps/docs/content/4.adapters/11.browser.md +++ b/apps/docs/content/4.adapters/11.browser.md @@ -90,6 +90,7 @@ const drain = pipeline(transport) | `headers` | - | Custom headers sent with each `fetch` request (e.g. `Authorization`, `X-API-Key`) | | `timeout` | `5000` | Request timeout in milliseconds | | `useBeacon` | `true` | Use `sendBeacon` when the page is hidden | +| `credentials` | `'same-origin'` | Fetch credentials mode (`'omit'`, `'same-origin'`, `'include'`). Set to `'include'` for cross-origin endpoints | ### `BrowserLogDrainOptions` @@ -123,7 +124,7 @@ const drain = createBrowserLogDrain({ ``` ::callout{icon="i-lucide-shield-alert" color="warning"} -`headers` are applied to `fetch` requests only. The `sendBeacon` API does not support custom headers, so when the page is hidden and `sendBeacon` is used, headers are not sent. If your endpoint requires authentication, consider validating via a session cookie (`credentials: 'same-origin'` is set by default) or disable `sendBeacon` with `useBeacon: false`. +`headers` are applied to `fetch` requests only. The `sendBeacon` API does not support custom headers, so when the page is hidden and `sendBeacon` is used, headers are not sent. If your endpoint requires authentication, consider validating via a session cookie (set `credentials: 'include'` for cross-origin endpoints, defaults to `'same-origin'`) or disable `sendBeacon` with `useBeacon: false`. :: ## Server Endpoint diff --git a/packages/evlog/src/browser.ts b/packages/evlog/src/browser.ts index 64c949a..2a88450 100644 --- a/packages/evlog/src/browser.ts +++ b/packages/evlog/src/browser.ts @@ -11,6 +11,8 @@ export interface BrowserDrainConfig { timeout?: number /** Use sendBeacon when the page is hidden. @default true */ useBeacon?: boolean + /** Fetch credentials mode. @default 'same-origin' */ + credentials?: RequestCredentials } export interface BrowserLogDrainOptions { @@ -39,7 +41,7 @@ export interface BrowserLogDrainOptions { * ``` */ export function createBrowserDrain(config: BrowserDrainConfig): (batch: DrainContext[]) => Promise { - const { endpoint, headers: customHeaders, timeout = 5000, useBeacon = true } = config + const { endpoint, headers: customHeaders, timeout = 5000, useBeacon = true, credentials = 'same-origin' } = config return async (batch: DrainContext[]): Promise => { if (batch.length === 0) return @@ -70,7 +72,7 @@ export function createBrowserDrain(config: BrowserDrainConfig): (batch: DrainCon body, signal: controller.signal, keepalive: true, - credentials: 'same-origin', + credentials, }) if (!response.ok) { diff --git a/packages/evlog/src/nuxt/module.ts b/packages/evlog/src/nuxt/module.ts index 47e25f8..0d2555a 100644 --- a/packages/evlog/src/nuxt/module.ts +++ b/packages/evlog/src/nuxt/module.ts @@ -136,8 +136,9 @@ export interface ModuleOptions { * @example * ```ts * transport: { - * enabled: true, // Send logs to server API - * endpoint: '/api/_evlog/ingest' // Custom endpoint + * enabled: true, // send client logs to server via API endpoint + * endpoint: '/api/_evlog/ingest', // default endpoint (or custom endpoint) + * credentials: 'include', // optional: cross-origin ingest * } * ``` */ @@ -264,6 +265,7 @@ export default defineNuxtModule({ const transportEnabled = options.transport?.enabled ?? false const transportEndpoint = options.transport?.endpoint ?? '/api/_evlog/ingest' + const transportCredentials = options.transport?.credentials ?? 'same-origin' // Register custom error handler for proper EvlogError serialization // Only set if not already configured to avoid overwriting user's custom handler @@ -280,6 +282,7 @@ export default defineNuxtModule({ transport: { enabled: transportEnabled, endpoint: transportEndpoint, + credentials: transportCredentials, }, } diff --git a/packages/evlog/src/runtime/client/log.ts b/packages/evlog/src/runtime/client/log.ts index d4034ee..8aa1186 100644 --- a/packages/evlog/src/runtime/client/log.ts +++ b/packages/evlog/src/runtime/client/log.ts @@ -9,6 +9,7 @@ let clientPretty = true let clientService = 'client' let transportEnabled = false let transportEndpoint = '/api/_evlog/ingest' +let transportCredentials: RequestCredentials = 'same-origin' let identityContext: Record = {} export function setIdentity(identity: Record): void { @@ -27,6 +28,7 @@ export function initLog(options: { enabled?: boolean, console?: boolean, pretty? clientService = options.service ?? 'client' transportEnabled = options.transport?.enabled ?? false transportEndpoint = options.transport?.endpoint ?? '/api/_evlog/ingest' + transportCredentials = options.transport?.credentials ?? 'same-origin' } async function sendToServer(event: Record): Promise { @@ -38,7 +40,7 @@ async function sendToServer(event: Record): Promise { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(event), keepalive: true, - credentials: 'same-origin', + credentials: transportCredentials, }) } catch { // Silently fail - don't break the app diff --git a/packages/evlog/src/types.ts b/packages/evlog/src/types.ts index 26093da..f7b2e15 100644 --- a/packages/evlog/src/types.ts +++ b/packages/evlog/src/types.ts @@ -73,6 +73,12 @@ export interface TransportConfig { * @default '/api/_evlog/ingest' */ endpoint?: string + + /** + * Fetch credentials mode + * @default 'same-origin' + */ + credentials?: RequestCredentials } /** diff --git a/packages/evlog/test/browser.test.ts b/packages/evlog/test/browser.test.ts index f403223..368ac80 100644 --- a/packages/evlog/test/browser.test.ts +++ b/packages/evlog/test/browser.test.ts @@ -58,6 +58,15 @@ describe('createBrowserDrain', () => { }) }) + it('uses custom credentials mode', async () => { + const drain = createBrowserDrain({ endpoint: '/api/logs', credentials: 'include' }) + + await drain([createTestContext(1)]) + + const [, options] = vi.mocked(fetch).mock.calls[0]! + expect(options?.credentials).toBe('include') + }) + it('skips empty batches', async () => { const drain = createBrowserDrain({ endpoint: '/api/logs' }) diff --git a/packages/evlog/test/client-console.test.ts b/packages/evlog/test/client-console.test.ts index e3e739d..87e07f6 100644 --- a/packages/evlog/test/client-console.test.ts +++ b/packages/evlog/test/client-console.test.ts @@ -54,6 +54,20 @@ describe('client console option', () => { expect(fetchSpy).toHaveBeenCalledTimes(1) }) + it('uses custom credentials mode for transport', () => { + initLog({ + enabled: true, + console: false, + pretty: false, + transport: { enabled: true, endpoint: '/api/_evlog/ingest', credentials: 'include' }, + }) + + log.info({ action: 'test' }) + + const [, options] = fetchSpy.mock.calls[0]! + expect(options?.credentials).toBe('include') + }) + it('suppresses pretty console output when console is false', () => { initLog({ enabled: true, console: false, pretty: true })