Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
71 changes: 71 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,77 @@ describe('PostHog Node.js', () => {
)
warnSpy.mockRestore()
})

it('should throw if capture is called with a string when strictCapture is enabled', async () => {
const ph = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
strictCapture: true,
})
// @ts-expect-error - Testing the error when passing a string instead of an object
expect(() => ph.capture('test-event')).toThrow(TypeError)
await ph.shutdown()
})

it('should throw if captureImmediate is called with a string when strictCapture is enabled', async () => {
const ph = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
strictCapture: true,
})
// @ts-expect-error - Testing the error when passing a string instead of an object
await expect(ph.captureImmediate('test-event')).rejects.toThrow(TypeError)
await ph.shutdown()
})

it('should throw if capture is called with a string when defaults >= 2026-03-19', async () => {
const ph = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
defaults: '2026-03-19',
})
// @ts-expect-error - Testing the error when passing a string instead of an object
expect(() => ph.capture('test-event')).toThrow(TypeError)
await ph.shutdown()
})

it('should throw if captureImmediate is called with a string when defaults >= 2026-03-19', async () => {
const ph = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
defaults: '2026-03-19',
})
// @ts-expect-error - Testing the error when passing a string instead of an object
await expect(ph.captureImmediate('test-event')).rejects.toThrow(TypeError)
await ph.shutdown()
})

it('should warn if capture is called with a string when defaults is unset', () => {
const ph = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
defaults: 'unset',
})
ph.debug(true)
// @ts-expect-error - Testing the warning when passing a string instead of an object
ph.capture('test-event')
expect(warnSpy).toHaveBeenCalledWith(
'[PostHog]',
'Called capture() with a string as the first argument when an object was expected.'
)
ph.shutdown()
})

it('should allow explicit strictCapture: false to override defaults', async () => {
const ph = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
defaults: '2026-03-19',
strictCapture: false,
})
ph.debug(true)
// @ts-expect-error - Testing the warning when passing a string instead of an object
ph.capture('test-event')
expect(warnSpy).toHaveBeenCalledWith(
'[PostHog]',
'Called capture() with a string as the first argument when an object was expected.'
)
await ph.shutdown()
Copy link
Contributor

Choose a reason for hiding this comment

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

P2 Warning path for captureImmediate is not tested

The "should warn" tests only exercise ph.capture(...). The symmetric branch in captureImmediate (lines 511-516 of client.ts) also emits the same warning when strictCapture is false, but there is no corresponding test for it. Given the capture and captureImmediate paths are separate code blocks, adding coverage for captureImmediate here (or via parameterisation as suggested above) would close that gap.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/node/src/__tests__/posthog-node.spec.ts
Line: 456-484

Comment:
**Warning path for `captureImmediate` is not tested**

The "should warn" tests only exercise `ph.capture(...)`. The symmetric branch in `captureImmediate` (lines 511-516 of `client.ts`) also emits the same warning when `strictCapture` is false, but there is no corresponding test for it. Given the `capture` and `captureImmediate` paths are separate code blocks, adding coverage for `captureImmediate` here (or via parameterisation as suggested above) would close that gap.

How can I resolve this? If you propose a fix, please make it concise.

})
})

describe('before_send', () => {
Expand Down
19 changes: 16 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,7 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen
constructor(apiKey: string, options: PostHogOptions = {}) {
super(apiKey, options)

this.options = options
this.options = { ...defaultsThatVaryByConfig(options.defaults), ...options }
this.context = this.initializeContext()

this.options.featureFlagsPollingInterval =
Expand Down Expand Up @@ -431,7 +436,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 +509,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