Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/strict-capture-defaults.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog-node': minor
---

Add `defaults` versioned config and `strictCapture` option to throw on invalid capture() arguments
48 changes: 48 additions & 0 deletions packages/node/examples/strict-capture-test.mjs
Original file line number Diff line number Diff line change
@@ -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.')
31 changes: 31 additions & 0 deletions packages/node/src/__tests__/posthog-node.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
23 changes: 20 additions & 3 deletions packages/node/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
GroupIdentifyMessage,
IdentifyMessage,
IPostHog,
NodeConfigDefaults,
OverrideFeatureFlagsOptions,
PostHogOptions,
SendFeatureFlagsOptions,
Expand Down Expand Up @@ -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<PostHogOptions, 'strictCapture'> => ({
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()
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO SDKs should never throw, SDKs should degrade gracefully and warn/log out if something is wrong (which is whats happening here).
Curious why people called with the wrong params tho, though. Was it not up-to-date documentation? LLMs guessing public APIs?

}
this._logger.warn(msg)
}
if (props.event === '$exception' && !props._originatedFromCaptureException) {
this._logger.warn(
Expand Down Expand Up @@ -500,7 +513,11 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen
*/
async captureImmediate(props: EventMessage): Promise<void> {
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(
Expand Down
21 changes: 21 additions & 0 deletions packages/node/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -215,6 +217,25 @@ export type PostHogOptions = Omit<PostHogCoreOptions, 'before_send'> & {
* @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
Expand Down
Loading