From 8e469c1d1bd028a001ff18bb2c6f0e7a198b2fb6 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Wed, 17 Jun 2026 17:53:22 -0700 Subject: [PATCH 1/6] refactor: migrate feature flags to LaunchDarkly --- .env.example | 7 +- bun.lock | 32 +++- package.json | 4 +- scripts/check-app-env.ts | 14 ++ src/core/modules/feature-flags/context.ts | 14 +- .../feature-flags/feature-flags.server.ts | 16 +- ...aunchdarkly-openfeature-provider.server.ts | 147 ++++++++++++++++++ .../openfeature-client.server.ts | 67 ++++++++ .../feature-flags/posthog-client.server.ts | 37 ----- .../feature-flags/posthog-provider.server.ts | 125 --------------- .../modules/feature-flags/provider.server.ts | 21 +++ src/lib/env.ts | 3 + tests/unit/feature-flags.test.ts | 90 ++++++++--- 13 files changed, 370 insertions(+), 207 deletions(-) create mode 100644 src/core/modules/feature-flags/launchdarkly-openfeature-provider.server.ts create mode 100644 src/core/modules/feature-flags/openfeature-client.server.ts delete mode 100644 src/core/modules/feature-flags/posthog-client.server.ts delete mode 100644 src/core/modules/feature-flags/posthog-provider.server.ts create mode 100644 src/core/modules/feature-flags/provider.server.ts diff --git a/.env.example b/.env.example index a5bcd077c..e6e4d9cad 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,11 @@ NEXT_PUBLIC_E2B_DOMAIN=e2b.dev # (Required if NEXT_PUBLIC_INCLUDE_REPORT_ISSUE=1) # PLAIN_API_KEY= +### LaunchDarkly feature flags +### Use the SDK key for the LaunchDarkly environment selected below. +# LAUNCHDARKLY_SDK_KEY= +# FEATURE_FLAG_ENVIRONMENT=staging + ### OTEL Configuration # OTEL_SERVICE_NAME= # OTEL_EXPORTER_OTLP_ENDPOINT= @@ -74,7 +79,7 @@ NEXT_PUBLIC_E2B_DOMAIN=e2b.dev ### OPTIONAL CLIENT ENVIRONMENT VARIABLES ### ================================= -### PostHog analytics and feature flag project key +### PostHog analytics project key # NEXT_PUBLIC_POSTHOG_KEY= ### PostHog source map upload (build-time only, used by @posthog/nextjs-config). diff --git a/bun.lock b/bun.lock index 19063898c..1236df63f 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,10 @@ "name": "@e2b/dashboard", "dependencies": { "@hookform/resolvers": "^5.2.2", + "@launchdarkly/node-server-sdk": "^9.11.2", + "@launchdarkly/openfeature-node-server": "^1.2.0", "@next/env": "^16.2.7", + "@openfeature/server-sdk": "^1.22.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/auto-instrumentations-node": "^0.77.0", "@opentelemetry/exporter-logs-otlp-http": "^0.203.0", @@ -76,7 +79,6 @@ "pathe": "^2.0.3", "pino": "^9.7.0", "posthog-js": "^1.268.1", - "posthog-node": "^5.38.0", "react": "19.2.4", "react-day-picker": "^9.9.0", "react-dom": "19.2.4", @@ -399,6 +401,14 @@ "@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="], + "@launchdarkly/js-sdk-common": ["@launchdarkly/js-sdk-common@2.25.1", "", {}, "sha512-erG2RbA8QQMKW+D9Y7Uahez1dU+TzmyTzv9qlB7b0Pr/DS2SmUg5H8pJbkLcRrSxoshczZdB1UqsuE3RAk7UYQ=="], + + "@launchdarkly/js-server-sdk-common": ["@launchdarkly/js-server-sdk-common@2.19.1", "", { "dependencies": { "@launchdarkly/js-sdk-common": "2.25.1", "semver": "7.5.4" } }, "sha512-3N1R62FF5qeBJdZbZk62ixGFG73kbwwqoHD00e0OygMB3hIRuV+spYbB3S8BXZ8VpnzKBame0OChC8lYrb/Zow=="], + + "@launchdarkly/node-server-sdk": ["@launchdarkly/node-server-sdk@9.11.2", "", { "dependencies": { "@launchdarkly/js-server-sdk-common": "2.19.1", "https-proxy-agent": "^7.0.6", "launchdarkly-eventsource": "2.2.0" } }, "sha512-+S0V8bkxSvD3THaLTw/AKG6OVFFUqcoKXDrV7JhuCk9xhvI/LsvgpdzHZdzJjlicW/U+DRn3rPvhzGdYPLyasg=="], + + "@launchdarkly/openfeature-node-server": ["@launchdarkly/openfeature-node-server@1.2.0", "", { "peerDependencies": { "@launchdarkly/node-server-sdk": "9.x", "@openfeature/server-sdk": "^1.16.0" } }, "sha512-VXnfQJfHsofhmHV4HvURLdMiX3bSwLumyeSPzuFn+5FdNKH2l44ijwXeSsotXpANMp+Gmz1LiQLz7ftgTSHKXg=="], + "@marsidev/react-turnstile": ["@marsidev/react-turnstile@1.5.3", "", { "peerDependencies": { "react": "^17.0.2 || ^18.0.0 || ^19.0", "react-dom": "^17.0.2 || ^18.0.0 || ^19.0" } }, "sha512-8Dij2jiNGNczq1U4EKpO4do2XepcTPxSMc2ZzvHndO+gcp68tvMULm27z2P99rGkdB89hc3452NZeu2Rti4g6A=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], @@ -421,6 +431,10 @@ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.7", "", { "os": "win32", "cpu": "x64" }, "sha512-J4WlM72NMk076Qsg0jTdK3SNXatlSdnjW7L7oNGLst1tAGjHrJh/FYi+pw9wyIjEtGRKDNzD0zuiY16oWYWVaw=="], + "@openfeature/core": ["@openfeature/core@1.11.0", "", {}, "sha512-P0u3/ht/oZCQT89fOed+laLk0kZR529a825cS02uPDglxXbE97irWYpDAeRGGVETIzKfuy+H2g8c3Ccv/tXJNQ=="], + + "@openfeature/server-sdk": ["@openfeature/server-sdk@1.22.0", "", { "peerDependencies": { "@openfeature/core": "^1.11.0" } }, "sha512-YBrf6SQkn0FNB/dRAtLEs41dvFMUE8CrQTwI+iLaMFUIqWlqGNJfGnulKSneEKS+2OgKTAC6DdmKcZ6tK7kBcg=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.203.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-9B9RU0H7Ya1Dx/Rkyc4stuBZSGVQF27WigitInx2QQoj6KUpEFYPKoWjdFTunJYxmXmh17HeBvbMa1EhGyPmqQ=="], @@ -687,7 +701,7 @@ "@posthog/plugin-utils": ["@posthog/plugin-utils@1.1.1", "", { "dependencies": { "cross-spawn": "^7.0.6" } }, "sha512-vCbaFeuwf9Pc0gI5bkCGvkOn2Bxru2KbZJtOa6loTJjanCNoMsjECEPijr7X5oln1IIg+VKnGiwV4tKY2b7NuQ=="], - "@posthog/types": ["@posthog/types@1.389.0", "", {}, "sha512-zoH1xlnOtpsez8LzAZ7edCzuAMbFHgOsZvwBICmH59YGvSNAmSsyI7eAh34ewYAglZT0sWrWRiBzglHPsTrCWw=="], + "@posthog/types": ["@posthog/types@1.386.3", "", {}, "sha512-LqJoiQi2eyWn7rCUgnn+D+F3Efp6+04o72bjSX6kWHx0nFaYNC/nJuAIRliDTY/X7GPIUAaHAcSjbMI/9wfX1Q=="], "@posthog/webpack-plugin": ["@posthog/webpack-plugin@1.5.22", "", { "dependencies": { "@posthog/cli": "~0.7.21", "@posthog/core": "1.32.3", "@posthog/plugin-utils": "1.1.1" }, "peerDependencies": { "webpack": "^5" } }, "sha512-vXlu73m90sAOkWzcdnIhJY9PnI5YXR+TfXe2Ssk4tUMpO/sMp1kOLc8S64dwUgEUISnE1hSJWQmmciLn3BKTXQ=="], @@ -1485,6 +1499,8 @@ "knip": ["knip@6.11.0", "", { "dependencies": { "fdir": "^6.5.0", "formatly": "^0.3.0", "get-tsconfig": "4.14.0", "jiti": "^2.6.0", "minimist": "^1.2.8", "oxc-parser": "^0.128.0", "oxc-resolver": "^11.19.1", "picomatch": "^4.0.4", "smol-toml": "^1.6.1", "strip-json-comments": "5.0.3", "tinyglobby": "^0.2.16", "unbash": "^3.0.0", "yaml": "^2.8.2", "zod": "^4.1.11" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-84PTlN8Q5smLpTbzs8smTVh8PMbTDXtw0tFksXq/m6auGFC/KSzJykKFmnYh3As38kiWDkoDBvdTTyKk5M1TAQ=="], + "launchdarkly-eventsource": ["launchdarkly-eventsource@2.2.0", "", {}, "sha512-u38fYlLSq/m6oFz0MS1/76Sj2xzlYhTKZ+sf/vju6PA86PMc6fPlY5k8CdU79edLXjNwsvIQTDvDNy3llDqB8A=="], + "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], @@ -1673,8 +1689,6 @@ "posthog-js": ["posthog-js@1.288.0", "", { "dependencies": { "@posthog/core": "1.5.1", "core-js": "^3.38.1", "fflate": "^0.4.8", "preact": "^10.19.3", "web-vitals": "^4.2.4" } }, "sha512-KOeF8PK/zxBuFB4b3FVkj5JxSWAfSOrfDVvWj5VrJNBGYqr8igDbAl10huFv9NB4/K9XeIWQ7AzPPGV4D3lbEA=="], - "posthog-node": ["posthog-node@5.38.0", "", { "dependencies": { "@posthog/core": "^1.33.0" }, "peerDependencies": { "rxjs": "^7.0.0" }, "optionalPeers": ["rxjs"] }, "sha512-xEojUWq7ajYfsMPZIexG9xtvhewpqrPWvL/qRhnm8W3KVZi6BlXHe29tjywZJglrDwEQpqdtYKbRXyg2zF9MWw=="], - "preact": ["preact@10.27.2", "", {}, "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg=="], "preact-render-to-string": ["preact-render-to-string@6.5.11", "", { "peerDependencies": { "preact": ">=10" } }, "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw=="], @@ -2045,6 +2059,8 @@ "@jest/types/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "@launchdarkly/js-server-sdk-common/semver": ["semver@7.5.4", "", { "dependencies": { "lru-cache": "^6.0.0" }, "bin": { "semver": "bin/semver.js" } }, "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA=="], + "@opentelemetry/auto-instrumentations-node/@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], "@opentelemetry/auto-instrumentations-node/@opentelemetry/sdk-node": ["@opentelemetry/sdk-node@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/configuration": "0.219.0", "@opentelemetry/context-async-hooks": "2.8.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/exporter-logs-otlp-grpc": "0.219.0", "@opentelemetry/exporter-logs-otlp-http": "0.219.0", "@opentelemetry/exporter-logs-otlp-proto": "0.219.0", "@opentelemetry/exporter-metrics-otlp-grpc": "0.219.0", "@opentelemetry/exporter-metrics-otlp-http": "0.219.0", "@opentelemetry/exporter-metrics-otlp-proto": "0.219.0", "@opentelemetry/exporter-prometheus": "0.219.0", "@opentelemetry/exporter-trace-otlp-grpc": "0.219.0", "@opentelemetry/exporter-trace-otlp-http": "0.219.0", "@opentelemetry/exporter-trace-otlp-proto": "0.219.0", "@opentelemetry/exporter-zipkin": "2.8.0", "@opentelemetry/instrumentation": "0.219.0", "@opentelemetry/otlp-exporter-base": "0.219.0", "@opentelemetry/otlp-grpc-exporter-base": "0.219.0", "@opentelemetry/propagator-b3": "2.8.0", "@opentelemetry/propagator-jaeger": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0", "@opentelemetry/sdk-trace-node": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-NWLpWLEb8gV3+JBHYoIrktbM385wyHpRJoh3J/4Q52d4PR+AlPMNGJT3DzBUrDSUEVbKAXoHR+EDAPxtiNcj8g=="], @@ -2439,8 +2455,6 @@ "posthog-js/fflate": ["fflate@0.4.8", "", {}, "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA=="], - "posthog-node/@posthog/core": ["@posthog/core@1.35.1", "", { "dependencies": { "@posthog/types": "^1.389.0" } }, "sha512-2a9JgJgR+Ow8lrUQHVZYXH9EgXskYDlpbgNW6UvtsVxp8pEEFD8PxjZMnzq73Dx3NhAwNUrnVpb8KeZzHAtoSA=="], - "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "react-intl/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -2517,6 +2531,8 @@ "@jest/types/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "@launchdarkly/js-server-sdk-common/semver/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + "@opentelemetry/auto-instrumentations-node/@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/core@2.7.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw=="], "@opentelemetry/auto-instrumentations-node/@opentelemetry/sdk-node/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.219.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg=="], @@ -2627,8 +2643,6 @@ "@ory/elements-react/@radix-ui/react-dropdown-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.2", "", { "dependencies": { "@radix-ui/react-slot": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-uHa+l/lKfxuDD2zjN/0peM/RhhSmRjr5YWdk/37EnSv1nJ88uvG85DPexSm8HdFQROd2VdERJ6ynXbkCFi+APw=="], - "@posthog/webpack-plugin/@posthog/core/@posthog/types": ["@posthog/types@1.386.3", "", {}, "sha512-LqJoiQi2eyWn7rCUgnn+D+F3Efp6+04o72bjSX6kWHx0nFaYNC/nJuAIRliDTY/X7GPIUAaHAcSjbMI/9wfX1Q=="], - "@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-checkbox/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -2729,6 +2743,8 @@ "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "@launchdarkly/js-server-sdk-common/semver/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "@opentelemetry/auto-instrumentations-node/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-grpc/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], "@opentelemetry/auto-instrumentations-node/@opentelemetry/sdk-node/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.219.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.219.0", "@opentelemetry/core": "2.8.0", "@opentelemetry/resources": "2.8.0", "@opentelemetry/sdk-logs": "0.219.0", "@opentelemetry/sdk-metrics": "2.8.0", "@opentelemetry/sdk-trace-base": "2.8.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ=="], diff --git a/package.json b/package.json index 15f4ee75c..d32e737e8 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,10 @@ }, "dependencies": { "@hookform/resolvers": "^5.2.2", + "@launchdarkly/node-server-sdk": "^9.11.2", + "@launchdarkly/openfeature-node-server": "^1.2.0", "@next/env": "^16.2.7", + "@openfeature/server-sdk": "^1.22.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/auto-instrumentations-node": "^0.77.0", "@opentelemetry/exporter-logs-otlp-http": "^0.203.0", @@ -117,7 +120,6 @@ "pathe": "^2.0.3", "pino": "^9.7.0", "posthog-js": "^1.268.1", - "posthog-node": "^5.38.0", "react": "19.2.4", "react-day-picker": "^9.9.0", "react-dom": "19.2.4", diff --git a/scripts/check-app-env.ts b/scripts/check-app-env.ts index 2f977369f..53cce9453 100644 --- a/scripts/check-app-env.ts +++ b/scripts/check-app-env.ts @@ -34,5 +34,19 @@ const schema = appEnvSchema path: ['PLAIN_API_KEY'], } ) + .refine( + (data) => { + if (data.LAUNCHDARKLY_SDK_KEY) { + return !!data.FEATURE_FLAG_ENVIRONMENT + } + + return true + }, + { + message: + 'LAUNCHDARKLY_SDK_KEY is set, but FEATURE_FLAG_ENVIRONMENT is missing', + path: ['FEATURE_FLAG_ENVIRONMENT'], + } + ) validateEnv(schema) diff --git a/src/core/modules/feature-flags/context.ts b/src/core/modules/feature-flags/context.ts index ab1ff6604..e7329d806 100644 --- a/src/core/modules/feature-flags/context.ts +++ b/src/core/modules/feature-flags/context.ts @@ -8,16 +8,20 @@ export type FeatureFlagContext = { slug?: string name?: string } - environment?: 'production' | 'preview' | 'development' + environment?: 'production' | 'staging' } export function getFeatureFlagEnvironment(): FeatureFlagContext['environment'] { + switch (process.env.FEATURE_FLAG_ENVIRONMENT) { + case 'production': + case 'staging': + return process.env.FEATURE_FLAG_ENVIRONMENT + } + switch (process.env.VERCEL_ENV) { case 'production': - case 'preview': - case 'development': - return process.env.VERCEL_ENV + return 'production' default: - return 'development' + return 'staging' } } diff --git a/src/core/modules/feature-flags/feature-flags.server.ts b/src/core/modules/feature-flags/feature-flags.server.ts index dce64e8ca..c125e3f43 100644 --- a/src/core/modules/feature-flags/feature-flags.server.ts +++ b/src/core/modules/feature-flags/feature-flags.server.ts @@ -12,11 +12,11 @@ import type { PayloadFeatureFlagDefinition, } from '@/core/modules/feature-flags/types' import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' -import { - type FeatureFlagProvider, - type FeatureFlagSnapshot, - postHogFeatureFlagProvider, -} from './posthog-provider.server' +import { launchDarklyOpenFeatureProvider } from './launchdarkly-openfeature-provider.server' +import type { + FeatureFlagProvider, + FeatureFlagSnapshot, +} from './provider.server' export type FeatureFlagService = { isEnabled( @@ -89,12 +89,12 @@ function getEvaluatedValue( } export function createFeatureFlagService( - provider: FeatureFlagProvider = postHogFeatureFlagProvider + provider: FeatureFlagProvider = launchDarklyOpenFeatureProvider ): FeatureFlagService { return { async isEnabled(flagId, context) { const flag = FEATURE_FLAGS[flagId] - const snapshot = await provider.evaluate(context, [flag.key]) + const snapshot = await provider.evaluate(context, [flag]) const value = snapshot.getFlagValue(flag.key) if (typeof value === 'boolean') { @@ -112,7 +112,7 @@ export function createFeatureFlagService( ][] const snapshot = await provider.evaluate( context, - flags.map(([, flag]) => flag.key) + flags.map(([, flag]) => flag) ) return flags.map(([id, flag]) => ({ diff --git a/src/core/modules/feature-flags/launchdarkly-openfeature-provider.server.ts b/src/core/modules/feature-flags/launchdarkly-openfeature-provider.server.ts new file mode 100644 index 000000000..2387cfa25 --- /dev/null +++ b/src/core/modules/feature-flags/launchdarkly-openfeature-provider.server.ts @@ -0,0 +1,147 @@ +import 'server-only' + +import type { + EvaluationContext, + EvaluationDetails, + JsonValue, +} from '@openfeature/server-sdk' +import { + type FeatureFlagContext, + getFeatureFlagEnvironment, +} from '@/core/modules/feature-flags/context' +import type { FeatureFlagDefinition } from '@/core/modules/feature-flags/types' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' +import { getOpenFeatureServerClient } from './openfeature-client.server' +import { + type FeatureFlagProvider, + type FeatureFlagSnapshot, + unavailableSnapshot, +} from './provider.server' + +function definedStringAttributes( + attributes: Record +) { + return Object.fromEntries( + Object.entries(attributes).filter((entry): entry is [string, string] => { + const [, value] = entry + return typeof value === 'string' && value.length > 0 + }) + ) +} + +export function createOpenFeatureEvaluationContext( + context: FeatureFlagContext +): EvaluationContext { + const environment = context.environment ?? getFeatureFlagEnvironment() + const user = { + targetingKey: context.user.id, + ...definedStringAttributes({ + email: context.user.email, + environment, + }), + } + + if (!context.team) { + return { + kind: 'user', + ...user, + } + } + + return { + kind: 'multi', + user, + team: { + targetingKey: context.team.id, + ...definedStringAttributes({ + name: context.team.name, + slug: context.team.slug, + environment, + }), + }, + } +} + +function logEvaluationError( + flag: FeatureFlagDefinition, + details: EvaluationDetails +) { + if (details.reason !== 'ERROR') { + return + } + + l.warn( + { + key: 'feature_flags:launchdarkly_evaluation_error', + context: { + flagKey: flag.key, + errorCode: details.errorCode, + errorMessage: details.errorMessage, + }, + }, + 'LaunchDarkly feature flag evaluation returned an error result' + ) +} + +function toJsonValue(value: unknown): JsonValue { + return value as JsonValue +} + +export const launchDarklyOpenFeatureProvider: FeatureFlagProvider = { + async evaluate(context, flags) { + const client = await getOpenFeatureServerClient() + + if (!client) { + return unavailableSnapshot + } + + const evaluationContext = createOpenFeatureEvaluationContext(context) + const flagValues = new Map() + const payloads = new Map() + + try { + await Promise.all( + flags.map(async (flag) => { + if (flag.kind === 'boolean') { + const details = await client.getBooleanDetails( + flag.key, + flag.defaultValue, + evaluationContext + ) + logEvaluationError(flag, details) + flagValues.set(flag.key, details.value) + return + } + + const details = await client.getObjectDetails( + flag.key, + toJsonValue(flag.defaultValue), + evaluationContext + ) + logEvaluationError(flag, details) + payloads.set(flag.key, details.value) + }) + ) + + return { + getFlagValue(key) { + return flagValues.get(key) + }, + getPayload(key) { + return payloads.get(key) + }, + } satisfies FeatureFlagSnapshot + } catch (error) { + l.warn( + { + key: 'feature_flags:launchdarkly_evaluation_failed', + context: { flagKeys: flags.map((flag) => flag.key) }, + error: serializeErrorForLog(error), + }, + 'LaunchDarkly feature flag evaluation failed' + ) + + return unavailableSnapshot + } + }, +} diff --git a/src/core/modules/feature-flags/openfeature-client.server.ts b/src/core/modules/feature-flags/openfeature-client.server.ts new file mode 100644 index 000000000..67b98b672 --- /dev/null +++ b/src/core/modules/feature-flags/openfeature-client.server.ts @@ -0,0 +1,67 @@ +import 'server-only' + +import { LaunchDarklyProvider } from '@launchdarkly/openfeature-node-server' +import { type Client, OpenFeature } from '@openfeature/server-sdk' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' + +const OPENFEATURE_DOMAIN = 'dashboard-feature-flags' +const LAUNCHDARKLY_INIT_TIMEOUT_SECONDS = 3 + +let openFeatureClientPromise: Promise | undefined +let loggedMissingLaunchDarklyKey = false + +function getLaunchDarklySdkKey() { + const key = process.env.LAUNCHDARKLY_SDK_KEY?.trim() + return key || null +} + +async function initializeOpenFeatureClient(sdkKey: string) { + try { + const provider = new LaunchDarklyProvider( + sdkKey, + { + sendEvents: false, + }, + LAUNCHDARKLY_INIT_TIMEOUT_SECONDS + ) + + await OpenFeature.setProviderAndWait(OPENFEATURE_DOMAIN, provider) + + return OpenFeature.getClient(OPENFEATURE_DOMAIN) + } catch (error) { + openFeatureClientPromise = undefined + l.warn( + { + key: 'feature_flags:launchdarkly_initialization_failed', + error: serializeErrorForLog(error), + }, + 'LaunchDarkly OpenFeature provider initialization failed' + ) + + return null + } +} + +export function getOpenFeatureServerClient() { + if (openFeatureClientPromise !== undefined) { + return openFeatureClientPromise + } + + const sdkKey = getLaunchDarklySdkKey() + + if (!sdkKey) { + if (!loggedMissingLaunchDarklyKey) { + loggedMissingLaunchDarklyKey = true + l.warn( + { key: 'feature_flags:launchdarkly_unconfigured' }, + 'LaunchDarkly feature flags are disabled because LAUNCHDARKLY_SDK_KEY is missing' + ) + } + + openFeatureClientPromise = Promise.resolve(null) + return openFeatureClientPromise + } + + openFeatureClientPromise = initializeOpenFeatureClient(sdkKey) + return openFeatureClientPromise +} diff --git a/src/core/modules/feature-flags/posthog-client.server.ts b/src/core/modules/feature-flags/posthog-client.server.ts deleted file mode 100644 index 0d0ef01c1..000000000 --- a/src/core/modules/feature-flags/posthog-client.server.ts +++ /dev/null @@ -1,37 +0,0 @@ -import 'server-only' - -import { PostHog } from 'posthog-node' - -const POSTHOG_HOST = 'https://us.i.posthog.com' - -let postHogClient: PostHog | null | undefined - -function getPostHogProjectKey() { - const key = process.env.NEXT_PUBLIC_POSTHOG_KEY?.trim() - return key || null -} - -export function getPostHogServerClient() { - if (postHogClient !== undefined) { - return postHogClient - } - - const projectKey = getPostHogProjectKey() - - if (!projectKey) { - postHogClient = null - return postHogClient - } - - postHogClient = new PostHog(projectKey, { - host: POSTHOG_HOST, - flushAt: 1, - flushInterval: 0, - requestTimeout: 3000, - featureFlagsRequestTimeoutMs: 1500, - disableGeoip: true, - featureFlagsLogWarnings: false, - }) - - return postHogClient -} diff --git a/src/core/modules/feature-flags/posthog-provider.server.ts b/src/core/modules/feature-flags/posthog-provider.server.ts deleted file mode 100644 index 7604e7e76..000000000 --- a/src/core/modules/feature-flags/posthog-provider.server.ts +++ /dev/null @@ -1,125 +0,0 @@ -import 'server-only' - -import type { AllFlagsOptions } from 'posthog-node' -import type { FeatureFlagContext } from '@/core/modules/feature-flags/context' -import { getFeatureFlagEnvironment } from '@/core/modules/feature-flags/context' -import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' -import { getPostHogServerClient } from './posthog-client.server' - -export type FeatureFlagValue = boolean | string | undefined - -export type FeatureFlagSnapshot = { - getFlagValue(key: string): FeatureFlagValue - getPayload(key: string): unknown -} - -export type FeatureFlagProvider = { - evaluate( - context: FeatureFlagContext, - flagKeys: readonly string[] - ): Promise -} - -const unavailableSnapshot: FeatureFlagSnapshot = { - getFlagValue: () => undefined, - getPayload: () => undefined, -} - -let loggedMissingPostHogKey = false - -function stringProperties(properties: Record) { - return Object.fromEntries( - Object.entries(properties).filter((entry): entry is [string, string] => { - const [, value] = entry - return typeof value === 'string' && value.length > 0 - }) - ) -} - -export function createPostHogFlagEvaluationOptions( - context: FeatureFlagContext, - flagKeys: readonly string[] -): AllFlagsOptions { - const environment = context.environment ?? getFeatureFlagEnvironment() - const personProperties = stringProperties({ - email: context.user.email, - environment, - }) - - const options: AllFlagsOptions = { - flagKeys: [...flagKeys], - disableGeoip: true, - } - - if (Object.keys(personProperties).length > 0) { - options.personProperties = personProperties - } - - if (context.team) { - options.groups = { - team: context.team.id, - } - - const teamProperties = stringProperties({ - name: context.team.name, - slug: context.team.slug, - environment, - }) - - if (Object.keys(teamProperties).length > 0) { - options.groupProperties = { - team: teamProperties, - } - } - } - - return options -} - -export const postHogFeatureFlagProvider: FeatureFlagProvider = { - async evaluate(context, flagKeys) { - const client = getPostHogServerClient() - - if (!client) { - if (!loggedMissingPostHogKey) { - loggedMissingPostHogKey = true - l.warn( - { key: 'feature_flags:posthog_unconfigured' }, - 'PostHog feature flags are disabled because NEXT_PUBLIC_POSTHOG_KEY is missing' - ) - } - - return unavailableSnapshot - } - - try { - const evaluatedFlags = await client.evaluateFlags( - context.user.id, - createPostHogFlagEvaluationOptions(context, flagKeys) - ) - - return { - getFlagValue(key) { - const value = evaluatedFlags.getFlag(key) - return typeof value === 'boolean' || typeof value === 'string' - ? value - : undefined - }, - getPayload(key) { - return evaluatedFlags.getFlagPayload(key) - }, - } - } catch (error) { - l.warn( - { - key: 'feature_flags:posthog_evaluation_failed', - context: { flagKeys }, - error: serializeErrorForLog(error), - }, - 'PostHog feature flag evaluation failed' - ) - - return unavailableSnapshot - } - }, -} diff --git a/src/core/modules/feature-flags/provider.server.ts b/src/core/modules/feature-flags/provider.server.ts new file mode 100644 index 000000000..5197f7a15 --- /dev/null +++ b/src/core/modules/feature-flags/provider.server.ts @@ -0,0 +1,21 @@ +import 'server-only' + +import type { FeatureFlagContext } from '@/core/modules/feature-flags/context' +import type { FeatureFlagDefinition } from '@/core/modules/feature-flags/types' + +export type FeatureFlagSnapshot = { + getFlagValue(key: string): unknown + getPayload(key: string): unknown +} + +export type FeatureFlagProvider = { + evaluate( + context: FeatureFlagContext, + flags: readonly FeatureFlagDefinition[] + ): Promise +} + +export const unavailableSnapshot: FeatureFlagSnapshot = { + getFlagValue: () => undefined, + getPayload: () => undefined, +} diff --git a/src/lib/env.ts b/src/lib/env.ts index 552ddb974..963c0da3a 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -10,6 +10,9 @@ export const serverSchema = z.object({ POSTHOG_API_KEY: z.string().min(1).optional(), POSTHOG_PROJECT_ID: z.string().min(1).optional(), + LAUNCHDARKLY_SDK_KEY: z.string().min(1).optional(), + FEATURE_FLAG_ENVIRONMENT: z.enum(['production', 'staging']).optional(), + AUTH_SECRET: z.string().min(1).optional(), AUTH_TRUST_HOST: z.string().optional(), // Prefix for Auth.js cookie names to disambiguate multiple local diff --git a/tests/unit/feature-flags.test.ts b/tests/unit/feature-flags.test.ts index 7d914d390..5b3012325 100644 --- a/tests/unit/feature-flags.test.ts +++ b/tests/unit/feature-flags.test.ts @@ -1,7 +1,11 @@ -import { describe, expect, it, vi } from 'vitest' -import type { FeatureFlagContext } from '@/core/modules/feature-flags/context' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + type FeatureFlagContext, + getFeatureFlagEnvironment, +} from '@/core/modules/feature-flags/context' +import { FEATURE_FLAGS } from '@/core/modules/feature-flags/definitions' import { createFeatureFlagService } from '@/core/modules/feature-flags/feature-flags.server' -import { createPostHogFlagEvaluationOptions } from '@/core/modules/feature-flags/posthog-provider.server' +import { createOpenFeatureEvaluationContext } from '@/core/modules/feature-flags/launchdarkly-openfeature-provider.server' const context = { user: { @@ -13,9 +17,13 @@ const context = { slug: 'team-slug', name: 'Team Name', }, - environment: 'preview', + environment: 'staging', } satisfies FeatureFlagContext +afterEach(() => { + vi.unstubAllEnvs() +}) + describe('createFeatureFlagService', () => { it('evaluates boolean flags through the provider', async () => { const provider = { @@ -31,10 +39,12 @@ describe('createFeatureFlagService', () => { ) expect(result).toBe(true) - expect(provider.evaluate).toHaveBeenCalledWith(context, ['is_admin']) + expect(provider.evaluate).toHaveBeenCalledWith(context, [ + FEATURE_FLAGS.isAdmin, + ]) }) - it('falls back to the flag default when PostHog has no value', async () => { + it('falls back to the flag default when the provider has no value', async () => { const provider = { evaluate: vi.fn().mockResolvedValue({ getFlagValue: vi.fn().mockReturnValue(undefined), @@ -61,7 +71,9 @@ describe('createFeatureFlagService', () => { const result = await createFeatureFlagService(provider).evaluateAll(context) expect(provider.evaluate).toHaveBeenCalledTimes(1) - expect(provider.evaluate).toHaveBeenCalledWith(context, ['is_admin']) + expect(provider.evaluate).toHaveBeenCalledWith(context, [ + FEATURE_FLAGS.isAdmin, + ]) expect(result).toEqual([ { id: 'isAdmin', @@ -75,25 +87,59 @@ describe('createFeatureFlagService', () => { }) }) -describe('createPostHogFlagEvaluationOptions', () => { - it('maps dashboard users and teams to PostHog identity inputs', () => { - expect(createPostHogFlagEvaluationOptions(context, ['is_admin'])).toEqual({ - flagKeys: ['is_admin'], - disableGeoip: true, - personProperties: { +describe('createOpenFeatureEvaluationContext', () => { + it('maps dashboard users and teams to a LaunchDarkly multi-context', () => { + expect(createOpenFeatureEvaluationContext(context)).toEqual({ + kind: 'multi', + user: { + targetingKey: 'user-id', email: 'user@example.com', - environment: 'preview', + environment: 'staging', }, - groups: { - team: 'team-id', + team: { + targetingKey: 'team-id', + name: 'Team Name', + slug: 'team-slug', + environment: 'staging', }, - groupProperties: { - team: { - name: 'Team Name', - slug: 'team-slug', - environment: 'preview', + }) + }) + + it('maps dashboard users without teams to a user context', () => { + expect( + createOpenFeatureEvaluationContext({ + user: { + id: 'user-id', + email: 'user@example.com', }, - }, + }) + ).toEqual({ + kind: 'user', + targetingKey: 'user-id', + email: 'user@example.com', + environment: 'staging', }) }) }) + +describe('getFeatureFlagEnvironment', () => { + it('uses the explicit feature flag environment', () => { + vi.stubEnv('FEATURE_FLAG_ENVIRONMENT', 'production') + + expect(getFeatureFlagEnvironment()).toBe('production') + }) + + it('maps production Vercel deployments to production', () => { + vi.stubEnv('FEATURE_FLAG_ENVIRONMENT', '') + vi.stubEnv('VERCEL_ENV', 'production') + + expect(getFeatureFlagEnvironment()).toBe('production') + }) + + it('maps non-production deployments to staging', () => { + vi.stubEnv('FEATURE_FLAG_ENVIRONMENT', '') + vi.stubEnv('VERCEL_ENV', 'preview') + + expect(getFeatureFlagEnvironment()).toBe('staging') + }) +}) From 0e26b790f244cb1317eb0c194804b88db63b7ceb Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Wed, 17 Jun 2026 18:00:39 -0700 Subject: [PATCH 2/6] chore: remove PostHog build vars from app env schema --- src/lib/env.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/lib/env.ts b/src/lib/env.ts index 963c0da3a..834eb9ac1 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -7,9 +7,6 @@ export const serverSchema = z.object({ BILLING_API_URL: z.url().optional(), PLAIN_API_KEY: z.string().min(1).optional(), - POSTHOG_API_KEY: z.string().min(1).optional(), - POSTHOG_PROJECT_ID: z.string().min(1).optional(), - LAUNCHDARKLY_SDK_KEY: z.string().min(1).optional(), FEATURE_FLAG_ENVIRONMENT: z.enum(['production', 'staging']).optional(), From 49437d54c7eb8a3c43aa23fbaff73d9e03276aec Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Wed, 17 Jun 2026 18:03:00 -0700 Subject: [PATCH 3/6] chore: keep PostHog build vars in env schema --- src/lib/env.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/lib/env.ts b/src/lib/env.ts index 834eb9ac1..963c0da3a 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -7,6 +7,9 @@ export const serverSchema = z.object({ BILLING_API_URL: z.url().optional(), PLAIN_API_KEY: z.string().min(1).optional(), + POSTHOG_API_KEY: z.string().min(1).optional(), + POSTHOG_PROJECT_ID: z.string().min(1).optional(), + LAUNCHDARKLY_SDK_KEY: z.string().min(1).optional(), FEATURE_FLAG_ENVIRONMENT: z.enum(['production', 'staging']).optional(), From f9197d887bb4397f23652bca5d2b7a9dc38f873f Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Wed, 17 Jun 2026 18:04:20 -0700 Subject: [PATCH 4/6] fix: cache failed LaunchDarkly initialization --- src/core/modules/feature-flags/openfeature-client.server.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/modules/feature-flags/openfeature-client.server.ts b/src/core/modules/feature-flags/openfeature-client.server.ts index 67b98b672..a5803a7a4 100644 --- a/src/core/modules/feature-flags/openfeature-client.server.ts +++ b/src/core/modules/feature-flags/openfeature-client.server.ts @@ -29,7 +29,6 @@ async function initializeOpenFeatureClient(sdkKey: string) { return OpenFeature.getClient(OPENFEATURE_DOMAIN) } catch (error) { - openFeatureClientPromise = undefined l.warn( { key: 'feature_flags:launchdarkly_initialization_failed', From a78e9d57af5689005d99288225772d9a1156cbd0 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Wed, 17 Jun 2026 18:14:33 -0700 Subject: [PATCH 5/6] docs: document feature flag workflow --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 9f0e916ea..701471d69 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,16 @@ We use a layered testing strategy with Vitest and Playwright. For details on tes ### Environment Variables See [`src/lib/env.ts`](./src/lib/env.ts) for all required environment variables and their validation schemas. +### Feature Flags +Feature flags are evaluated server-side with LaunchDarkly via OpenFeature and hydrated into the dashboard client. To add a flag: + +1. Create it in LaunchDarkly `staging` and `production` with the same key. +2. Add it to [`src/core/modules/feature-flags/definitions.ts`](./src/core/modules/feature-flags/definitions.ts) with a safe default value. +3. Use `featureFlags.isEnabled(...)` on the server or `useFeatureFlag(...)` inside dashboard client components. +4. Target users or teams in LaunchDarkly using the `user` and `team` contexts. + +Set `LAUNCHDARKLY_SDK_KEY` and `FEATURE_FLAG_ENVIRONMENT=staging|production` for environments that should use LaunchDarkly. + ## Production Deployment This application is optimized for deployment on Vercel: From 61fec0d6910948fc0346d3a5e133534cac26c29d Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Wed, 17 Jun 2026 18:17:36 -0700 Subject: [PATCH 6/6] fix: retry LaunchDarkly initialization after failure --- .../openfeature-client.server.ts | 19 ++++- tests/unit/openfeature-client.test.ts | 78 +++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 tests/unit/openfeature-client.test.ts diff --git a/src/core/modules/feature-flags/openfeature-client.server.ts b/src/core/modules/feature-flags/openfeature-client.server.ts index a5803a7a4..66e757f7a 100644 --- a/src/core/modules/feature-flags/openfeature-client.server.ts +++ b/src/core/modules/feature-flags/openfeature-client.server.ts @@ -6,8 +6,10 @@ import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' const OPENFEATURE_DOMAIN = 'dashboard-feature-flags' const LAUNCHDARKLY_INIT_TIMEOUT_SECONDS = 3 +const LAUNCHDARKLY_INIT_RETRY_INTERVAL_MS = 60_000 let openFeatureClientPromise: Promise | undefined +let launchDarklyInitializationFailedAt: number | undefined let loggedMissingLaunchDarklyKey = false function getLaunchDarklySdkKey() { @@ -26,9 +28,11 @@ async function initializeOpenFeatureClient(sdkKey: string) { ) await OpenFeature.setProviderAndWait(OPENFEATURE_DOMAIN, provider) + launchDarklyInitializationFailedAt = undefined return OpenFeature.getClient(OPENFEATURE_DOMAIN) } catch (error) { + launchDarklyInitializationFailedAt = Date.now() l.warn( { key: 'feature_flags:launchdarkly_initialization_failed', @@ -41,9 +45,22 @@ async function initializeOpenFeatureClient(sdkKey: string) { } } +function shouldRetryFailedInitialization() { + return ( + launchDarklyInitializationFailedAt !== undefined && + Date.now() - launchDarklyInitializationFailedAt >= + LAUNCHDARKLY_INIT_RETRY_INTERVAL_MS + ) +} + export function getOpenFeatureServerClient() { if (openFeatureClientPromise !== undefined) { - return openFeatureClientPromise + if (shouldRetryFailedInitialization()) { + openFeatureClientPromise = undefined + launchDarklyInitializationFailedAt = undefined + } else { + return openFeatureClientPromise + } } const sdkKey = getLaunchDarklySdkKey() diff --git a/tests/unit/openfeature-client.test.ts b/tests/unit/openfeature-client.test.ts new file mode 100644 index 000000000..c09ebd40a --- /dev/null +++ b/tests/unit/openfeature-client.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +async function loadOpenFeatureClient({ + getClient = vi.fn(() => ({ client: true })), + setProviderAndWait, +}: { + getClient?: ReturnType + setProviderAndWait: ReturnType +}) { + vi.resetModules() + vi.doMock('@launchdarkly/openfeature-node-server', () => ({ + LaunchDarklyProvider: vi.fn(), + })) + vi.doMock('@openfeature/server-sdk', () => ({ + OpenFeature: { + getClient, + setProviderAndWait, + }, + })) + vi.doMock('@/core/shared/clients/logger/logger', () => ({ + l: { warn: vi.fn() }, + serializeErrorForLog: vi.fn((error) => error), + })) + + return import('@/core/modules/feature-flags/openfeature-client.server') +} + +afterEach(() => { + vi.resetModules() + vi.unstubAllEnvs() + vi.useRealTimers() +}) + +describe('getOpenFeatureServerClient', () => { + it('caches failed LaunchDarkly initialization until the retry interval elapses', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + vi.stubEnv('LAUNCHDARKLY_SDK_KEY', 'sdk-key') + const setProviderAndWait = vi.fn().mockRejectedValue(new Error('timeout')) + const { getOpenFeatureServerClient } = await loadOpenFeatureClient({ + setProviderAndWait, + }) + + await expect(getOpenFeatureServerClient()).resolves.toBeNull() + await expect(getOpenFeatureServerClient()).resolves.toBeNull() + + expect(setProviderAndWait).toHaveBeenCalledTimes(1) + + vi.setSystemTime(60_000) + await expect(getOpenFeatureServerClient()).resolves.toBeNull() + + expect(setProviderAndWait).toHaveBeenCalledTimes(2) + }) + + it('recovers when LaunchDarkly initialization succeeds after the retry interval', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + vi.stubEnv('LAUNCHDARKLY_SDK_KEY', 'sdk-key') + const client = { client: true } + const getClient = vi.fn(() => client) + const setProviderAndWait = vi + .fn() + .mockRejectedValueOnce(new Error('timeout')) + .mockResolvedValueOnce(undefined) + const { getOpenFeatureServerClient } = await loadOpenFeatureClient({ + getClient, + setProviderAndWait, + }) + + await expect(getOpenFeatureServerClient()).resolves.toBeNull() + + vi.setSystemTime(60_000) + + await expect(getOpenFeatureServerClient()).resolves.toBe(client) + expect(setProviderAndWait).toHaveBeenCalledTimes(2) + expect(getClient).toHaveBeenCalledTimes(1) + }) +})