diff --git a/.changeset/openfeature-web-provider.md b/.changeset/openfeature-web-provider.md
new file mode 100644
index 0000000000..8afff7b750
--- /dev/null
+++ b/.changeset/openfeature-web-provider.md
@@ -0,0 +1,5 @@
+---
+'@posthog/openfeature-web-provider': minor
+---
+
+Initial release of the official PostHog provider for the OpenFeature web SDK, backed by `posthog-js`.
diff --git a/.eslintrc.cjs b/.eslintrc.cjs
index e0e49d042b..f28f05a0a2 100644
--- a/.eslintrc.cjs
+++ b/.eslintrc.cjs
@@ -71,6 +71,7 @@ module.exports = {
'packages/mcp/**',
'packages/nextjs-config/**',
'packages/nuxt/**',
+ 'packages/openfeature-web-provider/**',
'packages/react-native/**',
'packages/node/**',
'packages/web/**',
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 833fbdbc2b..dab205c532 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -24,6 +24,7 @@
- [ ] @posthog/next
- [ ] @posthog/nextjs-config
- [ ] @posthog/nuxt
+- [ ] @posthog/openfeature-web-provider
- [ ] @posthog/rollup-plugin
- [ ] @posthog/webpack-plugin
- [ ] @posthog/types
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 8da508b0fb..2f3a85b6d3 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -268,6 +268,7 @@ jobs:
- name: '@posthog/next'
- name: '@posthog/nextjs-config'
- name: '@posthog/nuxt'
+ - name: '@posthog/openfeature-web-provider'
- name: '@posthog/plugin-utils'
- name: '@posthog/rollup-plugin'
- name: '@posthog/types'
diff --git a/AGENTS.md b/AGENTS.md
index 1a5290d05a..4969b54dcb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -37,6 +37,7 @@ The repository contains the following SDK packages in `./packages/`:
| `nuxt/` | `@posthog/nuxt` | Nuxt framework module |
| `next/` | `@posthog/next` | Next.js framework module |
| `nextjs-config/` | `@posthog/nextjs-config` | Next.js configuration helper |
+| `openfeature-web-provider/` | `@posthog/openfeature-web-provider` | OpenFeature web provider (posthog-js) |
| `plugin-utils/` | `@posthog/plugin-utils` | Shared CLI and sourcemap utilities for plugins |
| `types/` | `@posthog/types` | TypeScript type definitions for the SDK |
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e26d4d9f09..1acdfc3baf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,4 +11,5 @@ PostHog JS is a mono-repo. The changelogs are in the individual packages
- [@posthog/convex](./packages/convex/CHANGELOG.md)
- [@posthog/next](./packages/next/CHANGELOG.md)
- [@posthog/nextjs-config](./packages/nextjs-config/CHANGELOG.md)
+- [@posthog/openfeature-web-provider](./packages/openfeature-web-provider/CHANGELOG.md)
- [@posthog/plugin-utils](./packages/plugin-utils/CHANGELOG.md)
diff --git a/README.md b/README.md
index 420c25b2f9..7408defd22 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,7 @@ Please see the main [PostHog docs](https://posthog.com/docs).
- [@posthog/convex](./packages/convex/README.md)
- [@posthog/nextjs-config](./packages/nextjs-config/README.md)
- [@posthog/nuxt](./packages/nuxt/README.md)
+- [@posthog/openfeature-web-provider](./packages/openfeature-web-provider/README.md)
- [@posthog/plugin-utils](./packages/plugin-utils/README.md)
- [@posthog/rollup-plugin](./packages/rollup-plugin/README.md)
- [@posthog/types](./packages/types/README.md)
diff --git a/examples/example-openfeature-web/.gitignore b/examples/example-openfeature-web/.gitignore
new file mode 100644
index 0000000000..5b9f6ac26a
--- /dev/null
+++ b/examples/example-openfeature-web/.gitignore
@@ -0,0 +1,6 @@
+/node_modules
+/dist
+.DS_Store
+.env
+.env.local
+*.log
diff --git a/examples/example-openfeature-web/.npmrc b/examples/example-openfeature-web/.npmrc
new file mode 100644
index 0000000000..c0f538dcf3
--- /dev/null
+++ b/examples/example-openfeature-web/.npmrc
@@ -0,0 +1,2 @@
+# Minimum age (in days) before a package version can be installed
+min-release-age=7
diff --git a/examples/example-openfeature-web/README.md b/examples/example-openfeature-web/README.md
new file mode 100644
index 0000000000..4919aaa001
--- /dev/null
+++ b/examples/example-openfeature-web/README.md
@@ -0,0 +1,32 @@
+# example-openfeature-web
+
+Minimal browser example of the **[`@posthog/openfeature-web-provider`](../../packages/openfeature-web-provider)** — evaluating PostHog feature flags through the standard [OpenFeature](https://openfeature.dev) web SDK, backed by `posthog-js`.
+
+The server equivalent lives in [`example-openfeature-node`](../example-openfeature-node) (see `@posthog/openfeature-node-provider`).
+
+## What it shows
+
+- Initializing `posthog-js` (you own the client lifecycle and user identity).
+- Registering `PostHogWebProvider` with `OpenFeature.setProviderAndWait(...)`.
+- Synchronous flag evaluation via the vendor-neutral OpenFeature client
+ (`getBooleanValue` / `getStringValue` / `getObjectValue`).
+
+See [`src/main.ts`](./src/main.ts).
+
+## Run it
+
+These examples install workspace packages as tarballs (see [`../README.md`](../README.md)).
+
+1. From the repo root, build the tarballs:
+ ```bash
+ pnpm package:watch
+ ```
+2. In this folder, install and start:
+ ```bash
+ pnpm install
+ pnpm start
+ ```
+3. Open the printed local URL. Set `VITE_POSTHOG_KEY` / `VITE_POSTHOG_HOST` in a
+ `.env` file (or edit `src/main.ts`) and use real flag keys from your project.
+
+> The lockfile is generated on first `pnpm install` per the examples tarball workflow.
diff --git a/examples/example-openfeature-web/index.html b/examples/example-openfeature-web/index.html
new file mode 100644
index 0000000000..faed740e86
--- /dev/null
+++ b/examples/example-openfeature-web/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+ PostHog OpenFeature web provider example
+
+
+ PostHog + OpenFeature (web)
+ Flags evaluated through the standard OpenFeature API, backed by posthog-js:
+ Evaluating flags…
+
+
+
diff --git a/examples/example-openfeature-web/package.json b/examples/example-openfeature-web/package.json
new file mode 100644
index 0000000000..d022ac6517
--- /dev/null
+++ b/examples/example-openfeature-web/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "example-openfeature-web",
+ "version": "0.1.0",
+ "private": true,
+ "packageManager": "pnpm@10.15.0",
+ "type": "module",
+ "scripts": {
+ "start": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@openfeature/web-sdk": "^1.9.0",
+ "@posthog/openfeature-web-provider": "*",
+ "posthog-js": "*"
+ },
+ "devDependencies": {
+ "typescript": "^5.8.2",
+ "vite": "^6.2.0"
+ }
+}
diff --git a/examples/example-openfeature-web/pnpm-workspace.yaml b/examples/example-openfeature-web/pnpm-workspace.yaml
new file mode 100644
index 0000000000..50d3e5defb
--- /dev/null
+++ b/examples/example-openfeature-web/pnpm-workspace.yaml
@@ -0,0 +1,12 @@
+packages:
+ - .
+
+overrides:
+ node-forge: 1.3.2
+
+nodeLinker: hoisted
+pnpmfile: ../.pnpmfile.cjs
+minimumReleaseAge: 10080
+
+blockExoticSubdeps: true
+trustPolicy: no-downgrade
diff --git a/examples/example-openfeature-web/src/main.ts b/examples/example-openfeature-web/src/main.ts
new file mode 100644
index 0000000000..413726a29e
--- /dev/null
+++ b/examples/example-openfeature-web/src/main.ts
@@ -0,0 +1,44 @@
+import { OpenFeature } from '@openfeature/web-sdk'
+import { PostHogWebProvider } from '@posthog/openfeature-web-provider'
+import posthog from 'posthog-js'
+
+// Configure via a local .env file (VITE_POSTHOG_KEY / VITE_POSTHOG_HOST) or edit inline.
+const PROJECT_API_KEY = import.meta.env.VITE_POSTHOG_KEY ?? ''
+const API_HOST = import.meta.env.VITE_POSTHOG_HOST ?? 'https://us.i.posthog.com'
+
+async function main(): Promise {
+ // 1. You own the posthog-js lifecycle. Manage user identity as usual
+ // (posthog.identify(...)); the provider never calls identify().
+ posthog.init(PROJECT_API_KEY, { api_host: API_HOST })
+
+ // 2. Register the PostHog provider with OpenFeature. setProviderAndWait
+ // resolves once the provider's initial flag load has settled.
+ await OpenFeature.setProviderAndWait(new PostHogWebProvider(posthog))
+ const client = OpenFeature.getClient()
+
+ // 3. Evaluate flags through the vendor-neutral OpenFeature API. Web evaluation
+ // is synchronous. Swap these keys for real flags in your project.
+ const result = {
+ 'my-boolean-flag': client.getBooleanValue('my-boolean-flag', false),
+ 'my-multivariate-flag': client.getStringValue('my-multivariate-flag', 'control'),
+ 'my-payload-flag': client.getObjectValue('my-payload-flag', {}),
+ }
+
+ render(result)
+
+ // Optional: pass extra evaluation context (person/group properties). The
+ // provider reconciles it into posthog-js and reloads flags before returning.
+ // await OpenFeature.setContext({ plan: 'enterprise', groups: { organization: 'acme' } })
+}
+
+function render(result: Record): void {
+ const el = document.getElementById('app')
+ if (el) {
+ el.textContent = JSON.stringify(result, null, 2)
+ }
+}
+
+main().catch((err) => {
+ // eslint-disable-next-line no-console
+ console.error('OpenFeature example failed:', err)
+})
diff --git a/examples/example-openfeature-web/src/vite-env.d.ts b/examples/example-openfeature-web/src/vite-env.d.ts
new file mode 100644
index 0000000000..11f02fe2a0
--- /dev/null
+++ b/examples/example-openfeature-web/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/example-openfeature-web/tsconfig.json b/examples/example-openfeature-web/tsconfig.json
new file mode 100644
index 0000000000..d08b0948ab
--- /dev/null
+++ b/examples/example-openfeature-web/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "es2020",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "types": ["vite/client"]
+ },
+ "include": ["src"]
+}
diff --git a/packages/openfeature-web-provider/.prettierrc b/packages/openfeature-web-provider/.prettierrc
new file mode 100644
index 0000000000..5b7d09a033
--- /dev/null
+++ b/packages/openfeature-web-provider/.prettierrc
@@ -0,0 +1,7 @@
+{
+ "trailingComma": "es5",
+ "tabWidth": 2,
+ "semi": false,
+ "singleQuote": true,
+ "printWidth": 120
+}
diff --git a/packages/openfeature-web-provider/README.md b/packages/openfeature-web-provider/README.md
new file mode 100644
index 0000000000..719754f033
--- /dev/null
+++ b/packages/openfeature-web-provider/README.md
@@ -0,0 +1,13 @@
+# @posthog/openfeature-web-provider
+
+Official [PostHog](https://posthog.com) provider for the [OpenFeature](https://openfeature.dev) **web**
+SDK ([`@openfeature/web-sdk`](https://openfeature.dev/docs/reference/technologies/client/web)), backed by
+[`posthog-js`](https://posthog.com/docs/libraries/js).
+
+For the server, use [`@posthog/openfeature-node-provider`](../openfeature-node-provider).
+
+## Documentation
+
+Installation and usage instructions live in the PostHog docs, so they stay in one place and don't drift:
+
+**https://posthog.com/docs/feature-flags/installation/openfeature-js**
diff --git a/packages/openfeature-web-provider/jest.config.mjs b/packages/openfeature-web-provider/jest.config.mjs
new file mode 100644
index 0000000000..0446edfe76
--- /dev/null
+++ b/packages/openfeature-web-provider/jest.config.mjs
@@ -0,0 +1,12 @@
+import { createDefaultPreset } from 'ts-jest'
+
+const tsJestTransformCfg = createDefaultPreset().transform
+
+/** @type {import("jest").Config} **/
+export default {
+ testEnvironment: 'node',
+ testPathIgnorePatterns: ['/node_modules/', '/dist/'],
+ transform: {
+ ...tsJestTransformCfg,
+ },
+}
diff --git a/packages/openfeature-web-provider/package.json b/packages/openfeature-web-provider/package.json
new file mode 100644
index 0000000000..5f13046279
--- /dev/null
+++ b/packages/openfeature-web-provider/package.json
@@ -0,0 +1,74 @@
+{
+ "name": "@posthog/openfeature-web-provider",
+ "version": "0.0.0",
+ "bugs": {
+ "url": "https://github.com/PostHog/posthog-js/issues"
+ },
+ "description": "Official PostHog provider for the OpenFeature web SDK (browser), backed by posthog-js",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/PostHog/posthog-js.git",
+ "directory": "packages/openfeature-web-provider"
+ },
+ "homepage": "https://github.com/PostHog/posthog-js/tree/main/packages/openfeature-web-provider#readme",
+ "author": {
+ "name": "PostHog",
+ "email": "engineering@posthog.com",
+ "url": "https://posthog.com"
+ },
+ "license": "MIT",
+ "main": "./dist/index.js",
+ "module": "./dist/index.mjs",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "import": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.mjs"
+ },
+ "require": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ }
+ }
+ },
+ "scripts": {
+ "clean": "rimraf dist",
+ "lint": "eslint src",
+ "lint:fix": "eslint src --fix",
+ "build": "rslib build",
+ "dev": "rslib build --watch",
+ "test:unit": "jest",
+ "prepublishOnly": "pnpm lint && pnpm test:unit && pnpm build",
+ "package": "pnpm pack --out $PACKAGE_DEST/%s.tgz"
+ },
+ "engines": {
+ "node": "^20.20.0 || >=22.22.0"
+ },
+ "files": [
+ "dist/"
+ ],
+ "keywords": [
+ "posthog",
+ "openfeature",
+ "feature-flags",
+ "browser",
+ "web"
+ ],
+ "peerDependencies": {
+ "@openfeature/core": "^1.11.0",
+ "@openfeature/web-sdk": "^1.7.0",
+ "posthog-js": ">=1.336.0"
+ },
+ "devDependencies": {
+ "@openfeature/core": "^1.11.0",
+ "@openfeature/web-sdk": "^1.9.0",
+ "@posthog-tooling/tsconfig-base": "workspace:*",
+ "@rslib/core": "catalog:",
+ "@types/jest": "catalog:",
+ "@types/node": "^22.15.23",
+ "jest": "catalog:",
+ "posthog-js": "workspace:*",
+ "ts-jest": "catalog:"
+ }
+}
diff --git a/packages/openfeature-web-provider/rslib.config.ts b/packages/openfeature-web-provider/rslib.config.ts
new file mode 100644
index 0000000000..6a71533546
--- /dev/null
+++ b/packages/openfeature-web-provider/rslib.config.ts
@@ -0,0 +1,18 @@
+import { defineConfig } from '@rslib/core'
+
+export default defineConfig({
+ lib: [
+ { format: 'esm', syntax: 'es2023', dts: true, bundle: false },
+ { format: 'cjs', syntax: 'es2023', dts: true, bundle: false },
+ ],
+ output: {
+ // Web provider: targets the browser runtime.
+ target: 'web',
+ },
+ source: {
+ entry: {
+ index: ['src/**/*', '!src/__tests__/**/*', '!src/**/*.spec.ts'],
+ },
+ tsconfigPath: './tsconfig.build.json',
+ },
+})
diff --git a/packages/openfeature-web-provider/src/__tests__/provider.spec.ts b/packages/openfeature-web-provider/src/__tests__/provider.spec.ts
new file mode 100644
index 0000000000..916fd10764
--- /dev/null
+++ b/packages/openfeature-web-provider/src/__tests__/provider.spec.ts
@@ -0,0 +1,289 @@
+import { ErrorCode, OpenFeature, StandardResolutionReasons, type ResolutionDetails } from '@openfeature/web-sdk'
+import type { PostHog } from 'posthog-js'
+
+import { PostHogWebProvider } from '../provider'
+
+type FlagResult = {
+ key: string
+ enabled: boolean
+ variant?: string
+ payload?: unknown
+}
+
+function makeClient(
+ result: FlagResult | undefined,
+ { alreadyLoaded = false }: { alreadyLoaded?: boolean } = {}
+): {
+ client: PostHog
+ getFeatureFlagResult: jest.Mock
+ reloadFeatureFlags: jest.Mock
+ onFeatureFlags: jest.Mock
+ setPersonPropertiesForFlags: jest.Mock
+ group: jest.Mock
+} {
+ let callback: ((flags: string[], variants: Record) => void) | undefined
+
+ const onFeatureFlags = jest.fn((cb: (flags: string[], variants: Record) => void) => {
+ callback = cb
+ // posthog-js fires synchronously on subscribe when flags are already loaded.
+ if (alreadyLoaded) {
+ cb([], {})
+ }
+ return () => {
+ callback = undefined
+ }
+ })
+ // Simulate an async reload that notifies subscribers on completion.
+ const reloadFeatureFlags = jest.fn(() => {
+ queueMicrotask(() => callback?.([], {}))
+ })
+ const getFeatureFlagResult = jest.fn().mockReturnValue(result)
+ const setPersonPropertiesForFlags = jest.fn()
+ const group = jest.fn()
+
+ return {
+ client: {
+ getFeatureFlagResult,
+ reloadFeatureFlags,
+ onFeatureFlags,
+ setPersonPropertiesForFlags,
+ group,
+ } as unknown as PostHog,
+ getFeatureFlagResult,
+ reloadFeatureFlags,
+ onFeatureFlags,
+ setPersonPropertiesForFlags,
+ group,
+ }
+}
+
+type Resolve = (provider: PostHogWebProvider) => ResolutionDetails
+
+describe('PostHogWebProvider', () => {
+ it('identifies as a client provider', () => {
+ const { client } = makeClient(undefined)
+ const provider = new PostHogWebProvider(client)
+ expect(provider.metadata.name).toBe('PostHogWebProvider')
+ expect(provider.runsOn).toBe('client')
+ })
+
+ describe('synchronous resolution', () => {
+ it.each<[string, FlagResult, Resolve, Partial>]>([
+ [
+ 'boolean enabled → true / TARGETING_MATCH',
+ { key: 'flag', enabled: true },
+ (p) => p.resolveBooleanEvaluation('flag', false),
+ { value: true, reason: StandardResolutionReasons.TARGETING_MATCH },
+ ],
+ [
+ 'boolean disabled → false / DEFAULT',
+ { key: 'flag', enabled: false },
+ (p) => p.resolveBooleanEvaluation('flag', true),
+ { value: false, reason: StandardResolutionReasons.DEFAULT },
+ ],
+ [
+ 'string → multivariate variant',
+ { key: 'flag', enabled: true, variant: 'control' },
+ (p) => p.resolveStringEvaluation('flag', 'x'),
+ { value: 'control', variant: 'control' },
+ ],
+ [
+ 'number → parsed variant',
+ { key: 'flag', enabled: true, variant: '7' },
+ (p) => p.resolveNumberEvaluation('flag', 0),
+ { value: 7 },
+ ],
+ [
+ 'object → JSON payload',
+ { key: 'flag', enabled: true, payload: { a: 1 } },
+ (p) => p.resolveObjectEvaluation('flag', {}),
+ { value: { a: 1 } },
+ ],
+ [
+ 'disabled flag as string → default / DEFAULT',
+ { key: 'flag', enabled: false },
+ (p) => p.resolveStringEvaluation('flag', 'fallback'),
+ { value: 'fallback', reason: StandardResolutionReasons.DEFAULT },
+ ],
+ [
+ 'disabled flag as number → default / DEFAULT',
+ { key: 'flag', enabled: false },
+ (p) => p.resolveNumberEvaluation('flag', 42),
+ { value: 42, reason: StandardResolutionReasons.DEFAULT },
+ ],
+ [
+ 'disabled flag as object → default / DEFAULT',
+ { key: 'flag', enabled: false },
+ (p) => p.resolveObjectEvaluation('flag', { fallback: true }),
+ { value: { fallback: true }, reason: StandardResolutionReasons.DEFAULT },
+ ],
+ ])('resolves %s', (_name, result, resolve, expected) => {
+ const { client } = makeClient(result)
+ expect(resolve(new PostHogWebProvider(client))).toMatchObject(expected)
+ })
+
+ it.each<[string, FlagResult | undefined, Resolve, ErrorCode]>([
+ [
+ 'string from an enabled boolean flag (no variant)',
+ { key: 'flag', enabled: true },
+ (p) => p.resolveStringEvaluation('flag', 'x'),
+ ErrorCode.TYPE_MISMATCH,
+ ],
+ [
+ 'number from an enabled boolean flag (no variant)',
+ { key: 'flag', enabled: true },
+ (p) => p.resolveNumberEvaluation('flag', 0),
+ ErrorCode.TYPE_MISMATCH,
+ ],
+ [
+ 'number from a non-numeric variant',
+ { key: 'flag', enabled: true, variant: 'not-a-number' },
+ (p) => p.resolveNumberEvaluation('flag', 0),
+ ErrorCode.TYPE_MISMATCH,
+ ],
+ [
+ 'object from an enabled flag with no payload',
+ { key: 'flag', enabled: true, variant: 'x' },
+ (p) => p.resolveObjectEvaluation('flag', {}),
+ ErrorCode.TYPE_MISMATCH,
+ ],
+ [
+ 'object from a non-object payload',
+ { key: 'flag', enabled: true, variant: 'x', payload: 'not-an-object' },
+ (p) => p.resolveObjectEvaluation('flag', {}),
+ ErrorCode.TYPE_MISMATCH,
+ ],
+ [
+ 'missing flag (client returns undefined)',
+ undefined,
+ (p) => p.resolveBooleanEvaluation('missing', false),
+ ErrorCode.FLAG_NOT_FOUND,
+ ],
+ ])('throws on %s', (_name, result, resolve, code) => {
+ const { client } = makeClient(result)
+ expect(() => resolve(new PostHogWebProvider(client))).toThrow(expect.objectContaining({ code }))
+ })
+
+ it('passes send_event through to the client', () => {
+ const { client, getFeatureFlagResult } = makeClient({ key: 'flag', enabled: true })
+ new PostHogWebProvider(client, { sendFeatureFlagEvents: false }).resolveBooleanEvaluation('flag', false)
+ expect(getFeatureFlagResult).toHaveBeenCalledWith('flag', { send_event: false })
+ })
+
+ it('defaults send_event to true', () => {
+ const { client, getFeatureFlagResult } = makeClient({ key: 'flag', enabled: true })
+ new PostHogWebProvider(client).resolveBooleanEvaluation('flag', false)
+ expect(getFeatureFlagResult).toHaveBeenCalledWith('flag', { send_event: true })
+ })
+ })
+
+ describe('initialize / reconciliation', () => {
+ it('reloads flags on initialize and resolves once loaded', async () => {
+ const { client, reloadFeatureFlags } = makeClient({ key: 'flag', enabled: true })
+ const provider = new PostHogWebProvider(client)
+ await provider.initialize()
+ expect(reloadFeatureFlags).toHaveBeenCalledTimes(1)
+ })
+
+ it('resolves even when flags were already loaded (ignores the immediate fire)', async () => {
+ const { client, reloadFeatureFlags } = makeClient({ key: 'flag', enabled: true }, { alreadyLoaded: true })
+ const provider = new PostHogWebProvider(client)
+ await expect(provider.initialize()).resolves.toBeUndefined()
+ expect(reloadFeatureFlags).toHaveBeenCalledTimes(1)
+ })
+
+ it('resolves on timeout when the flags callback never fires', async () => {
+ // onFeatureFlags never invokes its callback and reloadFeatureFlags is a no-op,
+ // so only the reloadTimeoutMs safety net can settle initialize(). Fake timers
+ // keep this deterministic and instant rather than waiting on real wall-clock.
+ jest.useFakeTimers()
+ try {
+ const client = {
+ getFeatureFlagResult: jest.fn(),
+ reloadFeatureFlags: jest.fn(),
+ onFeatureFlags: jest.fn(() => () => {}),
+ setPersonPropertiesForFlags: jest.fn(),
+ group: jest.fn(),
+ } as unknown as PostHog
+ const provider = new PostHogWebProvider(client, { reloadTimeoutMs: 20 })
+ const pending = provider.initialize()
+ await jest.runAllTimersAsync()
+ await expect(pending).resolves.toBeUndefined()
+ } finally {
+ jest.useRealTimers()
+ }
+ })
+
+ it('reconciles person properties and groups from the context on change', async () => {
+ const { client, setPersonPropertiesForFlags, group } = makeClient({ key: 'flag', enabled: true })
+ const provider = new PostHogWebProvider(client)
+ await provider.onContextChange(
+ {},
+ {
+ targetingKey: 'user_1',
+ plan: 'enterprise',
+ groups: { organization: 'acme' },
+ groupProperties: { organization: { tier: 'gold' } },
+ }
+ )
+ // reload suppressed on the property write — a single trailing reload is awaited instead
+ expect(setPersonPropertiesForFlags).toHaveBeenCalledWith({ plan: 'enterprise' }, false)
+ expect(group).toHaveBeenCalledWith('organization', 'acme', { tier: 'gold' })
+ })
+
+ it('does not touch person properties when none are provided', async () => {
+ const { client, setPersonPropertiesForFlags } = makeClient({ key: 'flag', enabled: true })
+ const provider = new PostHogWebProvider(client)
+ await provider.onContextChange({}, { targetingKey: 'user_1' })
+ expect(setPersonPropertiesForFlags).not.toHaveBeenCalled()
+ })
+
+ it('calls group without properties when groupProperties are absent', async () => {
+ const { client, group } = makeClient({ key: 'flag', enabled: true })
+ const provider = new PostHogWebProvider(client)
+ await provider.onContextChange({}, { groups: { organization: 'acme' } })
+ expect(group).toHaveBeenCalledWith('organization', 'acme', undefined)
+ })
+
+ it('skips reconciliation when the context is deeply unchanged', async () => {
+ const { client, reloadFeatureFlags, setPersonPropertiesForFlags, group } = makeClient({
+ key: 'flag',
+ enabled: true,
+ })
+ const provider = new PostHogWebProvider(client)
+ const ctx = { targetingKey: 'user_1', plan: 'pro', groups: { organization: 'acme' } }
+ // Deeply-equal but distinct object instances (as a re-render would produce).
+ await provider.onContextChange(ctx, { ...ctx, groups: { ...ctx.groups } })
+ expect(reloadFeatureFlags).not.toHaveBeenCalled()
+ expect(setPersonPropertiesForFlags).not.toHaveBeenCalled()
+ expect(group).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('end-to-end through the OpenFeature client', () => {
+ afterEach(async () => {
+ await OpenFeature.close()
+ })
+
+ it('resolves values synchronously through the real client', async () => {
+ const { client } = makeClient({ key: 'flag', enabled: true, variant: 'control', payload: { a: 1 } })
+ await OpenFeature.setProviderAndWait(new PostHogWebProvider(client))
+ const ofClient = OpenFeature.getClient()
+
+ expect(ofClient.getBooleanValue('flag', false)).toBe(true)
+ expect(ofClient.getStringValue('flag', 'x')).toBe('control')
+ expect(ofClient.getObjectDetails('flag', {}).value).toEqual({ a: 1 })
+ })
+
+ it('returns the default value with an error code on a type mismatch', async () => {
+ const { client } = makeClient({ key: 'flag', enabled: true })
+ await OpenFeature.setProviderAndWait(new PostHogWebProvider(client))
+ const ofClient = OpenFeature.getClient()
+
+ const details = ofClient.getStringDetails('flag', 'fallback')
+ expect(details.value).toBe('fallback')
+ expect(details.errorCode).toBe(ErrorCode.TYPE_MISMATCH)
+ expect(details.reason).toBe(StandardResolutionReasons.ERROR)
+ })
+ })
+})
diff --git a/packages/openfeature-web-provider/src/index.ts b/packages/openfeature-web-provider/src/index.ts
new file mode 100644
index 0000000000..c44238d652
--- /dev/null
+++ b/packages/openfeature-web-provider/src/index.ts
@@ -0,0 +1,8 @@
+/**
+ * Official PostHog provider for the OpenFeature **web** SDK
+ * (`@openfeature/web-sdk`), backed by `posthog-js`.
+ *
+ * import { PostHogWebProvider } from '@posthog/openfeature-web-provider'
+ */
+export { PostHogWebProvider, type PostHogWebProviderOptions } from './provider'
+export { GROUPS_KEY, GROUP_PROPERTIES_KEY, type PostHogFlagResult, type SplitContext } from './mapping'
diff --git a/packages/openfeature-web-provider/src/mapping.ts b/packages/openfeature-web-provider/src/mapping.ts
new file mode 100644
index 0000000000..391cfc700a
--- /dev/null
+++ b/packages/openfeature-web-provider/src/mapping.ts
@@ -0,0 +1,164 @@
+/**
+ * Mapping between PostHog feature flag results and OpenFeature resolution
+ * details for the **web** provider.
+ *
+ * `posthog-js`'s `getFeatureFlagResult` returns `{ key, enabled, variant?,
+ * payload? }`, and this module turns that into the OpenFeature
+ * `ResolutionDetails` shape (and the reserved-attribute context split).
+ *
+ * Everything is imported from `@openfeature/core` (a peer dependency shared by
+ * the web SDK) so the error classes thrown here are the same identities the
+ * active SDK catches.
+ */
+import {
+ FlagNotFoundError,
+ StandardResolutionReasons,
+ TypeMismatchError,
+ type EvaluationContext,
+ type JsonValue,
+ type ResolutionDetails,
+ type ResolutionReason,
+} from '@openfeature/core'
+
+/**
+ * The minimal flag-result shape returned by `posthog-js`'s
+ * `getFeatureFlagResult`. The client's result structurally satisfies this, so
+ * the SDK does not need to be imported here.
+ */
+export interface PostHogFlagResult {
+ readonly key: string
+ readonly enabled: boolean
+ readonly variant?: string
+ readonly payload?: unknown
+}
+
+/**
+ * Reserved evaluation-context attribute keys. Every other attribute (besides
+ * the standard `targetingKey`) is forwarded to PostHog as a person property.
+ */
+export const GROUPS_KEY = 'groups'
+export const GROUP_PROPERTIES_KEY = 'groupProperties'
+
+/** PostHog evaluation inputs derived from an OpenFeature evaluation context. */
+export interface SplitContext {
+ personProperties: Record
+ groups: Record
+ groupProperties: Record>
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+/**
+ * Split an OpenFeature evaluation context into PostHog's evaluation inputs:
+ * - reserved `groups` -> PostHog `groups`
+ * - reserved `groupProperties` -> PostHog `groupProperties`
+ * - every other attribute -> PostHog `personProperties`
+ *
+ * `targetingKey` is consumed separately and never becomes a person property.
+ */
+export function splitContext(context?: EvaluationContext): SplitContext {
+ if (!context) {
+ return { personProperties: {}, groups: {}, groupProperties: {} }
+ }
+ const {
+ targetingKey: _targetingKey,
+ [GROUPS_KEY]: rawGroups,
+ [GROUP_PROPERTIES_KEY]: rawGroupProperties,
+ ...rest
+ } = context
+ return {
+ personProperties: rest,
+ groups: isRecord(rawGroups) ? (rawGroups as Record) : {},
+ groupProperties: isRecord(rawGroupProperties)
+ ? (rawGroupProperties as Record>)
+ : {},
+ }
+}
+
+/**
+ * Map PostHog's enabled state to an OpenFeature reason. PostHog's JS
+ * `FeatureFlagResult` carries no free-text reason (unlike the Python client), so
+ * an enabled flag means a targeting condition matched and a disabled one falls
+ * back to the default rollout.
+ */
+function reasonFor(result: PostHogFlagResult): ResolutionReason {
+ return result.enabled ? StandardResolutionReasons.TARGETING_MATCH : StandardResolutionReasons.DEFAULT
+}
+
+/**
+ * A `undefined` result means the flag does not exist (or was archived) —
+ * `getFeatureFlagResult` returns a populated result with `enabled: false` for a
+ * flag that exists but did not match. Surface the former as the OpenFeature
+ * `FLAG_NOT_FOUND` error so callers get their default value.
+ */
+function ensureResolved(result: PostHogFlagResult | undefined, flagKey: string): PostHogFlagResult {
+ if (result == null) {
+ throw new FlagNotFoundError(`Flag '${flagKey}' not found.`)
+ }
+ return result
+}
+
+export function resolveBooleanDetails(
+ result: PostHogFlagResult | undefined,
+ flagKey: string
+): ResolutionDetails {
+ const resolved = ensureResolved(result, flagKey)
+ return { value: resolved.enabled, variant: resolved.variant, reason: reasonFor(resolved) }
+}
+
+export function resolveStringDetails(
+ result: PostHogFlagResult | undefined,
+ flagKey: string,
+ defaultValue: string
+): ResolutionDetails {
+ const resolved = ensureResolved(result, flagKey)
+ if (resolved.variant === undefined) {
+ if (!resolved.enabled) {
+ // A disabled or unmatched flag has no variant. Resolve to the caller's
+ // default (per the OpenFeature spec) rather than throwing — a throw would
+ // set reason=ERROR and fire every registered error hook on an ordinary
+ // disabled-flag read.
+ return { value: defaultValue, reason: StandardResolutionReasons.DEFAULT }
+ }
+ // An enabled boolean flag has no string variant: a genuine type mismatch.
+ throw new TypeMismatchError(`Flag '${flagKey}' has no string variant (boolean flag).`)
+ }
+ return { value: resolved.variant, variant: resolved.variant, reason: reasonFor(resolved) }
+}
+
+export function resolveNumberDetails(
+ result: PostHogFlagResult | undefined,
+ flagKey: string,
+ defaultValue: number
+): ResolutionDetails {
+ const resolved = ensureResolved(result, flagKey)
+ if (resolved.variant === undefined) {
+ if (!resolved.enabled) {
+ return { value: defaultValue, reason: StandardResolutionReasons.DEFAULT }
+ }
+ throw new TypeMismatchError(`Flag '${flagKey}' has no variant to parse as a number.`)
+ }
+ const value = Number(resolved.variant)
+ if (!Number.isFinite(value)) {
+ throw new TypeMismatchError(`Flag '${flagKey}' variant '${resolved.variant}' is not a valid number.`)
+ }
+ return { value, variant: resolved.variant, reason: reasonFor(resolved) }
+}
+
+export function resolveObjectDetails(
+ result: PostHogFlagResult | undefined,
+ flagKey: string,
+ defaultValue: T
+): ResolutionDetails {
+ const resolved = ensureResolved(result, flagKey)
+ const payload = resolved.payload
+ if (typeof payload !== 'object' || payload === null) {
+ if (!resolved.enabled) {
+ return { value: defaultValue, reason: StandardResolutionReasons.DEFAULT }
+ }
+ throw new TypeMismatchError(`Flag '${flagKey}' has no object/JSON payload.`)
+ }
+ return { value: payload as T, variant: resolved.variant, reason: reasonFor(resolved) }
+}
diff --git a/packages/openfeature-web-provider/src/provider.ts b/packages/openfeature-web-provider/src/provider.ts
new file mode 100644
index 0000000000..38e44e9925
--- /dev/null
+++ b/packages/openfeature-web-provider/src/provider.ts
@@ -0,0 +1,188 @@
+/**
+ * Official PostHog provider for the OpenFeature **web** SDK
+ * (`@openfeature/web-sdk`), backed by a configured `posthog-js` client.
+ *
+ * The browser model is single-user and synchronous: `posthog-js` owns the user
+ * identity and keeps flags in memory, so evaluation is synchronous and the
+ * static evaluation context is reconciled into the SDK whenever it changes
+ * (the OpenFeature `onContextChange` contract).
+ */
+import type { EvaluationContext, JsonValue, Provider, ResolutionDetails } from '@openfeature/web-sdk'
+import type { PostHog } from 'posthog-js'
+
+import {
+ resolveBooleanDetails,
+ resolveNumberDetails,
+ resolveObjectDetails,
+ resolveStringDetails,
+ splitContext,
+} from './mapping'
+
+export interface PostHogWebProviderOptions {
+ /**
+ * Forwarded to `getFeatureFlagResult` to control `$feature_flag_called`
+ * capture. Defaults to `true` so PostHog flag analytics (and experiments)
+ * keep working.
+ */
+ sendFeatureFlagEvents?: boolean
+ /**
+ * Maximum time in milliseconds that `initialize()` / `onContextChange()` will
+ * wait for `posthog-js` to (re)load flags before resolving anyway. This is a
+ * safety net: if the SDK never fires its flags callback after a reload (e.g.
+ * `posthog.init()` was never called, or a network request fails silently),
+ * the OpenFeature client would otherwise stay stuck in NOT_READY forever. On
+ * timeout the provider becomes ready and serves whatever flags are cached.
+ * Defaults to 5000.
+ */
+ reloadTimeoutMs?: number
+}
+
+/**
+ * OpenFeature web provider backed by a configured `posthog-js` client.
+ *
+ * The caller owns the PostHog client lifecycle (init it, and manage the user
+ * identity via `posthog.identify()` as usual). This provider does **not** call
+ * `identify()` — `targetingKey` is therefore not used to switch users, since in
+ * the browser the host app owns identity. The rest of the evaluation context is
+ * reconciled into the SDK so it influences flag evaluation:
+ * - reserved `groups` -> `posthog.group(type, key)`
+ * - reserved `groupProperties` -> `posthog.group(type, key, properties)`
+ * - every other attribute -> `posthog.setPersonPropertiesForFlags(...)`
+ *
+ * Note that `group()` and `setPersonPropertiesForFlags()` persist on the client
+ * and may emit a `$groupidentify` event — the standard `posthog-js` behaviour.
+ *
+ * Flag-type mapping mirrors the server provider (boolean -> `enabled`, string
+ * -> `variant`, number -> parsed `variant`, object -> `payload`).
+ */
+export class PostHogWebProvider implements Provider {
+ public readonly runsOn = 'client'
+ public readonly metadata = { name: 'PostHogWebProvider' } as const
+
+ private readonly _client: PostHog
+ private readonly _sendFeatureFlagEvents: boolean
+ private readonly _reloadTimeoutMs: number
+
+ constructor(client: PostHog, options: PostHogWebProviderOptions = {}) {
+ this._client = client
+ this._sendFeatureFlagEvents = options.sendFeatureFlagEvents ?? true
+ this._reloadTimeoutMs = options.reloadTimeoutMs ?? 5000
+ }
+
+ async initialize(context?: EvaluationContext): Promise {
+ await this._reconcile(context)
+ }
+
+ async onContextChange(oldContext: EvaluationContext, newContext: EvaluationContext): Promise {
+ // The web SDK runs this handler on every setContext() call with no equality
+ // check, so a host that re-sets an equivalent context (e.g. a React
+ // integration passing a fresh object each render) would otherwise trigger a
+ // $groupidentify and a flag reload every time. Skip when nothing changed.
+ if (deepEqual(oldContext, newContext)) {
+ return
+ }
+ await this._reconcile(newContext)
+ }
+
+ resolveBooleanEvaluation(flagKey: string, _defaultValue: boolean): ResolutionDetails {
+ return resolveBooleanDetails(this._evaluate(flagKey), flagKey)
+ }
+
+ resolveStringEvaluation(flagKey: string, defaultValue: string): ResolutionDetails {
+ return resolveStringDetails(this._evaluate(flagKey), flagKey, defaultValue)
+ }
+
+ resolveNumberEvaluation(flagKey: string, defaultValue: number): ResolutionDetails {
+ return resolveNumberDetails(this._evaluate(flagKey), flagKey, defaultValue)
+ }
+
+ resolveObjectEvaluation(flagKey: string, defaultValue: T): ResolutionDetails {
+ return resolveObjectDetails(this._evaluate(flagKey), flagKey, defaultValue)
+ }
+
+ private _evaluate(flagKey: string): ReturnType {
+ return this._client.getFeatureFlagResult(flagKey, { send_event: this._sendFeatureFlagEvents })
+ }
+
+ /**
+ * Reconcile the evaluation context into `posthog-js` and wait for flags to
+ * (re)load. Person properties and groups are applied with their own reloads
+ * suppressed where possible; a single trailing `reloadFeatureFlags()` (which
+ * `posthog-js` debounces with any others) is then awaited so OpenFeature
+ * treats reconciliation as complete only once fresh flags are available.
+ */
+ private async _reconcile(context?: EvaluationContext): Promise {
+ const { personProperties, groups, groupProperties } = splitContext(context)
+
+ if (Object.keys(personProperties).length > 0) {
+ this._client.setPersonPropertiesForFlags(personProperties, false)
+ }
+ for (const [groupType, groupKey] of Object.entries(groups)) {
+ this._client.group(groupType, groupKey, groupProperties[groupType])
+ }
+
+ await this._reloadFlags()
+ }
+
+ private _reloadFlags(): Promise {
+ return new Promise((resolve) => {
+ let settled = false
+ let subscribed = false
+ // Cleanups are collected as they're created so `finish` never has to
+ // reference `unsubscribe`/`timer` before they're declared below.
+ const cleanups: Array<() => void> = []
+
+ const finish = (): void => {
+ if (settled) {
+ return
+ }
+ settled = true
+ cleanups.forEach((fn) => fn())
+ resolve()
+ }
+
+ // `onFeatureFlags` fires synchronously on subscribe if flags are already
+ // loaded; ignore that immediate call (it happens before `subscribed` is
+ // set) and resolve only on the callback that follows our reload request.
+ const unsubscribe = this._client.onFeatureFlags(() => {
+ if (subscribed) {
+ finish()
+ }
+ })
+ // Safety net: resolve anyway if posthog-js never delivers the callback
+ // (uninitialised SDK, silent network failure, ...) so the OpenFeature
+ // client can't get stuck NOT_READY forever.
+ const timer = setTimeout(finish, this._reloadTimeoutMs)
+ cleanups.push(unsubscribe, () => clearTimeout(timer))
+ subscribed = true
+ this._client.reloadFeatureFlags()
+ })
+ }
+}
+
+/**
+ * Deep structural equality for evaluation contexts (which are JSON-like, with
+ * possibly-nested `groupProperties`). Used to skip redundant reconciliation
+ * when the host re-sets an equivalent context.
+ */
+function deepEqual(a: unknown, b: unknown): boolean {
+ if (a === b) {
+ return true
+ }
+ if (a instanceof Date && b instanceof Date) {
+ return a.getTime() === b.getTime()
+ }
+ if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
+ return false
+ }
+ const aKeys = Object.keys(a as Record)
+ const bKeys = Object.keys(b as Record)
+ if (aKeys.length !== bKeys.length) {
+ return false
+ }
+ return aKeys.every(
+ (key) =>
+ Object.prototype.hasOwnProperty.call(b, key) &&
+ deepEqual((a as Record)[key], (b as Record)[key])
+ )
+}
diff --git a/packages/openfeature-web-provider/tsconfig.build.json b/packages/openfeature-web-provider/tsconfig.build.json
new file mode 100644
index 0000000000..fcd4c2a7c6
--- /dev/null
+++ b/packages/openfeature-web-provider/tsconfig.build.json
@@ -0,0 +1,8 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "rootDir": "./src"
+ },
+ "include": ["./src/**/*"],
+ "exclude": ["./src/__tests__/**/*", "./src/**/*.spec.ts"]
+}
diff --git a/packages/openfeature-web-provider/tsconfig.json b/packages/openfeature-web-provider/tsconfig.json
new file mode 100644
index 0000000000..996c948cc4
--- /dev/null
+++ b/packages/openfeature-web-provider/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "extends": "@posthog-tooling/tsconfig-base",
+ "compilerOptions": {
+ "rootDir": ".",
+ "outDir": "dist",
+ "declaration": true,
+ "declarationMap": true,
+ "skipLibCheck": true,
+ "lib": ["ES2023", "DOM"]
+ },
+ "include": ["./src/**/*", "rslib.config.ts"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4ddf6a6d6a..5fcd0fe037 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -787,6 +787,36 @@ importers:
specifier: ^4.1.2
version: 4.1.3(@parcel/watcher@2.5.1)(@types/node@20.19.9)(@vercel/functions@3.4.3(@aws-sdk/credential-provider-web-identity@3.972.29))(@vue/compiler-sfc@3.5.22)(db0@0.3.4)(eslint@9.37.0(jiti@2.6.1))(ioredis@5.8.1)(lightningcss@1.27.0)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.53.3)(terser@5.44.1)(typescript@5.8.2)(vite@7.3.1(@types/node@20.19.9)(jiti@2.6.1)(lightningcss@1.27.0)(terser@5.44.1)(yaml@2.8.0))(yaml@2.8.0)
+ packages/openfeature-web-provider:
+ devDependencies:
+ '@openfeature/core':
+ specifier: ^1.11.0
+ version: 1.11.0
+ '@openfeature/web-sdk':
+ specifier: ^1.9.0
+ version: 1.9.0(@openfeature/core@1.11.0)
+ '@posthog-tooling/tsconfig-base':
+ specifier: workspace:*
+ version: link:../../tooling/tsconfig-base
+ '@rslib/core':
+ specifier: 'catalog:'
+ version: 0.10.6(@microsoft/api-extractor@7.55.1(@types/node@22.16.5))(typescript@5.8.2)
+ '@types/jest':
+ specifier: 'catalog:'
+ version: 29.5.14
+ '@types/node':
+ specifier: ^22.15.23
+ version: 22.16.5
+ jest:
+ specifier: 'catalog:'
+ version: 29.7.0(@types/node@22.16.5)(node-notifier@8.0.2)(ts-node@10.9.2(@types/node@22.16.5)(typescript@5.8.2))
+ posthog-js:
+ specifier: workspace:*
+ version: link:../browser
+ ts-jest:
+ specifier: 'catalog:'
+ version: 29.4.11(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(esbuild@0.25.10)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.16.5)(node-notifier@8.0.2)(ts-node@10.9.2(@types/node@22.16.5)(typescript@5.8.2)))(typescript@5.8.2)
+
packages/plugin-utils:
dependencies:
cross-spawn:
@@ -4376,6 +4406,14 @@ packages:
peerDependencies:
zod: ^4.0.0
+ '@openfeature/core@1.11.0':
+ resolution: {integrity: sha512-P0u3/ht/oZCQT89fOed+laLk0kZR529a825cS02uPDglxXbE97irWYpDAeRGGVETIzKfuy+H2g8c3Ccv/tXJNQ==}
+
+ '@openfeature/web-sdk@1.9.0':
+ resolution: {integrity: sha512-FCrNfqvE/thHVfCNU0KKx1SD7rk+1wE2UaR5B5OPZl917QJv6AsKRwaI3N+SVgwXWI07GgtXP6hNlTVb49PGhg==}
+ peerDependencies:
+ '@openfeature/core': ^1.11.0
+
'@opentelemetry/api-logs@0.200.0':
resolution: {integrity: sha512-IKJBQxh91qJ+3ssRly5hYEJ8NDHu9oY/B1PXVSCWf7zytmYO9RNLB0Ox9XQ/fJ8m6gY6Q6NtBWlmXfaXt5Uc4Q==}
engines: {node: '>=8.0.0'}
@@ -20263,6 +20301,12 @@ snapshots:
- utf-8-validate
- ws
+ '@openfeature/core@1.11.0': {}
+
+ '@openfeature/web-sdk@1.9.0(@openfeature/core@1.11.0)':
+ dependencies:
+ '@openfeature/core': 1.11.0
+
'@opentelemetry/api-logs@0.200.0':
dependencies:
'@opentelemetry/api': 1.9.0