diff --git a/.changeset/strict-capture-defaults.md b/.changeset/strict-capture-defaults.md new file mode 100644 index 0000000000..1d215951c8 --- /dev/null +++ b/.changeset/strict-capture-defaults.md @@ -0,0 +1,5 @@ +--- +'posthog-node': minor +--- + +Add `defaults` versioned config and `strictCapture` option to throw on invalid capture() arguments diff --git a/packages/node/examples/strict-capture-test.mjs b/packages/node/examples/strict-capture-test.mjs new file mode 100644 index 0000000000..f61fe74bd1 --- /dev/null +++ b/packages/node/examples/strict-capture-test.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +/* eslint-env node */ +/** + * Strict Capture Demo + * + * Demonstrates the strictCapture behavior introduced with defaults >= '2026-03-19'. + * When enabled, passing a plain string to capture() throws a TypeError instead of + * silently warning, catching misuse at development time. + * + * Usage: + * node examples/strict-capture-test.mjs + * POSTHOG_PROJECT_API_KEY=phc_... node examples/strict-capture-test.mjs + */ + +const { PostHog } = await import('../dist/entrypoints/index.node.mjs') + +const API_KEY = process.env.POSTHOG_PROJECT_API_KEY || 'fake-key' +const HOST = process.env.POSTHOG_HOST || 'http://localhost:8000' + +// --- Strict mode (via versioned defaults) --- +const strict = new PostHog(API_KEY, { + host: HOST, + defaults: '2026-03-19', +}) + +console.log('1. capture() with correct object form — should succeed:') +strict.capture({ distinctId: 'user-1', event: 'page_view' }) +console.log(' OK\n') + +console.log('2. capture() with a plain string — should throw TypeError:') +try { + strict.capture('page_view') +} catch (e) { + console.log(` Caught: ${e.constructor.name}: ${e.message}\n`) +} + +// --- Legacy mode (no defaults) --- +const legacy = new PostHog(API_KEY, { + host: HOST, +}) +legacy.debug(true) + +console.log('3. capture() with a plain string in legacy mode — should warn (not throw):') +legacy.capture('page_view') +console.log(' (check the warning above)\n') + +await Promise.all([strict.shutdown(), legacy.shutdown()]) +console.log('Done.') diff --git a/packages/node/src/__tests__/posthog-node.spec.ts b/packages/node/src/__tests__/posthog-node.spec.ts index 1cc18708b5..a51701d3d3 100644 --- a/packages/node/src/__tests__/posthog-node.spec.ts +++ b/packages/node/src/__tests__/posthog-node.spec.ts @@ -412,6 +412,37 @@ describe('PostHog Node.js', () => { ) warnSpy.mockRestore() }) + + it.each([ + ['capture', { strictCapture: true }], + ['capture', { defaults: '2026-03-19' }], + ['capture', { defaults: '2026-03-19', strictCapture: undefined }], + ['captureImmediate', { strictCapture: true }], + ['captureImmediate', { defaults: '2026-03-19' }], + ['captureImmediate', { defaults: '2026-03-19', strictCapture: undefined }], + ] as const)('should throw if %s is called with a string when %j', async (method, extraOptions) => { + const ph = new PostHog('TEST_API_KEY', { host: 'http://example.com', ...extraOptions }) + // @ts-expect-error - Testing the error when passing a string instead of an object + await expect(Promise.resolve().then(() => ph[method]('test-event'))).rejects.toThrow(TypeError) + await ph.shutdown() + }) + + it.each([ + ['capture', { defaults: 'unset' as const }], + ['captureImmediate', { defaults: 'unset' as const }], + ['capture', { defaults: '2026-03-19' as const, strictCapture: false }], + ['captureImmediate', { defaults: '2026-03-19' as const, strictCapture: false }], + ])('should warn if %s is called with a string when %j', async (method, extraOptions) => { + const ph = new PostHog('TEST_API_KEY', { host: 'http://example.com', ...extraOptions }) + ph.debug(true) + // @ts-expect-error - Testing the warning when passing a string instead of an object + ph[method]('test-event') + expect(warnSpy).toHaveBeenCalledWith( + '[PostHog]', + `Called ${method}() with a string as the first argument when an object was expected.` + ) + await ph.shutdown() + }) }) describe('before_send', () => { diff --git a/packages/node/src/client.ts b/packages/node/src/client.ts index 9428ac9e59..55d58fc2c0 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -22,6 +22,7 @@ import { GroupIdentifyMessage, IdentifyMessage, IPostHog, + NodeConfigDefaults, OverrideFeatureFlagsOptions, PostHogOptions, SendFeatureFlagsOptions, @@ -49,6 +50,10 @@ const MAX_CACHE_SIZE = 50 * 1000 const WAITUNTIL_DEBOUNCE_MS = 50 const WAITUNTIL_MAX_WAIT_MS = 500 +const defaultsThatVaryByConfig = (defaults?: NodeConfigDefaults): Pick => ({ + strictCapture: !!defaults && defaults !== 'unset' && defaults >= '2026-03-19', +}) + // The actual exported Nodejs API. export abstract class PostHogBackendClient extends PostHogCoreStateless implements IPostHog { private _memoryStorage = new PostHogMemoryStorage() @@ -104,7 +109,11 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen constructor(apiKey: string, options: PostHogOptions = {}) { super(apiKey, options) - this.options = options + const { strictCapture: strictCaptureDefault } = defaultsThatVaryByConfig(options.defaults) + this.options = { + ...options, + strictCapture: options.strictCapture ?? strictCaptureDefault, + } this.context = this.initializeContext() this.options.featureFlagsPollingInterval = @@ -431,7 +440,11 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen */ capture(props: EventMessage): void { if (typeof props === 'string') { - this._logger.warn('Called capture() with a string as the first argument when an object was expected.') + const msg = 'Called capture() with a string as the first argument when an object was expected.' + if (this.options.strictCapture) { + throw new TypeError(msg) + } + this._logger.warn(msg) } if (props.event === '$exception' && !props._originatedFromCaptureException) { this._logger.warn( @@ -500,7 +513,11 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen */ async captureImmediate(props: EventMessage): Promise { if (typeof props === 'string') { - this._logger.warn('Called captureImmediate() with a string as the first argument when an object was expected.') + const msg = 'Called captureImmediate() with a string as the first argument when an object was expected.' + if (this.options.strictCapture) { + throw new TypeError(msg) + } + this._logger.warn(msg) } if (props.event === '$exception' && !props._originatedFromCaptureException) { this._logger.warn( diff --git a/packages/node/src/types.ts b/packages/node/src/types.ts index 6630773cc0..81f7add246 100644 --- a/packages/node/src/types.ts +++ b/packages/node/src/types.ts @@ -8,6 +8,8 @@ import type { } from '@posthog/core' import { ContextData, ContextOptions } from './extensions/context/types' +export type NodeConfigDefaults = '2026-03-19' | 'unset' + import type { FlagDefinitionCacheProvider } from './extensions/feature-flags/cache' export type IdentifyMessage = { @@ -215,6 +217,25 @@ export type PostHogOptions = Omit & { * @default false */ strictLocalEvaluation?: boolean + /** + * Configuration defaults for breaking changes. When set to a specific date, + * enables new default behaviors introduced on that date. + * + * - `'unset'`: Legacy default behaviors + * - `'2026-03-19'`: capture() and captureImmediate() throw on invalid arguments + * + * @default 'unset' + */ + defaults?: NodeConfigDefaults + /** + * When enabled, capture() and captureImmediate() throw an error instead of + * warning when called with invalid arguments (e.g., a string instead of an + * EventMessage object). + * + * Defaults to `true` when `defaults >= '2026-03-19'`, `false` otherwise. + * Explicitly setting this overrides the defaults-based value. + */ + strictCapture?: boolean /** * Provides the API to extend the lifetime of a serverless invocation until * background work (like flushing analytics events) completes after the response