Authoring a browser extension
A browser extension is an opt-in feature that implements Extension and talks to its host SDK exclusively through the
single Client adapter passed to setup and shared by extensions on that SDK instance. The contract is designed for
extensions shared across browser generations; concrete host adapters and loading integrations remain owned by their SDK
packages.
The shape
Prefer a class when porting a posthog-js v1 extension that is already a class. Retaining its method boundaries makes the
port easier to review and keeps future fixes comparable with v1.
import type { Client, Extension } from '@posthog/browser-common'
import { MyExtensionToken } from './my-extension-contract'
export interface MyExtensionOptions {
enabled?: boolean
}
export class MyExtension implements Extension {
readonly name = MyExtensionToken
private _client: Client | undefined
constructor(private readonly _options: MyExtensionOptions = {}) {}
setup(client: Client): void | Promise<void> {
this._client = client
this.startIfEnabled()
}
startIfEnabled(): void {
// Install listeners and patches here; retain every Disposable.
}
stop(): void {
// Release resources owned by this instance.
}
dispose(): void {
this.stop()
this._client = undefined
}
}
name is unique within one client and is used for diagnostics and de-duplication.
setup(client) may be async when it needs KV or other asynchronous state.
dispose() is optional, synchronous, idempotent, and best-effort.
- Static SDK configuration belongs in explicit constructor options, not in
Client.
When other extensions need its controls, export an implementation-free typed token from a small contract module:
import type { Extension, ExtensionToken } from '@posthog/browser-common'
export interface MyExtensionApi extends Extension {
stop(): void
}
export const MyExtensionToken = 'myExtension' as ExtensionToken<MyExtensionApi>
The token string must match the implementation's name. Keep the interface and token separate from the implementation
so consumers do not pull its code into their bundles.
Client capabilities
| Need |
Use |
| current identity |
client.distinctId, client.anonymousId, client.groups |
| current session |
client.session |
| check capture permission |
client.canCapture |
| record an event |
await client.capture(event, properties?, options?) |
| add properties to every event |
client.registerDynamicEventProperties(() => ({ … })) |
| react to finalized events |
client.onEvent(({ event, properties }) => …) |
| access an installed extension |
client.getExtension(MyExtensionToken) |
| call a PostHog endpoint |
await client.sendRequest(path, init?) |
| react to server config |
client.onRemoteConfig(…) |
| persist small state |
client.kv |
| log |
client.logger |
Create an extension-named child logger with client.logger.createLogger('[myExtension]') when its messages need a
prefix. onRemoteConfig replays the latest available outcome when host capabilities permit; configuration timing is
host-owned, and the subscription remains active until disposed. Narrow on result.ok before reading result.config
and define safe behavior for { ok: false }.
sendRequest is a low-level transport bridge. Select the configured origin with target (api, flags, or
assets) and construct endpoint authentication using client.projectToken in the required query parameter, body,
header, or path.
Hard rules
- Enrichers are synchronous.
registerDynamicEventProperties runs inline while the host builds an event. Read any
async state during setup and close over it.
- Enrich = add; observe = react. Dynamic properties contribute to events;
onEvent observes finalized events and
cannot mutate them.
- Keep disposables. Store and release values returned by listeners, dynamic-property registration, and timer or
patch wrappers. Use
createDisposable(teardown) for idempotent synchronous cleanup.
- Treat extension lookup as optional.
getExtension(token) returns undefined when the named extension is absent
or has been removed. A resolved extension may still be running setup; use typed stable-name tokens and avoid
mandatory startup cycles between extensions.
- Initialize KV, then use it synchronously.
client.kv.initialize() may be awaitable while a host hydrates its
memory buffer. Once initialization completes, KV reads, writes, and removals are synchronous; the host owns ordered
durable flushing. Identity, session, canCapture, and projectToken are also synchronous, while capture and requests
are awaitable and remote-config outcomes are delivered through onRemoteConfig.
- Design for async readiness. Setup may occur before remote config loads or after events have already been captured.
Guard work after each
await so disposal cannot be followed by late installation.
- Persist through
client.kv, not globals. Complete client.kv.initialize() during setup before using the
synchronous buffer. Browser-v1 keys are passed verbatim to persistence. Unknown keys may be captured as event
properties, collisions can overwrite SDK state, and reset clears them. Use stable extension-owned keys and define
their exposure policy.
- browser-common owns shared extensions outright. SDKs construct shared extensions and call
setup(client) and
optional dispose(); they do not wrap or subclass extension implementations.
Event streams
Use Publisher for an event stream exposed by an extension. Keep the publisher private and expose only its listener:
import { Publisher, type Extension, type Listener } from '@posthog/browser-common'
interface FeatureFlagsChange {
flag: string
value: string | boolean | undefined
}
export class FeatureFlagsExtension implements Extension {
readonly name = 'featureFlags'
private readonly _changes = new Publisher<FeatureFlagsChange>()
readonly onChange: Listener<FeatureFlagsChange> = this._changes.listener
setup(): void {}
dispose(): void {
this._changes.dispose()
}
}
Porting from browser-v1
| v1 |
Shared extension |
instance.capture(e, p) |
client.capture(e, p) |
instance.get_distinct_id() |
client.distinctId |
instance.get_property(k) / persistence |
client.kv.get(k) |
instance.config.X (static) |
constructor option |
instance.config.X (server-driven) |
client.onRemoteConfig(...) |
instance.sessionManager.checkAndGetSessionAndWindowId(true) |
client.session |
instance.is_capturing() |
client.canCapture |
_addCaptureHook / observing events |
client.onEvent(...) |
| registering an enricher |
client.registerDynamicEventProperties(fn) |
| accessing another installed extension |
client.getExtension(MyExtensionToken) |
requestRouter.endpointFor(...) + _send_request |
client.sendRequest(path, init?) |
| snapshot/keepalive send on unload |
client.sendRequest(path, { transport: 'sendBeacon' }) |
Checklist
1---2name: develop-extension3description: Author a new PostHog browser extension, or port a posthog-js v1 extension, against the @posthog/browser-common Client/Extension contract. Use when adding or porting an extension (autocapture, pageview, surveys, replay, exceptions, web-vitals, campaign-params, feature flags, …).4---56# Authoring a browser extension78A browser extension is an opt-in feature that implements `Extension` and talks to its host SDK exclusively through the9single `Client` adapter passed to `setup` and shared by extensions on that SDK instance. The contract is designed for10extensions shared across browser generations; concrete host adapters and loading integrations remain owned by their SDK11packages.1213## The shape1415Prefer a class when porting a posthog-js v1 extension that is already a class. Retaining its method boundaries makes the16port easier to review and keeps future fixes comparable with v1.1718```ts19import type { Client, Extension } from '@posthog/browser-common'2021import { MyExtensionToken } from './my-extension-contract'2223export interface MyExtensionOptions {24 enabled?: boolean25}2627export class MyExtension implements Extension {28 readonly name = MyExtensionToken29 private _client: Client | undefined3031 constructor(private readonly _options: MyExtensionOptions = {}) {}3233 setup(client: Client): void | Promise<void> {34 this._client = client35 this.startIfEnabled()36 }3738 startIfEnabled(): void {39 // Install listeners and patches here; retain every Disposable.40 }4142 stop(): void {43 // Release resources owned by this instance.44 }4546 dispose(): void {47 this.stop()48 this._client = undefined49 }50}51```5253- `name` is unique within one client and is used for diagnostics and de-duplication.54- `setup(client)` may be async when it needs KV or other asynchronous state.55- `dispose()` is optional, synchronous, idempotent, and best-effort.56- Static SDK configuration belongs in explicit constructor options, not in `Client`.5758When other extensions need its controls, export an implementation-free typed token from a small contract module:5960```ts61import type { Extension, ExtensionToken } from '@posthog/browser-common'6263export interface MyExtensionApi extends Extension {64 stop(): void65}6667export const MyExtensionToken = 'myExtension' as ExtensionToken<MyExtensionApi>68```6970The token string must match the implementation's `name`. Keep the interface and token separate from the implementation71so consumers do not pull its code into their bundles.7273## Client capabilities7475| Need | Use |76| ----------------------------- | ---------------------------------------------------------- |77| current identity | `client.distinctId`, `client.anonymousId`, `client.groups` |78| current session | `client.session` |79| check capture permission | `client.canCapture` |80| record an event | `await client.capture(event, properties?, options?)` |81| add properties to every event | `client.registerDynamicEventProperties(() => ({ … }))` |82| react to finalized events | `client.onEvent(({ event, properties }) => …)` |83| access an installed extension | `client.getExtension(MyExtensionToken)` |84| call a PostHog endpoint | `await client.sendRequest(path, init?)` |85| react to server config | `client.onRemoteConfig(…)` |86| persist small state | `client.kv` |87| log | `client.logger` |8889Create an extension-named child logger with `client.logger.createLogger('[myExtension]')` when its messages need a90prefix. `onRemoteConfig` replays the latest available outcome when host capabilities permit; configuration timing is91host-owned, and the subscription remains active until disposed. Narrow on `result.ok` before reading `result.config`92and define safe behavior for `{ ok: false }`.9394`sendRequest` is a low-level transport bridge. Select the configured origin with `target` (`api`, `flags`, or95`assets`) and construct endpoint authentication using `client.projectToken` in the required query parameter, body,96header, or path.9798## Hard rules99100- **Enrichers are synchronous.** `registerDynamicEventProperties` runs inline while the host builds an event. Read any101 async state during setup and close over it.102- **Enrich = add; observe = react.** Dynamic properties contribute to events; `onEvent` observes finalized events and103 cannot mutate them.104- **Keep disposables.** Store and release values returned by listeners, dynamic-property registration, and timer or105 patch wrappers. Use `createDisposable(teardown)` for idempotent synchronous cleanup.106- **Treat extension lookup as optional.** `getExtension(token)` returns `undefined` when the named extension is absent107 or has been removed. A resolved extension may still be running `setup`; use typed stable-name tokens and avoid108 mandatory startup cycles between extensions.109- **Initialize KV, then use it synchronously.** `client.kv.initialize()` may be awaitable while a host hydrates its110 memory buffer. Once initialization completes, KV reads, writes, and removals are synchronous; the host owns ordered111 durable flushing. Identity, session, `canCapture`, and `projectToken` are also synchronous, while capture and requests112 are awaitable and remote-config outcomes are delivered through `onRemoteConfig`.113- **Design for async readiness.** Setup may occur before remote config loads or after events have already been captured.114 Guard work after each `await` so disposal cannot be followed by late installation.115- **Persist through `client.kv`, not globals.** Complete `client.kv.initialize()` during setup before using the116 synchronous buffer. Browser-v1 keys are passed verbatim to persistence. Unknown keys may be captured as event117 properties, collisions can overwrite SDK state, and reset clears them. Use stable extension-owned keys and define118 their exposure policy.119- **browser-common owns shared extensions outright.** SDKs construct shared extensions and call `setup(client)` and120 optional `dispose()`; they do not wrap or subclass extension implementations.121122## Event streams123124Use `Publisher` for an event stream exposed by an extension. Keep the publisher private and expose only its listener:125126```ts127import { Publisher, type Extension, type Listener } from '@posthog/browser-common'128129interface FeatureFlagsChange {130 flag: string131 value: string | boolean | undefined132}133134export class FeatureFlagsExtension implements Extension {135 readonly name = 'featureFlags'136 private readonly _changes = new Publisher<FeatureFlagsChange>()137 readonly onChange: Listener<FeatureFlagsChange> = this._changes.listener138139 setup(): void {}140141 dispose(): void {142 this._changes.dispose()143 }144}145```146147## Porting from browser-v1148149| v1 | Shared extension |150| ------------------------------------------------------------- | ------------------------------------------------------- |151| `instance.capture(e, p)` | `client.capture(e, p)` |152| `instance.get_distinct_id()` | `client.distinctId` |153| `instance.get_property(k)` / `persistence` | `client.kv.get(k)` |154| `instance.config.X` (static) | constructor option |155| `instance.config.X` (server-driven) | `client.onRemoteConfig(...)` |156| `instance.sessionManager.checkAndGetSessionAndWindowId(true)` | `client.session` |157| `instance.is_capturing()` | `client.canCapture` |158| `_addCaptureHook` / observing events | `client.onEvent(...)` |159| registering an enricher | `client.registerDynamicEventProperties(fn)` |160| accessing another installed extension | `client.getExtension(MyExtensionToken)` |161| `requestRouter.endpointFor(...)` + `_send_request` | `client.sendRequest(path, init?)` |162| snapshot/keepalive send on unload | `client.sendRequest(path, { transport: 'sendBeacon' })` |163164## Checklist165166- [ ] All disposables are released in `dispose()`.167- [ ] Enrichers are synchronous; async data is read during setup.168- [ ] Static configuration is passed through constructor options.169- [ ] Event streams keep publishers private and expose listeners.170- [ ] Tests cover setup, teardown, behavior, and shared global patching or multi-instance behavior.