diff --git a/.github/ISSUE_TEMPLATE/package-telemetry-browser-telemetry--bug_report.md b/.github/ISSUE_TEMPLATE/package-telemetry-browser-telemetry--bug_report.md new file mode 100644 index 0000000000..53c4b692d4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/package-telemetry-browser-telemetry--bug_report.md @@ -0,0 +1,36 @@ +--- +name: '@launchdarkly/browser-telemetry Bug Report' +about: Create a report to help us improve +title: '' +labels: 'package: telemetry/browser-telemerty, bug' +assignees: '' +--- + +**Is this a support request?** +This issue tracker is maintained by LaunchDarkly SDK developers and is intended for feedback on the code in this library. If you're not sure whether the problem you are having is specifically related to this library, or to the LaunchDarkly service overall, it may be more appropriate to contact the LaunchDarkly support team; they can help to investigate the problem and will consult the SDK team if necessary. You can submit a support request by going [here](https://support.launchdarkly.com/) and clicking "submit a request", or by emailing support@launchdarkly.com. + +Note that issues filed on this issue tracker are publicly accessible. Do not provide any private account information on your issues. If your problem is specific to your account, you should submit a support request as described above. + +**Describe the bug** +A clear and concise description of what the bug is. + +**To reproduce** +Steps to reproduce the behavior. + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Logs** +If applicable, add any log output related to your problem. + +**SDK version** +The version of this SDK that you are using. + +**Language version, developer tools** +For instance, Go 1.11 or Ruby 2.5.3. If you are using a language that requires a separate compiler, such as C, please include the name and version of the compiler too. + +**OS/platform** +For instance, Ubuntu 16.04, Windows 10, or Android 4.0.3. If your code is running in a browser, please also include the browser type and version. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/package-telemetry-browser-telemetry-feature_request.md b/.github/ISSUE_TEMPLATE/package-telemetry-browser-telemetry-feature_request.md new file mode 100644 index 0000000000..08327687d6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/package-telemetry-browser-telemetry-feature_request.md @@ -0,0 +1,19 @@ +--- +name: '@launchdarkly/browser-telemetry Feature Request' +about: Create a report to help us improve +title: '' +labels: 'package: telemetry/browser-telemetry, feature' +assignees: '' +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I would love to see the SDK [...does something new...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context about the feature request here. diff --git a/.github/workflows/fastly.yml b/.github/workflows/fastly.yml new file mode 100644 index 0000000000..51939efb3f --- /dev/null +++ b/.github/workflows/fastly.yml @@ -0,0 +1,24 @@ +name: sdk/fastly + +on: + push: + branches: [main, 'feat/**'] + paths-ignore: + - '**.md' #Do not need to run CI for markdown changes. + pull_request: + branches: [main, 'feat/**'] + paths-ignore: + - '**.md' + +jobs: + build-test-fastly: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + - id: shared + name: Shared CI Steps + uses: ./actions/ci + with: + workspace_name: '@launchdarkly/fastly-server-sdk' + workspace_path: packages/sdk/fastly diff --git a/.github/workflows/manual-publish-docs.yml b/.github/workflows/manual-publish-docs.yml index 907b270001..54d03fa408 100644 --- a/.github/workflows/manual-publish-docs.yml +++ b/.github/workflows/manual-publish-docs.yml @@ -12,6 +12,7 @@ on: - packages/shared/sdk-server-edge - packages/shared/akamai-edgeworker-sdk - packages/sdk/cloudflare + - packages/sdk/fastly - packages/sdk/server-node - packages/sdk/vercel - packages/sdk/akamai-base @@ -21,6 +22,7 @@ on: - packages/telemetry/node-server-sdk-otel - packages/sdk/browser - packages/sdk/server-ai + - packages/telemetry/browser-telemetry name: Publish Documentation jobs: build-publish: diff --git a/.github/workflows/manual-publish.yml b/.github/workflows/manual-publish.yml index c1ca69e3b3..9e1bd4a909 100644 --- a/.github/workflows/manual-publish.yml +++ b/.github/workflows/manual-publish.yml @@ -22,6 +22,7 @@ on: - packages/shared/sdk-server-edge - packages/shared/akamai-edgeworker-sdk - packages/sdk/cloudflare + - packages/sdk/fastly - packages/sdk/react-native - packages/sdk/server-node - packages/sdk/react-universal @@ -34,6 +35,7 @@ on: - packages/tooling/jest - packages/sdk/browser - packages/sdk/server-ai + - packages/telemetry/browser-telemetry prerelease: description: 'Is this a prerelease. If so, then the latest tag will not be updated in npm.' type: boolean diff --git a/.github/workflows/react-native-detox.yml b/.github/workflows/react-native-detox.yml index 1b491d0de3..c1affa2643 100644 --- a/.github/workflows/react-native-detox.yml +++ b/.github/workflows/react-native-detox.yml @@ -16,10 +16,10 @@ on: - 'packages/shared/common/**' - 'packages/shared/sdk-client/**' - 'packages/sdk/react-native/**' - + - '.github/**' jobs: detox-android: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 permissions: id-token: write contents: read diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 33b7a55f1f..736cc5c85a 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -14,6 +14,7 @@ jobs: package-sdk-server-edge-released: ${{ steps.release.outputs['packages/shared/sdk-server-edge--release_created'] }} package-akamai-edgeworker-sdk-released: ${{ steps.release.outputs['packages/shared/akamai-edgeworker-sdk--release_created'] }} package-cloudflare-released: ${{ steps.release.outputs['packages/sdk/cloudflare--release_created'] }} + package-fastly-released: ${{ steps.release.outputs['packages/sdk/fastly--release_created'] }} package-react-native-released: ${{ steps.release.outputs['packages/sdk/react-native--release_created'] }} package-server-node-released: ${{ steps.release.outputs['packages/sdk/server-node--release_created'] }} package-vercel-released: ${{ steps.release.outputs['packages/sdk/vercel--release_created'] }} @@ -26,6 +27,7 @@ jobs: package-react-universal-release: ${{ steps.release.outputs['packages/sdk/react-universal--release_created'] }} package-browser-released: ${{ steps.release.outputs['packages/sdk/browser--release_created'] }} package-server-ai-released: ${{ steps.release.outputs['packages/sdk/server-ai--release_created'] }} + package-browser-telemetry-released: ${{ steps.release.outputs['packages/telemetry/browser-telemetry--release_created'] }} steps: - uses: googleapis/release-please-action@v4 id: release @@ -152,6 +154,26 @@ jobs: workspace_path: packages/sdk/cloudflare aws_assume_role: ${{ vars.AWS_ROLE_ARN }} + release-fastly: + runs-on: ubuntu-latest + needs: ['release-please', 'release-sdk-server'] + permissions: + id-token: write + contents: write + if: ${{ always() && !failure() && !cancelled() && needs.release-please.outputs.package-fastly-released == 'true'}} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20.x + registry-url: 'https://registry.npmjs.org' + - id: release-fastly + name: Full release of packages/sdk/fastly + uses: ./actions/full-release + with: + workspace_path: packages/sdk/fastly + aws_assume_role: ${{ vars.AWS_ROLE_ARN }} + release-react-native: runs-on: ubuntu-latest needs: ['release-please', 'release-sdk-client'] @@ -344,8 +366,7 @@ jobs: permissions: id-token: write contents: write - # HACK: jest is not ready for release yet. - if: false #${{ needs.release-please.outputs.package-tooling-jest-release == 'true' }} + if: ${{ needs.release-please.outputs.package-tooling-jest-release == 'true' }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -398,3 +419,23 @@ jobs: with: workspace_path: packages/sdk/server-ai aws_assume_role: ${{ vars.AWS_ROLE_ARN }} + + release-browser-telemetry: + runs-on: ubuntu-latest + needs: ['release-please', 'release-browser'] + permissions: + id-token: write + contents: write + if: ${{ always() && !failure() && !cancelled() && needs.release-please.outputs.package-browser-telemetry-released == 'true' }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20.x + registry-url: 'https://registry.npmjs.org' + - id: release-browser-telemetry + name: Full release of packages/telemetry/browser-telemetry + uses: ./actions/full-release + with: + workspace_path: packages/telemetry/browser-telemetry + aws_assume_role: ${{ vars.AWS_ROLE_ARN }} diff --git a/.github/workflows/server-node.yml b/.github/workflows/server-node.yml index e216e60add..9932876a6b 100644 --- a/.github/workflows/server-node.yml +++ b/.github/workflows/server-node.yml @@ -31,8 +31,12 @@ jobs: with: workspace_name: '@launchdarkly/node-server-sdk' workspace_path: packages/sdk/server-node + - name: Install contract test service dependencies + run: yarn workspace node-server-sdk-contract-tests install --no-immutable + - name: Build the test service + run: yarn contract-test-service-build - name: Launch the test service in the background - run: yarn run contract-test-service 2>&1 & + run: yarn contract-test-service 2>&1 & - uses: launchdarkly/gh-actions/actions/contract-tests@contract-tests-v1.0.2 with: test_service_port: 8000 diff --git a/.release-please-manifest.json b/.release-please-manifest.json index ee285fe746..8f31543e86 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,18 +1,21 @@ { - "packages/shared/common": "2.12.0", - "packages/shared/sdk-server": "2.10.0", - "packages/sdk/server-node": "9.7.2", - "packages/sdk/cloudflare": "2.6.3", - "packages/shared/sdk-server-edge": "2.5.2", - "packages/sdk/vercel": "1.3.21", - "packages/sdk/akamai-base": "2.1.20", - "packages/sdk/akamai-edgekv": "1.3.0", - "packages/shared/akamai-edgeworker-sdk": "1.3.2", - "packages/store/node-server-sdk-dynamodb": "6.2.2", - "packages/store/node-server-sdk-redis": "4.2.2", - "packages/shared/sdk-client": "1.12.1", - "packages/sdk/react-native": "10.9.3", - "packages/telemetry/node-server-sdk-otel": "1.1.2", - "packages/sdk/browser": "0.3.3", - "packages/sdk/server-ai": "0.7.0" + "packages/shared/common": "2.16.0", + "packages/shared/sdk-server": "2.15.0", + "packages/sdk/server-node": "9.9.0", + "packages/sdk/cloudflare": "2.7.4", + "packages/sdk/fastly": "0.1.5", + "packages/shared/sdk-server-edge": "2.6.4", + "packages/sdk/vercel": "1.3.28", + "packages/sdk/akamai-base": "3.0.5", + "packages/sdk/akamai-edgekv": "1.4.7", + "packages/shared/akamai-edgeworker-sdk": "2.0.5", + "packages/store/node-server-sdk-dynamodb": "6.2.9", + "packages/store/node-server-sdk-redis": "4.2.9", + "packages/shared/sdk-client": "1.12.6", + "packages/sdk/react-native": "10.9.9", + "packages/telemetry/node-server-sdk-otel": "1.2.0", + "packages/sdk/browser": "0.5.3", + "packages/sdk/server-ai": "0.9.6", + "packages/telemetry/browser-telemetry": "1.0.6", + "packages/tooling/jest": "0.1.4" } diff --git a/.sdk_metadata.json b/.sdk_metadata.json index fe6500fadc..0788fd67f2 100644 --- a/.sdk_metadata.json +++ b/.sdk_metadata.json @@ -8,7 +8,8 @@ "languages": ["JavaScript", "TypeScript"], "releases": { "tag-prefix": "akamai-server-base-sdk-" - } + }, + "userAgents": ["AkamaiEdgeSDK"] }, "akamai-edgekv": { "name": "Akamai SDK for EdgeKV", @@ -17,7 +18,8 @@ "languages": ["JavaScript", "TypeScript"], "releases": { "tag-prefix": "akamai-server-edgekv-sdk-" - } + }, + "userAgents": ["AkamaiEdgeSDK"] }, "cloudflare": { "name": "Cloudflare SDK", @@ -26,7 +28,18 @@ "languages": ["JavaScript", "TypeScript"], "releases": { "tag-prefix": "cloudflare-server-sdk-" - } + }, + "userAgents": ["CloudflareEdgeSDK"] + }, + "fastly": { + "name": "Fastly SDK", + "type": "edge", + "path": "packages/sdk/fastly", + "languages": ["JavaScript", "TypeScript"], + "releases": { + "tag-prefix": "fastly-server-sdk-" + }, + "userAgents": ["FastlyEdgeSDK"] }, "react-native": { "name": "React Native SDK", @@ -35,7 +48,9 @@ "languages": ["JavaScript", "TypeScript"], "releases": { "tag-prefix": "react-native-client-sdk-" - } + }, + "userAgents": ["ReactNativeClient"], + "wrapperNames": ["react-native-client"] }, "node-server": { "name": "Node.js Server SDK", @@ -44,7 +59,8 @@ "languages": ["JavaScript", "TypeScript"], "releases": { "tag-prefix": "node-server-sdk-" - } + }, + "userAgents": ["NodeJSClient"] }, "vercel": { "name": "Vercel Edge SDK", @@ -53,7 +69,8 @@ "languages": ["JavaScript", "TypeScript"], "releases": { "tag-prefix": "vercel-server-sdk-" - } + }, + "userAgents": ["VercelEdgeSDK"] } } } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dbcde575e3..fdf84e631f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,6 +35,14 @@ To build all projects, from the root directory: yarn build ``` +To build a single project and all of its dependencies: +``` +yarn workspaces foreach -pR --topological-dev --from '@launchdarkly/js-client-sdk' run build +``` +Replacing `@launchdarkly/js-client-sdk` with the specific package you want to build. + +Running `yarn build` in an individual package will build that package, but will not rebuild any dependencies. + ### Testing Unit tests should be implemented in a `__tests__` folder in the root of the package. The directory structure inside of `__tests__` should mirror that of the source directory. @@ -47,3 +55,198 @@ The SDK contract test suite will run the Node.js Server version of the SDK. ```bash yarn run contract-tests ``` + +Tests cases should be written using `it` and should read as a sentence including the `it`: +```TypeScript +it('does not load flags prior to start', async () => {/* test code */} +``` + +Describe blocks should be used for common setup for a series of tests: +```TypeScript +describe('given a mock filesystem and memory feature store', { /* tests */}) +``` + +These then combined to create an understandable test name: +`given a mock filesystem and memory feature store > it does not load flags prior to start` + +## Development Guidelines + +These are a series of recommendations for developing code in this repository. Not all existing code will comply +with these guidelines, but new code should unless there are specific reasons not to. + +While we develop code in TypeScript we generally want to aim for the compiled JavaScript to not be substantially different than if it had been written as JavaScript. + +### Avoid using TypeScript enumerations. Instead use unions of strings. + +Bad: +```TypeScript +export enum ValueType { + Bool = 'bool', + Int = 'int', + Double = 'double', + String = 'string', + Any = 'any', +} +``` + +Good: +```TypeScript +export type ValueType = 'bool' | 'int' | 'double' | 'string' | 'any' +``` + +While we are using TypeScript not all consumers of our code will be. Using a TypeScript enum from JavaScript is not very ergonomic. +Additionally the code size associated with enums is going to be larger. The enum actually generates code, where the union provides type safety, but has no impact on the generated code. + +### Prefer interfaces over classes when reasonable, especially if publicly exposed. + +Bad: +```TypeScript +class MyData { + public mutable: string; + constructor(private readonly value: string private readonly another: string); +} +``` + +Good: +```TypeScript +interface MyData { + readonly value: string; + readonly another: string; + mutable: string; +} + +function createMyData(value: string, another: string, mutable: string): MyData { + return { + value, + another, + mutable + } +} +``` + +There are several potential problems using classes and some of them may be unexpected. + +Classes produce JavaScript code while interfaces only represent contracts and don't exist in the generated JavaScript. In client-side applications keeping size to a minimum is very important. + +The minification of associated functions is also another major difference. Functions that are not exported from the package can have their names minified. Methods that are part of a class are generally not minified. + +A number of classes are present in the SDKs that cannot be removed without a major version. In order to reduce the size of these classes we have added support for minification of any member that starts with an underscore. + +Another thing to remember is that the private and readonly only really affect TypeScript. Using JavaScript you can still access and mutate. Our minification of private members starting with an underscore also helps prevent unintentional usage from JavaScript. + +## Repo Organization + +The below diagram shows the dependency relationships between the various packages in the LaunchDarkly JavaScript SDKs monorepo. + +Packages on the left are shared packages that should be technology agnostic. As you progress to the right, packages are more specific to a technology or implementation. + +```mermaid +flowchart LR + %% Shared packages + common[shared/common] + sdk-client[shared/sdk-client] + sdk-server[shared/sdk-server] + sdk-server-edge[shared/sdk-server-edge] + akamai-edgeworker[shared/akamai-edgeworker-sdk] + + %% SDK packages + server-node[sdk/server-node] + cloudflare[sdk/cloudflare] + fastly[sdk/fastly] + react-native[sdk/react-native] + browser[sdk/browser] + vercel[sdk/vercel] + akamai-base[sdk/akamai-base] + akamai-edgekv[sdk/akamai-edgekv] + server-ai[sdk/server-ai] + react-universal[sdk/react-universal] + svelte[sdk/svelte] + + %% Store packages + redis[store/node-server-sdk-redis] + dynamodb[store/node-server-sdk-dynamodb] + + %% Telemetry packages + node-otel[telemetry/node-server-sdk-otel] + browser-telemetry[telemetry/browser-telemetry] + + %% Tooling packages + jest[tooling/jest] + + %% Dependencies between shared packages + common --> sdk-client + common --> sdk-server + common --> sdk-server-edge + common --> akamai-edgeworker + sdk-server --> sdk-server-edge + + %% Dependencies for SDK packages + sdk-client --> browser + sdk-client --> react-native + sdk-client --> react-universal + sdk-client --> svelte + + sdk-server --> server-node + sdk-server --> server-ai + + sdk-server-edge --> cloudflare + sdk-server-edge --> fastly + sdk-server-edge --> vercel + + akamai-edgeworker --> akamai-base + akamai-edgeworker --> akamai-edgekv + + %% Dependencies for store packages + sdk-server --> redis + sdk-server --> dynamodb + + %% Dependencies for telemetry packages + server-node --> node-otel + browser --> browser-telemetry + + %% Dependencies for tooling packages + react-native -.-> jest + + class common,sdk-client,sdk-server,sdk-server-edge,akamai-edgeworker shared + class server-node,cloudflare,fastly,react-native,browser,vercel,akamai-base,akamai-edgekv,server-ai,react-universal,svelte sdk + class redis,dynamodb store + class node-otel,browser-telemetry telemetry + class jest tooling +``` + +There are a number of categories of packages in the monorepo: + +1. **Shared packages** (pink): Common code shared across multiple SDKs + - `shared/common`:Common code which is intended for use by any SDK type. + - `shared/sdk-client`: Common code for client-side SDKs. + - `shared/sdk-server`: Common code for server-side SDKs + - `shared/sdk-server-edge`: Common code for edge SDKs + - `shared/akamai-edgeworker-sdk`: Common code for Akamai edge worker SDKs + +2. **SDK packages** (blue): Actual SDK implementations for different platforms + - Browser, React Native, Server Node, Cloudflare, Fastly, Vercel, Akamai, etc. + +3. **Store packages** (green): Persistent storage implementations + - Redis and DynamoDB implementations + +4. **Telemetry packages** (purple): Monitoring and telemetry integrations + - OpenTelemetry for Node.js and browser telemetry + +5. **Tooling packages** (red): Development and testing tools + - Jest testing utilities + +### Depenencies + +In general dependencies should be avoided unless they are absolutely necessary. For each dependency several considerations should be made: + +- Size: Each dependency will increase the final bundle size. This is important for the browser SDK where bundle size impacts load time and costs. +- Maintenance: Dependencies can become unmaintained or deprecated over time. This requires that we routinely check dependencies and find replacements if they have become unmaintained. +- Security: Dependencies can have a large surface area of security vulnerabilities. It is rare we would fully utlize a dependency, and as such vulnerabilities often do not apply, but we still need to patch and provide updates. +- Compatibility: Each dependency will need to be compatible with the supported platforms it impacts. At any given point it isn't certain the potential platforms that could be impacted over time. For instance something may work in every current edge environment, but may not be compatible with a future edge environment. +- Conflicts: Dependencies can introduce conflicts with other dependencies in application code. + +We only want to use dependencies for functionality with highly-specified behavior, which are widely used, maintained, and have a large community. + +It is most important to avoid dependencies in the common package, sdk-client, and sdk-server packages. + +Individual SDK packages have a known execution environment and vetting a dependency is less complex, and some SDKs have specific platform dependencies that must be included. For example the edge SDKs must use their provider's edge store, and may require dependencies to do so. \ No newline at end of file diff --git a/README.md b/README.md index 42eddb660a..2881b7f8ee 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ This includes shared libraries, used by SDKs and other tools, as well as SDKs. | [@launchdarkly/akamai-server-base-sdk](packages/sdk/akamai-base/README.md) | [![NPM][sdk-akamai-base-npm-badge]][sdk-akamai-base-npm-link] | [Akamai Base][package-sdk-akamai-base-issues] | [![Actions Status][sdk-akamai-base-ci-badge]][sdk-akamai-base-ci] | | [@launchdarkly/akamai-server-edgekv-sdk](packages/sdk/akamai-edgekv/README.md) | [![NPM][sdk-akamai-edgekv-npm-badge]][sdk-akamai-edgekv-npm-link] | [Akamai EdgeKV][package-sdk-akamai-edgekv-issues] | [![Actions Status][sdk-akamai-edgekv-ci-badge]][sdk-akamai-edgekv-ci] | | [@launchdarkly/cloudflare-server-sdk](packages/sdk/cloudflare/README.md) | [![NPM][sdk-cloudflare-npm-badge]][sdk-cloudflare-npm-link] | [Cloudflare][package-sdk-cloudflare-issues] | [![Actions Status][sdk-cloudflare-ci-badge]][sdk-cloudflare-ci] | +| [@launchdarkly/fastly-server-sdk](packages/sdk/fastly/README.md) | [![NPM][sdk-fastly-npm-badge]][sdk-fastly-npm-link] | [Fastly][package-sdk-fastly-issues] | [![Actions Status][sdk-fastly-ci-badge]][sdk-fastly-ci] | | [@launchdarkly/node-server-sdk](packages/sdk/server-node/README.md) | [![NPM][sdk-server-node-npm-badge]][sdk-server-node-npm-link] | [Node.js Server][package-sdk-server-node-issues] | [![Actions Status][sdk-server-node-ci-badge]][sdk-server-node-ci] | | [@launchdarkly/vercel-server-sdk](packages/sdk/vercel/README.md) | [![NPM][sdk-vercel-npm-badge]][sdk-vercel-npm-link] | [Vercel][package-sdk-vercel-issues] | [![Actions Status][sdk-vercel-ci-badge]][sdk-vercel-ci] | | [@launchdarkly/react-native-client-sdk](packages/sdk/react-native/README.md) | [![NPM][sdk-react-native-npm-badge]][sdk-react-native-npm-link] | [React-Native][package-sdk-react-native-issues] | [![Actions Status][sdk-react-native-ci-badge]][sdk-react-native-ci] | @@ -28,9 +29,10 @@ This includes shared libraries, used by SDKs and other tools, as well as SDKs. | [@launchdarkly/node-server-sdk-redis](packages/store/node-server-sdk-redis/README.md) | [![NPM][node-redis-npm-badge]][node-redis-npm-link] | [Node Redis][node-redis-issues] | [![Actions Status][node-redis-ci-badge]][node-redis-ci] | | [@launchdarkly/node-server-sdk-dynamodb](packages/store/node-server-sdk-dynamodb/README.md) | [![NPM][node-dynamodb-npm-badge]][node-dynamodb-npm-link] | [Node DynamoDB][node-dynamodb-issues] | [![Actions Status][node-dynamodb-ci-badge]][node-dynamodb-ci] | -| Telemetry Packages | npm | issues | tests | -| --------------------------------------------------------------------------------------- | ------------------------------------------------- | ----------------------------- | ----------------------------------------------------- | -| [@launchdarkly/node-server-sdk-otel](packages/telemetry/node-server-sdk-otel/README.md) | [![NPM][node-otel-npm-badge]][node-otel-npm-link] | [Node OTel][node-otel-issues] | [![Actions Status][node-otel-ci-badge]][node-otel-ci] | +| Telemetry Packages | npm | issues | tests | +| --------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------- | --------------------------------------------------------------------- | +| [@launchdarkly/node-server-sdk-otel](packages/telemetry/node-server-sdk-otel/README.md) | [![NPM][node-otel-npm-badge]][node-otel-npm-link] | [Node OTel][node-otel-issues] | [![Actions Status][node-otel-ci-badge]][node-otel-ci] | +| [@launchdarkly/browser-telemetry](packages/telemetry/browser-telemetry/README.md) | [![NPM][browser-telemetry-npm-badge]][browser-telemetry-npm-link] | [Browser Telemetry][browser-telemetry-issues] | [![Actions Status][browser-telemetry-ci-badge]][browser-telemetry-ci] | ## Organization @@ -107,6 +109,16 @@ We encourage pull requests and other contributions from the community. Check out [sdk-cloudflare-dm-badge]: https://img.shields.io/npm/dm/@launchdarkly/cloudflare-server-sdk.svg?style=flat-square [sdk-cloudflare-dt-badge]: https://img.shields.io/npm/dt/@launchdarkly/cloudflare-server-sdk.svg?style=flat-square [package-sdk-cloudflare-issues]: https://github.com/launchdarkly/js-core/issues?q=is%3Aissue+is%3Aopen+label%3A%22package%3A+sdk%2Fcloudflare%22+ +[//]: # 'sdk/fastly' +[sdk-fastly-ci-badge]: https://github.com/launchdarkly/js-core/actions/workflows/fastly.yml/badge.svg +[sdk-fastly-ci]: https://github.com/launchdarkly/js-core/actions/workflows/fastly.yml +[sdk-fastly-npm-badge]: https://img.shields.io/npm/v/@launchdarkly/fastly-server-sdk.svg?style=flat-square +[sdk-fastly-npm-link]: https://www.npmjs.com/package/@launchdarkly/fastly-server-sdk +[package-sdk-fastly-issues]: https://github.com/launchdarkly/js-core/issues?q=is%3Aissue+is%3Aopen+label%3A%22package%3A+sdk%2Ffastly%22+ +[sdk-fastly-ghp-badge]: https://img.shields.io/static/v1?label=GitHub+Pages&message=API+reference&color=00add8 +[sdk-fastly-ghp-link]: https://launchdarkly.github.io/js-core/packages/sdk/fastly/docs/ +[sdk-fastly-dm-badge]: https://img.shields.io/npm/dm/@launchdarkly/fastly-server-sdk.svg?style=flat-square +[sdk-fastly-dt-badge]: https://img.shields.io/npm/dt/@launchdarkly/fastly-server-sdk.svg?style=flat-square [//]: # 'sdk/server-node' [sdk-server-node-npm-badge]: https://img.shields.io/npm/v/@launchdarkly/node-server-sdk.svg?style=flat-square [sdk-server-node-npm-link]: https://www.npmjs.com/package/@launchdarkly/node-server-sdk @@ -191,3 +203,9 @@ We encourage pull requests and other contributions from the community. Check out [sdk-server-ai-dm-badge]: https://img.shields.io/npm/dm/@launchdarkly/server-sdk-ai.svg?style=flat-square [sdk-server-ai-dt-badge]: https://img.shields.io/npm/dt/@launchdarkly/server-sdk-ai.svg?style=flat-square [package-sdk-server-ai-issues]: https://github.com/launchdarkly/js-core/issues?q=is%3Aissue+is%3Aopen+label%3A%22package%3A+sdk%2Fserver-ai%22+ +[//]: # 'telemetry/browser-telemetry' +[browser-telemetry-ci-badge]: https://github.com/launchdarkly/js-core/actions/workflows/browser-telemetry.yml/badge.svg +[browser-telemetry-ci]: https://github.com/launchdarkly/js-core/actions/workflows/browser-telemetry.yml +[browser-telemetry-npm-badge]: https://img.shields.io/npm/v/@launchdarkly/browser-telemetry.svg?style=flat-square +[browser-telemetry-npm-link]: https://www.npmjs.com/package/@launchdarkly/browser-telemetry +[browser-telemetry-issues]: https://github.com/launchdarkly/js-core/issues?q=is%3Aissue+is%3Aopen+label%3A%22package%3A+telemetry%2Fbrowser-telemetry%22+ diff --git a/contract-tests/BigSegmentTestStore.js b/contract-tests/BigSegmentTestStore.js deleted file mode 100644 index 747f9a8870..0000000000 --- a/contract-tests/BigSegmentTestStore.js +++ /dev/null @@ -1,31 +0,0 @@ -import got from 'got'; - -export default class BigSegmentTestStore { - /** - * Create a big segment test store suitable for use with the contract tests. - * @param {string} callbackUri Uri on the test service to direct big segments - * calls to. - */ - constructor(callbackUri) { - this._callbackUri = callbackUri; - } - - async getMetadata() { - const data = await got.get(`${this._callbackUri}/getMetadata`, { retry: { limit: 0 } }).json(); - return data; - } - - async getUserMembership(contextHash) { - const data = await got - .post(`${this._callbackUri}/getMembership`, { - retry: { limit: 0 }, - json: { - contextHash, - }, - }) - .json(); - return data?.values; - } - - close() {} -} diff --git a/contract-tests/TestHook.js b/contract-tests/TestHook.js deleted file mode 100644 index 548971b367..0000000000 --- a/contract-tests/TestHook.js +++ /dev/null @@ -1,51 +0,0 @@ -import got from 'got'; - -export default class TestHook { - constructor(name, endpoint, data, errors) { - this._name = name; - this._endpoint = endpoint; - this._data = data; - this._errors = errors; - } - - async _safePost(body) { - try { - await got.post(this._endpoint, { json: body }); - } catch { - // The test could move on before the post, so we are ignoring - // failed posts. - } - } - - getMetadata() { - return { - name: 'LaunchDarkly Tracing Hook', - }; - } - - beforeEvaluation(hookContext, data) { - if (this._errors?.beforeEvaluation) { - throw new Error(this._errors.beforeEvaluation); - } - this._safePost({ - evaluationSeriesContext: hookContext, - evaluationSeriesData: data, - stage: 'beforeEvaluation', - }); - return { ...data, ...(this._data?.['beforeEvaluation'] || {}) }; - } - - afterEvaluation(hookContext, data, detail) { - if (this._errors?.afterEvaluation) { - throw new Error(this._errors.afterEvaluation); - } - this._safePost({ - evaluationSeriesContext: hookContext, - evaluationSeriesData: data, - stage: 'afterEvaluation', - evaluationDetail: detail, - }); - - return { ...data, ...(this._data?.['afterEvaluation'] || {}) }; - } -} diff --git a/contract-tests/log.js b/contract-tests/log.js deleted file mode 100644 index e6dcd973d8..0000000000 --- a/contract-tests/log.js +++ /dev/null @@ -1,20 +0,0 @@ -import ld from 'node-server-sdk'; - -export function Log(tag) { - function doLog(level, message) { - console.log(new Date().toISOString() + ` [${tag}] ${level}: ${message}`); - } - return { - info: (message) => doLog('info', message), - error: (message) => doLog('error', message), - }; -} - -export function sdkLogger(tag) { - return ld.basicLogger({ - level: 'debug', - destination: (line) => { - console.log(new Date().toISOString() + ` [${tag}.sdk] ${line}`); - }, - }); -} diff --git a/contract-tests/package.json b/contract-tests/package.json index ef4975c946..7b3f880d64 100644 --- a/contract-tests/package.json +++ b/contract-tests/package.json @@ -1,17 +1,26 @@ { "name": "node-server-sdk-contract-tests", "version": "0.0.0", - "main": "index.js", + "main": "dist/src/index.js", "scripts": { - "start": "node --inspect index.js" + "start": "node --inspect dist/src/index.js", + "build": "tsc", + "dev": "tsc --watch" }, "type": "module", "author": "", "license": "Apache-2.0", + "private": true, "dependencies": { + "@launchdarkly/node-server-sdk": "9.8.0", "body-parser": "^1.19.0", "express": "^4.17.1", - "node-server-sdk": "file:../packages/sdk/server-node", - "got": "13.0.0" + "got": "14.4.7" + }, + "devDependencies": { + "@types/body-parser": "^1.19.2", + "@types/express": "^4.17.13", + "@types/node": "^18.11.9", + "typescript": "^4.9.0" } } diff --git a/contract-tests/src/BigSegmentTestStore.ts b/contract-tests/src/BigSegmentTestStore.ts new file mode 100644 index 0000000000..7e31dc0498 --- /dev/null +++ b/contract-tests/src/BigSegmentTestStore.ts @@ -0,0 +1,40 @@ +import got from 'got'; + +interface BigSegmentMetadata { + lastUpToDate?: number; +} + +interface BigSegmentMembership { + values?: Record; +} + +export default class BigSegmentTestStore { + private _callbackUri: string; + + /** + * Create a big segment test store suitable for use with the contract tests. + * @param callbackUri Uri on the test service to direct big segments calls to. + */ + constructor(callbackUri: string) { + this._callbackUri = callbackUri; + } + + async getMetadata(): Promise { + const data = await got.get(`${this._callbackUri}/getMetadata`, { retry: { limit: 0 } }).json(); + return data as BigSegmentMetadata; + } + + async getUserMembership(contextHash: string): Promise | undefined> { + const data = await got + .post(`${this._callbackUri}/getMembership`, { + retry: { limit: 0 }, + json: { + contextHash, + }, + }) + .json(); + return (data as BigSegmentMembership)?.values; + } + + close(): void {} +} diff --git a/contract-tests/src/TestHook.ts b/contract-tests/src/TestHook.ts new file mode 100644 index 0000000000..abad14331d --- /dev/null +++ b/contract-tests/src/TestHook.ts @@ -0,0 +1,75 @@ +import got from 'got'; + +import { integrations, LDEvaluationDetail } from '@launchdarkly/node-server-sdk'; + +export interface HookData { + beforeEvaluation?: Record; + afterEvaluation?: Record; +} + +export interface HookErrors { + beforeEvaluation?: string; + afterEvaluation?: string; +} + +export default class TestHook implements integrations.Hook { + private _name: string; + private _endpoint: string; + private _data?: HookData; + private _errors?: HookErrors; + + constructor(name: string, endpoint: string, data?: HookData, errors?: HookErrors) { + this._name = name; + this._endpoint = endpoint; + this._data = data; + this._errors = errors; + } + + private async _safePost(body: unknown): Promise { + try { + await got.post(this._endpoint, { json: body }); + } catch { + // The test could move on before the post, so we are ignoring + // failed posts. + } + } + + getMetadata(): integrations.HookMetadata { + return { + name: this._name, + }; + } + + beforeEvaluation( + hookContext: integrations.EvaluationSeriesContext, + data: integrations.EvaluationSeriesData, + ): integrations.EvaluationSeriesData { + if (this._errors?.beforeEvaluation) { + throw new Error(this._errors.beforeEvaluation); + } + this._safePost({ + evaluationSeriesContext: hookContext, + evaluationSeriesData: data, + stage: 'beforeEvaluation', + }); + return { ...data, ...(this._data?.beforeEvaluation || {}) }; + } + + afterEvaluation( + hookContext: integrations.EvaluationSeriesContext, + data: integrations.EvaluationSeriesData, + detail: LDEvaluationDetail, + ): integrations.EvaluationSeriesData { + if (this._errors?.afterEvaluation) { + throw new Error(this._errors.afterEvaluation); + } + this._safePost({ + evaluationSeriesContext: hookContext, + evaluationSeriesData: data, + stage: 'afterEvaluation', + evaluationDetail: detail, + }); + + return { ...data, ...(this._data?.afterEvaluation || {}) }; + } +} diff --git a/contract-tests/index.js b/contract-tests/src/index.ts similarity index 68% rename from contract-tests/index.js rename to contract-tests/src/index.ts index 761a431ddc..882ff549d5 100644 --- a/contract-tests/index.js +++ b/contract-tests/src/index.ts @@ -1,22 +1,23 @@ import bodyParser from 'body-parser'; -import express from 'express'; +import express, { Request, Response } from 'express'; +import { Server } from 'http'; import { Log } from './log.js'; -import { badCommandError, newSdkClientEntity } from './sdkClientEntity.js'; +import { badCommandError, newSdkClientEntity, SdkClientEntity } from './sdkClientEntity.js'; const app = express(); -let server = null; +let server: Server | null = null; const port = 8000; let clientCounter = 0; -const clients = {}; +const clients: Record = {}; const mainLog = Log('service'); app.use(bodyParser.json()); -app.get('/', (req, res) => { +app.get('/', (req: Request, res: Response) => { res.header('Content-Type', 'application/json'); res.json({ capabilities: [ @@ -34,30 +35,34 @@ app.get('/', (req, res) => { 'event-sampling', 'strongly-typed', 'polling-gzip', - 'inline-context', + 'inline-context-all', 'anonymous-redaction', 'evaluation-hooks', 'wrapper', 'client-prereq-events', + 'event-gzip', + 'optional-event-gzip', ], }); }); -app.delete('/', (req, res) => { +app.delete('/', (req: Request, res: Response) => { mainLog.info('Test service has told us to exit'); res.status(204); res.send(); // Defer the following actions till after the response has been sent setTimeout(() => { - server.close(() => process.exit()); + if (server) { + server.close(() => process.exit()); + } // We force-quit with process.exit because, even after closing the server, there could be some // scheduled tasks lingering if an SDK instance didn't get cleaned up properly, and we don't want // that to prevent us from quitting. }, 1); }); -app.post('/', async (req, res) => { +app.post('/', async (req: Request, res: Response) => { const options = req.body; clientCounter += 1; @@ -72,14 +77,14 @@ app.post('/', async (req, res) => { res.set('Location', resourceUrl); } catch (e) { res.status(500); - const message = e.message || JSON.stringify(e); - mainLog.error('Error creating client: ' + message); + const message = e instanceof Error ? e.message : JSON.stringify(e); + mainLog.error(`Error creating client: ${message}`); res.write(message); } res.send(); }); -app.post('/clients/:id', async (req, res) => { +app.post('/clients/:id', async (req: Request, res: Response) => { const client = clients[req.params.id]; if (!client) { res.status(404); @@ -95,8 +100,10 @@ app.post('/clients/:id', async (req, res) => { } catch (e) { const isBadRequest = e === badCommandError; res.status(isBadRequest ? 400 : 500); - res.write(e.message || JSON.stringify(e)); - if (!isBadRequest && e.stack) { + const message = e instanceof Error ? e.message : JSON.stringify(e); + res.write(message); + if (!isBadRequest && e instanceof Error && e.stack) { + // eslint-disable-next-line no-console console.log(e.stack); } } @@ -104,7 +111,7 @@ app.post('/clients/:id', async (req, res) => { res.send(); }); -app.delete('/clients/:id', async (req, res) => { +app.delete('/clients/:id', async (req: Request, res: Response) => { const client = clients[req.params.id]; if (!client) { res.status(404); @@ -118,5 +125,6 @@ app.delete('/clients/:id', async (req, res) => { }); server = app.listen(port, () => { + // eslint-disable-next-line no-console console.log('Listening on port %d', port); }); diff --git a/contract-tests/src/log.ts b/contract-tests/src/log.ts new file mode 100644 index 0000000000..1cb3005bc0 --- /dev/null +++ b/contract-tests/src/log.ts @@ -0,0 +1,27 @@ +import ld from '@launchdarkly/node-server-sdk'; + +export interface Logger { + info: (message: string) => void; + error: (message: string) => void; +} + +export function Log(tag: string): Logger { + function doLog(level: string, message: string): void { + // eslint-disable-next-line no-console + console.log(`${new Date().toISOString()} [${tag}] ${level}: ${message}`); + } + return { + info: (message: string) => doLog('info', message), + error: (message: string) => doLog('error', message), + }; +} + +export function sdkLogger(tag: string): ld.LDLogger { + return ld.basicLogger({ + level: 'debug', + destination: (line: string) => { + // eslint-disable-next-line no-console + console.log(`${new Date().toISOString()} [${tag}.sdk] ${line}`); + }, + }); +} diff --git a/contract-tests/sdkClientEntity.js b/contract-tests/src/sdkClientEntity.ts similarity index 63% rename from contract-tests/sdkClientEntity.js rename to contract-tests/src/sdkClientEntity.ts index fcb51ce9fe..96cb4dcadd 100644 --- a/contract-tests/sdkClientEntity.js +++ b/contract-tests/src/sdkClientEntity.ts @@ -1,12 +1,19 @@ import got from 'got'; + import ld, { createMigration, + LDClient, LDConcurrentExecution, + LDContext, LDExecutionOrdering, + LDFlagValue, LDMigrationError, + LDMigrationStage, LDMigrationSuccess, + LDOptions, LDSerialExecution, -} from 'node-server-sdk'; + LDUser, +} from '@launchdarkly/node-server-sdk'; import BigSegmentTestStore from './BigSegmentTestStore.js'; import { Log, sdkLogger } from './log.js'; @@ -15,30 +22,156 @@ import TestHook from './TestHook.js'; const badCommandError = new Error('unsupported command'); export { badCommandError }; -export function makeSdkConfig(options, tag) { - const cf = { +interface SdkConfigOptions { + streaming?: { + baseUri: string; + initialRetryDelayMs?: number; + filter?: string; + }; + polling?: { + baseUri: string; + pollIntervalMs: number; + filter?: string; + }; + dataSystem?: { + synchronizers: { + primary: { + streaming: { + baseUri: string; + pollIntervalMs: number; + filter?: string; + }, + polling: { + baseUri: string; + pollIntervalMs: number; + filter?: string; + }, + } + secondary: { + streaming: { + baseUri: string; + pollIntervalMs: number; + filter?: string; + }, + polling: { + baseUri: string; + pollIntervalMs: number; + filter?: string; + }, + } + }, + payloadFilter: string, + }; + events?: { + allAttributesPrivate?: boolean; + baseUri: string; + capacity?: number; + enableDiagnostics?: boolean; + flushIntervalMs?: number; + globalPrivateAttributes?: string[]; + enableGzip?: boolean; + }; + tags?: { + applicationId: string; + applicationVersion: string; + }; + bigSegments?: { + callbackUri: string; + userCacheSize?: number; + userCacheTimeMs?: number; + statusPollIntervalMs?: number; + staleAfterMs?: number; + }; + hooks?: { + hooks: { + name: string; + callbackUri: string; + data: any; + errors: any; + }[]; + }; + wrapper?: { + name?: string; + version?: string; + }; +} + +interface CommandParams { + command: string; + evaluate?: { + flagKey: string; + context?: LDContext; + user?: LDUser; + defaultValue: LDFlagValue; + detail?: boolean; + valueType?: string; + }; + evaluateAll?: { + context?: LDContext; + user?: LDUser; + clientSideOnly?: boolean; + detailsOnlyForTrackedFlags?: boolean; + withReasons?: boolean; + }; + identifyEvent?: { + context?: LDContext; + user?: LDUser; + }; + customEvent?: { + eventKey: string; + context?: LDContext; + user?: LDUser; + data?: any; + metricValue?: number; + }; + migrationVariation?: { + key: string; + context: LDContext; + defaultStage: LDMigrationStage; + }; + migrationOperation?: { + operation: string; + key: string; + context: LDContext; + defaultStage: LDMigrationStage; + payload: any; + readExecutionOrder: string; + trackLatency?: boolean; + trackErrors?: boolean; + trackConsistency?: boolean; + newEndpoint: string; + oldEndpoint: string; + }; +} + +export function makeSdkConfig(options: SdkConfigOptions, tag: string): LDOptions { + const cf: LDOptions = { logger: sdkLogger(tag), diagnosticOptOut: true, }; - const maybeTime = (seconds) => + const maybeTime = (seconds?: number) => seconds === undefined || seconds === null ? undefined : seconds / 1000; if (options.streaming) { cf.streamUri = options.streaming.baseUri; cf.streamInitialReconnectDelay = maybeTime(options.streaming.initialRetryDelayMs); if (options.streaming.filter) { - cf.payloadFilterKey = options.streaming.filter; + cf.application = cf.application || {}; + cf.application.payloadFilterKey = options.streaming.filter; } } + if (options.polling) { cf.stream = false; cf.baseUri = options.polling.baseUri; cf.pollInterval = options.polling.pollIntervalMs / 1000; if (options.polling.filter) { - cf.payloadFilterKey = options.polling.filter; + cf.application = cf.application || {}; + cf.application.payloadFilterKey = options.polling.filter; } } + if (options.events) { cf.allAttributesPrivate = options.events.allAttributesPrivate; cf.eventsUri = options.events.baseUri; @@ -46,13 +179,16 @@ export function makeSdkConfig(options, tag) { cf.diagnosticOptOut = !options.events.enableDiagnostics; cf.flushInterval = maybeTime(options.events.flushIntervalMs); cf.privateAttributes = options.events.globalPrivateAttributes; + cf.enableEventCompression = options.events.enableGzip; } + if (options.tags) { cf.application = { id: options.tags.applicationId, version: options.tags.applicationVersion, }; } + if (options.bigSegments) { const bigSegmentsOptions = options.bigSegments; cf.bigSegments = { @@ -69,11 +205,13 @@ export function makeSdkConfig(options, tag) { : undefined, }; } + if (options.hooks) { cf.hooks = options.hooks.hooks.map( (hook) => new TestHook(hook.name, hook.callbackUri, hook.data, hook.errors), ); } + if (options.wrapper) { if (options.wrapper.name) { cf.wrapperName = options.wrapper.name; @@ -82,6 +220,7 @@ export function makeSdkConfig(options, tag) { cf.wrapperVersion = options.wrapper.version; } } + if (options.dataSystem) { const dataSourceStreamingOptions = options.dataSystem.synchronizers?.primary?.streaming ?? options.dataSystem.synchronizers?.secondary?.streaming; const dataSourcePollingOptions = options.dataSystem.synchronizers?.primary?.polling ?? options.dataSystem.synchronizers?.secondary?.polling; @@ -140,7 +279,7 @@ export function makeSdkConfig(options, tag) { return cf; } -function getExecution(order) { +function getExecution(order: string) { switch (order) { case 'serial': { return new LDSerialExecution(LDExecutionOrdering.Fixed); @@ -157,29 +296,41 @@ function getExecution(order) { } } -function makeMigrationPostOptions(payload) { +function makeMigrationPostOptions(payload: any) { if (payload) { return { body: payload }; } return {}; } -export async function newSdkClientEntity(options) { - const c = {}; +function contextOrUser( + context: LDContext | undefined, + user: LDUser | undefined, +): LDContext | LDUser { + return (context || user)!; +} + +export interface SdkClientEntity { + close: () => void; + doCommand: (params: CommandParams) => Promise; +} + +export async function newSdkClientEntity(options: any): Promise { + const c: any = {}; const log = Log(options.tag); - log.info('Creating client with configuration: ' + JSON.stringify(options.configuration)); + log.info(`Creating client with configuration: ${JSON.stringify(options.configuration)}`); const timeout = options.configuration.startWaitTimeMs !== null && options.configuration.startWaitTimeMs !== undefined ? options.configuration.startWaitTimeMs : 5000; - const client = ld.init( + const client: LDClient = ld.init( options.configuration.credential || 'unknown-sdk-key', makeSdkConfig(options.configuration, options.tag), ); try { - await client.waitForInitialization({ timeout: timeout }); + await client.waitForInitialization({ timeout }); } catch (_) { // if waitForInitialization() rejects, the client failed to initialize, see next line } @@ -193,36 +344,25 @@ export async function newSdkClientEntity(options) { log.info('Test ended'); }; - c.doCommand = async (params) => { - log.info('Received command: ' + params.command); + c.doCommand = async (params: CommandParams) => { + log.info(`Received command: ${params.command}`); switch (params.command) { case 'evaluate': { - const pe = params.evaluate; + const pe = params.evaluate!; + const context = contextOrUser(pe.context, pe.user); if (pe.detail) { switch (pe.valueType) { case 'bool': - return await client.boolVariationDetail( - pe.flagKey, - pe.context || pe.user, - pe.defaultValue, - ); + return client.boolVariationDetail(pe.flagKey, context, pe.defaultValue); case 'int': // Intentional fallthrough. case 'double': - return await client.numberVariationDetail( - pe.flagKey, - pe.context || pe.user, - pe.defaultValue, - ); + return client.numberVariationDetail(pe.flagKey, context, pe.defaultValue); case 'string': - return await client.stringVariationDetail( - pe.flagKey, - pe.context || pe.user, - pe.defaultValue, - ); + return client.stringVariationDetail(pe.flagKey, context, pe.defaultValue); default: - return await client.variationDetail( + return client.variationDetail( pe.flagKey, - pe.context || pe.user, + contextOrUser(pe.context, pe.user), pe.defaultValue, ); } @@ -230,54 +370,42 @@ export async function newSdkClientEntity(options) { switch (pe.valueType) { case 'bool': return { - value: await client.boolVariation( - pe.flagKey, - pe.context || pe.user, - pe.defaultValue, - ), + value: await client.boolVariation(pe.flagKey, context, pe.defaultValue), }; case 'int': // Intentional fallthrough. case 'double': return { - value: await client.numberVariation( - pe.flagKey, - pe.context || pe.user, - pe.defaultValue, - ), + value: await client.numberVariation(pe.flagKey, context, pe.defaultValue), }; case 'string': return { - value: await client.stringVariation( - pe.flagKey, - pe.context || pe.user, - pe.defaultValue, - ), + value: await client.stringVariation(pe.flagKey, context, pe.defaultValue), }; default: return { - value: await client.variation(pe.flagKey, pe.context || pe.user, pe.defaultValue), + value: await client.variation(pe.flagKey, context, pe.defaultValue), }; } } } case 'evaluateAll': { - const pea = params.evaluateAll; + const pea = params.evaluateAll!; const eao = { clientSideOnly: pea.clientSideOnly, detailsOnlyForTrackedFlags: pea.detailsOnlyForTrackedFlags, withReasons: pea.withReasons, }; - return { state: await client.allFlagsState(pea.context || pea.user, eao) }; + return { state: await client.allFlagsState(contextOrUser(pea.context, pea.user), eao) }; } case 'identifyEvent': - client.identify(params.identifyEvent.context || params.identifyEvent.user); + client.identify(params.identifyEvent!.context || params.identifyEvent!.user!); return undefined; case 'customEvent': { - const pce = params.customEvent; - client.track(pce.eventKey, pce.context || pce.user, pce.data, pce.metricValue); + const pce = params.customEvent!; + client.track(pce.eventKey, contextOrUser(pce.context, pce.user), pce.data, pce.metricValue); return undefined; } @@ -286,20 +414,21 @@ export async function newSdkClientEntity(options) { return undefined; case 'getBigSegmentStoreStatus': - return await client.bigSegmentStoreStatusProvider.requireStatus(); + return client.bigSegmentStoreStatusProvider.requireStatus(); - case 'migrationVariation': - const migrationVariation = params.migrationVariation; + case 'migrationVariation': { + const migrationVariation = params.migrationVariation!; const res = await client.migrationVariation( migrationVariation.key, migrationVariation.context, migrationVariation.defaultStage, ); return { result: res.value }; + } - case 'migrationOperation': - const migrationOperation = params.migrationOperation; - const readExecutionOrder = migrationOperation.readExecutionOrder; + case 'migrationOperation': { + const migrationOperation = params.migrationOperation!; + const { readExecutionOrder } = migrationOperation; const migration = createMigration(client, { execution: getExecution(readExecutionOrder), @@ -313,7 +442,7 @@ export async function newSdkClientEntity(options) { makeMigrationPostOptions(payload), ); return LDMigrationSuccess(res.body); - } catch (err) { + } catch (err: any) { return LDMigrationError(err.message); } }, @@ -324,7 +453,7 @@ export async function newSdkClientEntity(options) { makeMigrationPostOptions(payload), ); return LDMigrationSuccess(res.body); - } catch (err) { + } catch (err: any) { return LDMigrationError(err.message); } }, @@ -335,7 +464,7 @@ export async function newSdkClientEntity(options) { makeMigrationPostOptions(payload), ); return LDMigrationSuccess(res.body); - } catch (err) { + } catch (err: any) { return LDMigrationError(err.message); } }, @@ -346,7 +475,7 @@ export async function newSdkClientEntity(options) { makeMigrationPostOptions(payload), ); return LDMigrationSuccess(res.body); - } catch (err) { + } catch (err: any) { return LDMigrationError(err.message); } }, @@ -362,9 +491,8 @@ export async function newSdkClientEntity(options) { ); if (res.success) { return { result: res.result }; - } else { - return { result: res.error }; } + return { result: res.error }; } case 'write': { const res = await migration.write( @@ -376,12 +504,14 @@ export async function newSdkClientEntity(options) { if (res.authoritative.success) { return { result: res.authoritative.result }; - } else { - return { result: res.authoritative.error }; } + return { result: res.authoritative.error }; + } + default: { + return undefined; } } - return undefined; + } default: throw badCommandError; diff --git a/contract-tests/testharness-suppressions.txt b/contract-tests/testharness-suppressions.txt index c7c8985d79..173fb7d30d 100644 --- a/contract-tests/testharness-suppressions.txt +++ b/contract-tests/testharness-suppressions.txt @@ -1,9 +1,12 @@ streaming/validation/drop and reconnect if stream event has malformed JSON streaming/validation/drop and reconnect if stream event has well-formed JSON not matching schema +streaming/requests/URL path is computed correctly/environment_filter_key="encoding_not_necessary"/base URI has no trailing slash/GET +streaming/requests/URL path is computed correctly/environment_filter_key="encoding_not_necessary"/base URI has a trailing slash/GET +polling/requests/URL path is computed correctly/environment_filter_key="encoding_not_necessary"/base URI has no trailing slash/GET +polling/requests/URL path is computed correctly/environment_filter_key="encoding_not_necessary"/base URI has a trailing slash/GET streaming/fdv2/reconnection state management/initializes from polling initializer streaming/fdv2/reconnection state management/initializes from 2 polling initializers - streaming/fdv2/reconnection state management/saves previously known state streaming/fdv2/reconnection state management/replaces previously known state streaming/fdv2/reconnection state management/updates previously known state diff --git a/contract-tests/tsconfig.json b/contract-tests/tsconfig.json new file mode 100644 index 0000000000..50aee6cff3 --- /dev/null +++ b/contract-tests/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + // Uses "." so it can load package.json. + "rootDir": ".", + "outDir": "dist", + "target": "ES2020", + "lib": ["ES2020"], + "module": "Node16", + "moduleResolution": "node16", + "strict": true, + "noImplicitOverride": true, + // Needed for CommonJS modules: markdown-it, fs-extra + "allowSyntheticDefaultImports": true, + "sourceMap": true, + "declaration": true, + "declarationMap": true, // enables importers to jump to source + "resolveJsonModule": true, + "stripInternal": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*"], + "exclude": ["**/*.test.ts", "dist", "node_modules", "__tests__"] +} diff --git a/contract-tests/tsconfig.ref.json b/contract-tests/tsconfig.ref.json new file mode 100644 index 0000000000..34a1cb607a --- /dev/null +++ b/contract-tests/tsconfig.ref.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*", "package.json"], + "compilerOptions": { + "composite": true + } +} diff --git a/package.json b/package.json index 53163bd25c..95c5749574 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "packages/sdk/server-node", "packages/sdk/cloudflare", "packages/sdk/cloudflare/example", + "packages/sdk/fastly", + "packages/sdk/fastly/example", "packages/sdk/react-native", "packages/sdk/react-native/example", "packages/sdk/react-universal", @@ -30,7 +32,8 @@ "packages/sdk/server-ai", "packages/sdk/server-ai/examples/bedrock", "packages/sdk/server-ai/examples/openai", - "packages/telemetry/browser-telemetry" + "packages/telemetry/browser-telemetry", + "contract-tests" ], "private": true, "scripts": { @@ -42,9 +45,10 @@ "lint:fix": "yarn run lint -- --fix", "test": "echo Please run tests for individual packages.", "coverage": "npm run test -- --coverage", - "contract-test-service": "npm --prefix contract-tests install && npm --prefix contract-tests start", + "contract-test-service-build": "yarn workspaces foreach -pR --topological-dev --from 'node-server-sdk-contract-tests' run build", + "contract-test-service": "yarn workspace node-server-sdk-contract-tests start", "contract-test-harness": "curl -s https://raw.githubusercontent.com/launchdarkly/sdk-test-harness/master/downloader/run.sh \\ | VERSION=v2 PARAMS=\"-url http://localhost:8000 -debug --skip-from=./contract-tests/testharness-suppressions.txt -stop-service-at-end $TEST_HARNESS_PARAMS\" sh", - "contract-tests": "npm run contract-test-service & npm run contract-test-harness", + "contract-tests": "yarn contract-test-service-build && yarn contract-test-service & yarn contract-test-harness", "prettier": "npx prettier --write \"**/*.{js,ts,tsx,json,yaml,yml,md}\" --log-level warn", "check": "yarn && yarn prettier && yarn lint && tsc && yarn build" }, diff --git a/packages/sdk/akamai-base/CHANGELOG.md b/packages/sdk/akamai-base/CHANGELOG.md index 926b2439cf..40b71857c8 100644 --- a/packages/sdk/akamai-base/CHANGELOG.md +++ b/packages/sdk/akamai-base/CHANGELOG.md @@ -30,6 +30,103 @@ All notable changes to the LaunchDarkly SDK for Akamai Workers will be documente * @launchdarkly/akamai-edgeworker-sdk-common bumped from ^1.1.1 to ^1.1.2 * @launchdarkly/js-server-sdk-common bumped from ^2.2.1 to ^2.2.2 +## [3.0.5](https://github.com/launchdarkly/js-core/compare/akamai-server-base-sdk-v3.0.4...akamai-server-base-sdk-v3.0.5) (2025-04-16) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/akamai-edgeworker-sdk-common bumped from ^2.0.4 to ^2.0.5 + * @launchdarkly/js-server-sdk-common bumped from ^2.14.0 to ^2.15.0 + +## [3.0.4](https://github.com/launchdarkly/js-core/compare/akamai-server-base-sdk-v3.0.3...akamai-server-base-sdk-v3.0.4) (2025-04-08) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/akamai-edgeworker-sdk-common bumped from ^2.0.3 to ^2.0.4 + * @launchdarkly/js-server-sdk-common bumped from ^2.13.0 to ^2.14.0 + +## [3.0.3](https://github.com/launchdarkly/js-core/compare/akamai-server-base-sdk-v3.0.2...akamai-server-base-sdk-v3.0.3) (2025-03-26) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/akamai-edgeworker-sdk-common bumped from ^2.0.2 to ^2.0.3 + * @launchdarkly/js-server-sdk-common bumped from ^2.12.1 to ^2.13.0 + +## [3.0.2](https://github.com/launchdarkly/js-core/compare/akamai-server-base-sdk-v3.0.1...akamai-server-base-sdk-v3.0.2) (2025-03-21) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/akamai-edgeworker-sdk-common bumped from ^2.0.1 to ^2.0.2 + * @launchdarkly/js-server-sdk-common bumped from ^2.12.0 to ^2.12.1 + +## [3.0.1](https://github.com/launchdarkly/js-core/compare/akamai-server-base-sdk-v3.0.0...akamai-server-base-sdk-v3.0.1) (2025-03-17) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/akamai-edgeworker-sdk-common bumped from ^2.0.0 to ^2.0.1 + * @launchdarkly/js-server-sdk-common bumped from ^2.11.1 to ^2.12.0 + +## [3.0.0](https://github.com/launchdarkly/js-core/compare/akamai-server-base-sdk-v2.1.23...akamai-server-base-sdk-v3.0.0) (2025-02-26) + + +### ⚠ BREAKING CHANGES + +* Replace prefetch behavior with simple TTL cache ([#786](https://github.com/launchdarkly/js-core/issues/786)) + +### Features + +* Replace prefetch behavior with simple TTL cache ([#786](https://github.com/launchdarkly/js-core/issues/786)) ([48b48cf](https://github.com/launchdarkly/js-core/commit/48b48cf69d518dc70a557ffd1dfb0209aee0b124)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/akamai-edgeworker-sdk-common bumped from ^1.4.1 to ^2.0.0 + +## [2.1.23](https://github.com/launchdarkly/js-core/compare/akamai-server-base-sdk-v2.1.22...akamai-server-base-sdk-v2.1.23) (2025-02-18) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/akamai-edgeworker-sdk-common bumped from ^1.4.0 to ^1.4.1 + * @launchdarkly/js-server-sdk-common bumped from ^2.11.0 to ^2.11.1 + +## [2.1.22](https://github.com/launchdarkly/js-core/compare/akamai-server-base-sdk-v2.1.21...akamai-server-base-sdk-v2.1.22) (2025-01-30) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/akamai-edgeworker-sdk-common bumped from ^1.3.3 to ^1.4.0 + +## [2.1.21](https://github.com/launchdarkly/js-core/compare/akamai-server-base-sdk-v2.1.20...akamai-server-base-sdk-v2.1.21) (2025-01-22) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/akamai-edgeworker-sdk-common bumped from ^1.3.2 to ^1.3.3 + * @launchdarkly/js-server-sdk-common bumped from ^2.10.0 to ^2.11.0 + ## [2.1.20](https://github.com/launchdarkly/js-core/compare/akamai-server-base-sdk-v2.1.19...akamai-server-base-sdk-v2.1.20) (2024-11-14) diff --git a/packages/sdk/akamai-base/example/ldClient.ts b/packages/sdk/akamai-base/example/ldClient.ts index 36f92d2098..46fc1fa020 100644 --- a/packages/sdk/akamai-base/example/ldClient.ts +++ b/packages/sdk/akamai-base/example/ldClient.ts @@ -54,6 +54,9 @@ export const evaluateFlagFromCustomFeatureStore = async ( const client = init({ sdkKey: 'Your-launchdarkly-environment-client-id', featureStoreProvider: new MyCustomStoreProvider(), + options: { + cacheTtlMs: 1_000, + }, }); return client.variation(flagKey, context, defaultValue); diff --git a/packages/sdk/akamai-base/example/package.json b/packages/sdk/akamai-base/example/package.json index 3daa983355..5e6891d949 100644 --- a/packages/sdk/akamai-base/example/package.json +++ b/packages/sdk/akamai-base/example/package.json @@ -32,6 +32,6 @@ "typescript": "5.1.6" }, "dependencies": { - "@launchdarkly/akamai-server-base-sdk": "2.1.20" + "@launchdarkly/akamai-server-base-sdk": "3.0.5" } } diff --git a/packages/sdk/akamai-base/package.json b/packages/sdk/akamai-base/package.json index 2194620357..aa8b584595 100644 --- a/packages/sdk/akamai-base/package.json +++ b/packages/sdk/akamai-base/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/akamai-server-base-sdk", - "version": "2.1.20", + "version": "3.0.5", "description": "Akamai LaunchDarkly EdgeWorker SDK", "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/sdk/akamai-base", "repository": { @@ -73,7 +73,7 @@ "typescript": "5.1.6" }, "dependencies": { - "@launchdarkly/akamai-edgeworker-sdk-common": "^1.3.2", - "@launchdarkly/js-server-sdk-common": "^2.10.0" + "@launchdarkly/akamai-edgeworker-sdk-common": "^2.0.5", + "@launchdarkly/js-server-sdk-common": "^2.15.0" } } diff --git a/packages/sdk/akamai-edgekv/CHANGELOG.md b/packages/sdk/akamai-edgekv/CHANGELOG.md index c39ad349a6..dfe17de3c4 100644 --- a/packages/sdk/akamai-edgekv/CHANGELOG.md +++ b/packages/sdk/akamai-edgekv/CHANGELOG.md @@ -31,6 +31,99 @@ All notable changes to the LaunchDarkly SDK for Akamai Workers will be documente * @launchdarkly/akamai-edgeworker-sdk-common bumped from ^1.1.1 to ^1.1.2 * @launchdarkly/js-server-sdk-common bumped from ^2.2.1 to ^2.2.2 +## [1.4.7](https://github.com/launchdarkly/js-core/compare/akamai-server-edgekv-sdk-v1.4.6...akamai-server-edgekv-sdk-v1.4.7) (2025-04-16) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/akamai-edgeworker-sdk-common bumped from ^2.0.4 to ^2.0.5 + * @launchdarkly/js-server-sdk-common bumped from ^2.14.0 to ^2.15.0 + +## [1.4.6](https://github.com/launchdarkly/js-core/compare/akamai-server-edgekv-sdk-v1.4.5...akamai-server-edgekv-sdk-v1.4.6) (2025-04-08) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/akamai-edgeworker-sdk-common bumped from ^2.0.3 to ^2.0.4 + * @launchdarkly/js-server-sdk-common bumped from ^2.13.0 to ^2.14.0 + +## [1.4.5](https://github.com/launchdarkly/js-core/compare/akamai-server-edgekv-sdk-v1.4.4...akamai-server-edgekv-sdk-v1.4.5) (2025-03-26) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/akamai-edgeworker-sdk-common bumped from ^2.0.2 to ^2.0.3 + * @launchdarkly/js-server-sdk-common bumped from ^2.12.1 to ^2.13.0 + +## [1.4.4](https://github.com/launchdarkly/js-core/compare/akamai-server-edgekv-sdk-v1.4.3...akamai-server-edgekv-sdk-v1.4.4) (2025-03-21) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/akamai-edgeworker-sdk-common bumped from ^2.0.1 to ^2.0.2 + * @launchdarkly/js-server-sdk-common bumped from ^2.12.0 to ^2.12.1 + +## [1.4.3](https://github.com/launchdarkly/js-core/compare/akamai-server-edgekv-sdk-v1.4.2...akamai-server-edgekv-sdk-v1.4.3) (2025-03-17) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/akamai-edgeworker-sdk-common bumped from ^2.0.0 to ^2.0.1 + * @launchdarkly/js-server-sdk-common bumped from ^2.11.1 to ^2.12.0 + +## [1.4.2](https://github.com/launchdarkly/js-core/compare/akamai-server-edgekv-sdk-v1.4.1...akamai-server-edgekv-sdk-v1.4.2) (2025-02-26) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/akamai-edgeworker-sdk-common bumped from ^1.4.1 to ^2.0.0 + +## [1.4.1](https://github.com/launchdarkly/js-core/compare/akamai-server-edgekv-sdk-v1.4.0...akamai-server-edgekv-sdk-v1.4.1) (2025-02-18) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/akamai-edgeworker-sdk-common bumped from ^1.4.0 to ^1.4.1 + * @launchdarkly/js-server-sdk-common bumped from ^2.11.0 to ^2.11.1 + +## [1.4.0](https://github.com/launchdarkly/js-core/compare/akamai-server-edgekv-sdk-v1.3.1...akamai-server-edgekv-sdk-v1.4.0) (2025-01-30) + + +### Features + +* Add cacheTtlMs option ([#760](https://github.com/launchdarkly/js-core/issues/760)) ([4f961dd](https://github.com/launchdarkly/js-core/commit/4f961dd16fd10f5bb55dd2116d26b218944bfeb2)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/akamai-edgeworker-sdk-common bumped from ^1.3.3 to ^1.4.0 + +## [1.3.1](https://github.com/launchdarkly/js-core/compare/akamai-server-edgekv-sdk-v1.3.0...akamai-server-edgekv-sdk-v1.3.1) (2025-01-22) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/akamai-edgeworker-sdk-common bumped from ^1.3.2 to ^1.3.3 + * @launchdarkly/js-server-sdk-common bumped from ^2.10.0 to ^2.11.0 + ## [1.3.0](https://github.com/launchdarkly/js-core/compare/akamai-server-edgekv-sdk-v1.2.1...akamai-server-edgekv-sdk-v1.3.0) (2024-11-14) diff --git a/packages/sdk/akamai-edgekv/__tests__/index.test.ts b/packages/sdk/akamai-edgekv/__tests__/index.test.ts index 90fd9e9249..a0c3a93976 100644 --- a/packages/sdk/akamai-edgekv/__tests__/index.test.ts +++ b/packages/sdk/akamai-edgekv/__tests__/index.test.ts @@ -1,11 +1,13 @@ import EdgeKVProvider from '../src/edgekv/edgeKVProvider'; -import { init as initWithEdgeKV, LDClient, LDContext } from '../src/index'; +import { init as initWithEdgeKV, LDClient, LDContext, LDLogger } from '../src/index'; import * as testData from './testData.json'; jest.mock('../src/edgekv/edgekv', () => ({ EdgeKV: jest.fn(), })); +let logger: LDLogger; + const sdkKey = 'test-sdk-key'; const flagKey1 = 'testFlag1'; const flagKey2 = 'testFlag2'; @@ -17,11 +19,22 @@ describe('init', () => { describe('init with Edge KV', () => { beforeAll(async () => { - ldClient = initWithEdgeKV({ namespace: 'akamai-test', group: 'Akamai', sdkKey }); + ldClient = initWithEdgeKV({ + namespace: 'akamai-test', + group: 'Akamai', + sdkKey, + options: { logger }, + }); await ldClient.waitForInitialization(); }); beforeEach(() => { + logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; jest .spyOn(EdgeKVProvider.prototype, 'get') .mockImplementation(() => Promise.resolve(JSON.stringify(testData))); @@ -31,6 +44,12 @@ describe('init', () => { ldClient.close(); }); + it('should not log a warning about initialization', async () => { + const spy = jest.spyOn(logger, 'warn'); + await ldClient.variation(flagKey1, context, false); + expect(spy).not.toHaveBeenCalled(); + }); + describe('flags', () => { it('variation default', async () => { const value = await ldClient.variation(flagKey1, context, false); diff --git a/packages/sdk/akamai-edgekv/example/package.json b/packages/sdk/akamai-edgekv/example/package.json index 6c577ce31c..575b5bcf68 100644 --- a/packages/sdk/akamai-edgekv/example/package.json +++ b/packages/sdk/akamai-edgekv/example/package.json @@ -31,6 +31,6 @@ "typescript": "5.1.6" }, "dependencies": { - "@launchdarkly/akamai-server-edgekv-sdk": "1.3.0" + "@launchdarkly/akamai-server-edgekv-sdk": "1.4.7" } } diff --git a/packages/sdk/akamai-edgekv/package.json b/packages/sdk/akamai-edgekv/package.json index 9508b75a67..c4843de1b5 100644 --- a/packages/sdk/akamai-edgekv/package.json +++ b/packages/sdk/akamai-edgekv/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/akamai-server-edgekv-sdk", - "version": "1.3.0", + "version": "1.4.7", "description": "Akamai LaunchDarkly EdgeWorker SDK for EdgeKV feature store", "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/sdk/akamai-edgekv", "repository": { @@ -73,7 +73,7 @@ "typescript": "5.1.6" }, "dependencies": { - "@launchdarkly/akamai-edgeworker-sdk-common": "^1.3.2", - "@launchdarkly/js-server-sdk-common": "^2.10.0" + "@launchdarkly/akamai-edgeworker-sdk-common": "^2.0.5", + "@launchdarkly/js-server-sdk-common": "^2.15.0" } } diff --git a/packages/sdk/browser/CHANGELOG.md b/packages/sdk/browser/CHANGELOG.md index 3c738012c4..9d7179021e 100644 --- a/packages/sdk/browser/CHANGELOG.md +++ b/packages/sdk/browser/CHANGELOG.md @@ -1,5 +1,77 @@ # Changelog +## [0.5.3](https://github.com/launchdarkly/js-core/compare/js-client-sdk-v0.5.2...js-client-sdk-v0.5.3) (2025-04-16) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-client-sdk-common bumped from 1.12.5 to 1.12.6 + +## [0.5.2](https://github.com/launchdarkly/js-core/compare/js-client-sdk-v0.5.1...js-client-sdk-v0.5.2) (2025-04-15) + + +### Bug Fixes + +* Handle default flush interval for browser SDK. ([#822](https://github.com/launchdarkly/js-core/issues/822)) ([2c1cc7a](https://github.com/launchdarkly/js-core/commit/2c1cc7a117fd011a329dfcc5332fddf7fd11eff9)) + +## [0.5.1](https://github.com/launchdarkly/js-core/compare/js-client-sdk-v0.5.0...js-client-sdk-v0.5.1) (2025-04-08) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-client-sdk-common bumped from 1.12.4 to 1.12.5 + +## [0.5.0](https://github.com/launchdarkly/js-core/compare/js-client-sdk-v0.4.1...js-client-sdk-v0.5.0) (2025-03-26) + + +### Features + +* Support inline context for custom and migration events ([6aadf04](https://github.com/launchdarkly/js-core/commit/6aadf0463968f89bc3df10023267244c2ade1b31)) + + +### Bug Fixes + +* Deprecate LDMigrationOpEvent.contextKeys in favor of LDMigrationOpEvent.context ([6aadf04](https://github.com/launchdarkly/js-core/commit/6aadf0463968f89bc3df10023267244c2ade1b31)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-client-sdk-common bumped from 1.12.3 to 1.12.4 + +## [0.4.1](https://github.com/launchdarkly/js-core/compare/js-client-sdk-v0.4.0...js-client-sdk-v0.4.1) (2025-02-06) + + +### Bug Fixes + +* Ensure streaming connection is closed on SDK close. ([#774](https://github.com/launchdarkly/js-core/issues/774)) ([f58e746](https://github.com/launchdarkly/js-core/commit/f58e746a089fb0cd5f6169f6c246e1f6515f5047)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-client-sdk-common bumped from 1.12.2 to 1.12.3 + +## [0.4.0](https://github.com/launchdarkly/js-core/compare/js-client-sdk-v0.3.3...js-client-sdk-v0.4.0) (2025-01-22) + + +### Features + +* Enable source maps with inlined sources for browser SDK. ([#734](https://github.com/launchdarkly/js-core/issues/734)) ([c2a87b1](https://github.com/launchdarkly/js-core/commit/c2a87b11d1eeb31bf0423e3d7dfc8e99fc940c99)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-client-sdk-common bumped from 1.12.1 to 1.12.2 + ## [0.3.3](https://github.com/launchdarkly/js-core/compare/js-client-sdk-v0.3.2...js-client-sdk-v0.3.3) (2024-11-22) diff --git a/packages/sdk/browser/__tests__/BrowserClient.test.ts b/packages/sdk/browser/__tests__/BrowserClient.test.ts index 4cba088a16..87a2422ee3 100644 --- a/packages/sdk/browser/__tests__/BrowserClient.test.ts +++ b/packages/sdk/browser/__tests__/BrowserClient.test.ts @@ -145,8 +145,9 @@ describe('given a mock platform for a BrowserClient', () => { kind: 'custom', creationDate: 1726704000000, key: 'user-key', - contextKeys: { - user: 'user-key', + context: { + key: 'user-key', + kind: 'user', }, metricValue: 1, url: 'http://browserclientintegration.com', @@ -178,8 +179,9 @@ describe('given a mock platform for a BrowserClient', () => { kind: 'custom', creationDate: 1726704000000, key: 'user-key', - contextKeys: { - user: 'user-key', + context: { + key: 'user-key', + kind: 'user', }, metricValue: 1, url: 'http://filtered.org', diff --git a/packages/sdk/browser/__tests__/BrowserDataManager.test.ts b/packages/sdk/browser/__tests__/BrowserDataManager.test.ts index 628602acf7..c467ef623a 100644 --- a/packages/sdk/browser/__tests__/BrowserDataManager.test.ts +++ b/packages/sdk/browser/__tests__/BrowserDataManager.test.ts @@ -63,6 +63,8 @@ describe('given a BrowserDataManager with mocked dependencies', () => { let diagnosticsManager: jest.Mocked; let dataManager: BrowserDataManager; let logger: LDLogger; + let eventSourceCloseMethod: jest.Mock; + beforeEach(() => { logger = { error: jest.fn(), @@ -70,6 +72,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => { info: jest.fn(), debug: jest.fn(), }; + eventSourceCloseMethod = jest.fn(); config = { logger, maxCachedContexts: 5, @@ -106,7 +109,7 @@ describe('given a BrowserDataManager with mocked dependencies', () => { options, onclose: jest.fn(), addEventListener: jest.fn(), - close: jest.fn(), + close: eventSourceCloseMethod, })), fetch: mockedFetch, getEventSourceCapabilities: jest.fn(), @@ -495,4 +498,31 @@ describe('given a BrowserDataManager with mocked dependencies', () => { expect(platform.requests.createEventSource).toHaveBeenCalled(); }); + + it('closes the event source when the data manager is closed', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: BrowserIdentifyOptions = {}; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + dataManager.setForcedStreaming(undefined); + dataManager.setAutomaticStreamingState(true); + expect(platform.requests.createEventSource).not.toHaveBeenCalled(); + + flagManager.loadCached.mockResolvedValue(false); + + await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(platform.requests.createEventSource).toHaveBeenCalled(); + + dataManager.close(); + expect(eventSourceCloseMethod).toHaveBeenCalled(); + // Verify a subsequent identify doesn't create a new event source + await dataManager.identify(identifyResolve, identifyReject, context, {}); + expect(platform.requests.createEventSource).toHaveBeenCalledTimes(1); + + expect(logger.debug).toHaveBeenCalledWith( + '[BrowserDataManager] Identify called after data manager was closed.', + ); + }); }); diff --git a/packages/sdk/browser/__tests__/options.test.ts b/packages/sdk/browser/__tests__/options.test.ts index cb1d84a67f..d8aefc63b1 100644 --- a/packages/sdk/browser/__tests__/options.test.ts +++ b/packages/sdk/browser/__tests__/options.test.ts @@ -2,7 +2,7 @@ import { jest } from '@jest/globals'; import { LDLogger } from '@launchdarkly/js-client-sdk-common'; -import validateOptions, { filterToBaseOptions } from '../src/options'; +import validateBrowserOptions, { filterToBaseOptionsWithDefaults } from '../src/options'; let logger: LDLogger; @@ -16,7 +16,7 @@ beforeEach(() => { }); it('logs no warnings when all configuration is valid', () => { - validateOptions( + validateBrowserOptions( { fetchGoals: true, eventUrlTransformer: (url: string) => url, @@ -31,7 +31,7 @@ it('logs no warnings when all configuration is valid', () => { }); it('warns for invalid configuration', () => { - validateOptions( + validateBrowserOptions( { // @ts-ignore fetchGoals: 'yes', @@ -50,8 +50,8 @@ it('warns for invalid configuration', () => { ); }); -it('applies default options', () => { - const opts = validateOptions({}, logger); +it('applies default browser-specific options', () => { + const opts = validateBrowserOptions({}, logger); expect(opts.fetchGoals).toBe(true); expect(opts.eventUrlTransformer).toBeDefined(); @@ -69,9 +69,24 @@ it('filters to base options', () => { eventUrlTransformer: (url: string) => url, }; - const baseOpts = filterToBaseOptions(opts); + const baseOpts = filterToBaseOptionsWithDefaults(opts); expect(baseOpts.debug).toBe(false); - expect(Object.keys(baseOpts).length).toEqual(1); + expect(Object.keys(baseOpts).length).toEqual(2); expect(baseOpts).not.toHaveProperty('fetchGoals'); expect(baseOpts).not.toHaveProperty('eventUrlTransformer'); + expect(baseOpts.flushInterval).toEqual(2); +}); + +it('applies default overrides to common config flushInterval', () => { + const opts = {}; + const result = filterToBaseOptionsWithDefaults(opts); + expect(result.flushInterval).toEqual(2); +}); + +it('does not override common config flushInterval if it is set', () => { + const opts = { + flushInterval: 15, + }; + const result = filterToBaseOptionsWithDefaults(opts); + expect(result.flushInterval).toEqual(15); }); diff --git a/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts b/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts index 29f5e6fa98..c1c280d483 100644 --- a/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts +++ b/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts @@ -38,7 +38,7 @@ export default class TestHarnessWebSocket { 'service-endpoints', 'tags', 'user-type', - 'inline-context', + 'inline-context-all', 'anonymous-redaction', 'strongly-typed', 'client-prereq-events', diff --git a/packages/sdk/browser/contract-tests/suppressions.txt b/packages/sdk/browser/contract-tests/suppressions.txt index 30a7f7365d..18abe27603 100644 --- a/packages/sdk/browser/contract-tests/suppressions.txt +++ b/packages/sdk/browser/contract-tests/suppressions.txt @@ -4,3 +4,15 @@ streaming/requests/URL path is computed correctly/no environment filter/base URI streaming/requests/context properties/single kind minimal/REPORT streaming/requests/context properties/single kind with all attributes/REPORT streaming/requests/context properties/multi-kind/REPORT +tags/stream requests/{"applicationId":null,"applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"} +tags/stream requests/{"applicationId":null,"applicationVersion":"________________________________________________________________"} +tags/stream requests/{"applicationId":"","applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"} +tags/stream requests/{"applicationId":"","applicationVersion":"________________________________________________________________"} +tags/stream requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":null} +tags/stream requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":""} +tags/stream requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"} +tags/stream requests/{"applicationId":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678","applicationVersion":"________________________________________________________________"} +tags/stream requests/{"applicationId":"________________________________________________________________","applicationVersion":null} +tags/stream requests/{"applicationId":"________________________________________________________________","applicationVersion":""} +tags/stream requests/{"applicationId":"________________________________________________________________","applicationVersion":"._-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678"} +tags/stream requests/{"applicationId":"________________________________________________________________","applicationVersion":"________________________________________________________________"} diff --git a/packages/sdk/browser/package.json b/packages/sdk/browser/package.json index 38350826e5..d61a02f273 100644 --- a/packages/sdk/browser/package.json +++ b/packages/sdk/browser/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/js-client-sdk", - "version": "0.3.3", + "version": "0.5.3", "description": "LaunchDarkly SDK for JavaScript in Browsers", "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/sdk/browser", "repository": { @@ -55,7 +55,7 @@ "check": "yarn prettier && yarn lint && yarn build && yarn test" }, "dependencies": { - "@launchdarkly/js-client-sdk-common": "1.12.1" + "@launchdarkly/js-client-sdk-common": "1.12.6" }, "devDependencies": { "@jest/globals": "^29.7.0", diff --git a/packages/sdk/browser/src/BrowserClient.ts b/packages/sdk/browser/src/BrowserClient.ts index 0ef3490e99..1585bdee24 100644 --- a/packages/sdk/browser/src/BrowserClient.ts +++ b/packages/sdk/browser/src/BrowserClient.ts @@ -21,7 +21,7 @@ import { BrowserIdentifyOptions as LDIdentifyOptions } from './BrowserIdentifyOp import { registerStateDetection } from './BrowserStateDetector'; import GoalManager from './goals/GoalManager'; import { Goal, isClick } from './goals/Goals'; -import validateOptions, { BrowserOptions, filterToBaseOptions } from './options'; +import validateBrowserOptions, { BrowserOptions, filterToBaseOptionsWithDefaults } from './options'; import BrowserPlatform from './platform/BrowserPlatform'; /** @@ -116,13 +116,16 @@ export class BrowserClient extends LDClientImpl implements LDClient { const baseUrl = options.baseUri ?? 'https://clientsdk.launchdarkly.com'; const platform = overridePlatform ?? new BrowserPlatform(logger); - const validatedBrowserOptions = validateOptions(options, logger); + // Only the browser-specific options are in validatedBrowserOptions. + const validatedBrowserOptions = validateBrowserOptions(options, logger); + // The base options are in baseOptionsWithDefaults. + const baseOptionsWithDefaults = filterToBaseOptionsWithDefaults({ ...options, logger }); const { eventUrlTransformer } = validatedBrowserOptions; super( clientSideId, autoEnvAttributes, platform, - filterToBaseOptions({ ...options, logger }), + baseOptionsWithDefaults, ( flagManager: FlagManager, configuration: Configuration, diff --git a/packages/sdk/browser/src/BrowserDataManager.ts b/packages/sdk/browser/src/BrowserDataManager.ts index 00f777f5e5..c25b3d6a49 100644 --- a/packages/sdk/browser/src/BrowserDataManager.ts +++ b/packages/sdk/browser/src/BrowserDataManager.ts @@ -74,6 +74,11 @@ export default class BrowserDataManager extends BaseDataManager { context: Context, identifyOptions?: LDIdentifyOptions, ): Promise { + if (this.closed) { + this._debugLog('Identify called after data manager was closed.'); + return; + } + this.context = context; const browserIdentifyOptions = identifyOptions as BrowserIdentifyOptions | undefined; if (browserIdentifyOptions?.hash) { diff --git a/packages/sdk/browser/src/options.ts b/packages/sdk/browser/src/options.ts index 89e53a4c82..afe1bbf66f 100644 --- a/packages/sdk/browser/src/options.ts +++ b/packages/sdk/browser/src/options.ts @@ -73,8 +73,14 @@ const validators: { [Property in keyof BrowserOptions]: TypeValidator | undefine streaming: TypeValidators.Boolean, }; -export function filterToBaseOptions(opts: BrowserOptions): LDOptionsBase { - const baseOptions: LDOptionsBase = { ...opts }; +function withBrowserDefaults(opts: BrowserOptions): BrowserOptions { + const output = { ...opts }; + output.flushInterval ??= DEFAULT_FLUSH_INTERVAL_SECONDS; + return output; +} + +export function filterToBaseOptionsWithDefaults(opts: BrowserOptions): LDOptionsBase { + const baseOptions: LDOptionsBase = withBrowserDefaults(opts); // Remove any browser specific configuration keys so we don't get warnings from // the base implementation for unknown configuration. @@ -84,14 +90,11 @@ export function filterToBaseOptions(opts: BrowserOptions): LDOptionsBase { return baseOptions; } -function applyBrowserDefaults(opts: BrowserOptions) { - // eslint-disable-next-line no-param-reassign - opts.flushInterval ??= DEFAULT_FLUSH_INTERVAL_SECONDS; -} - -export default function validateOptions(opts: BrowserOptions, logger: LDLogger): ValidatedOptions { +export default function validateBrowserOptions( + opts: BrowserOptions, + logger: LDLogger, +): ValidatedOptions { const output: ValidatedOptions = { ...optDefaults }; - applyBrowserDefaults(output); Object.entries(validators).forEach((entry) => { const [key, validator] = entry as [keyof BrowserOptions, TypeValidator]; diff --git a/packages/sdk/browser/tsconfig.json b/packages/sdk/browser/tsconfig.json index 7306c5b0c6..dcbbe20f3a 100644 --- a/packages/sdk/browser/tsconfig.json +++ b/packages/sdk/browser/tsconfig.json @@ -11,7 +11,8 @@ "rootDir": ".", "outDir": "dist", "skipLibCheck": true, - "sourceMap": false, + "sourceMap": true, + "inlineSources": true, "strict": true, "stripInternal": true, "target": "ES2017", diff --git a/packages/sdk/browser/tsup.config.ts b/packages/sdk/browser/tsup.config.ts index 56a856d876..e2c535a07a 100644 --- a/packages/sdk/browser/tsup.config.ts +++ b/packages/sdk/browser/tsup.config.ts @@ -10,7 +10,7 @@ export default defineConfig({ minify: true, format: ['esm', 'cjs'], splitting: false, - sourcemap: false, + sourcemap: true, clean: true, noExternal: ['@launchdarkly/js-sdk-common', '@launchdarkly/js-client-sdk-common'], dts: true, diff --git a/packages/sdk/cloudflare/CHANGELOG.md b/packages/sdk/cloudflare/CHANGELOG.md index a8f10ce1b4..c0463a3f80 100644 --- a/packages/sdk/cloudflare/CHANGELOG.md +++ b/packages/sdk/cloudflare/CHANGELOG.md @@ -21,6 +21,74 @@ All notable changes to the LaunchDarkly SDK for Cloudflare Workers will be docum * devDependencies * @launchdarkly/js-server-sdk-common-edge bumped from 2.2.1 to 2.2.2 +## [2.7.4](https://github.com/launchdarkly/js-core/compare/cloudflare-server-sdk-v2.7.3...cloudflare-server-sdk-v2.7.4) (2025-04-16) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common-edge bumped from 2.6.3 to 2.6.4 + +## [2.7.3](https://github.com/launchdarkly/js-core/compare/cloudflare-server-sdk-v2.7.2...cloudflare-server-sdk-v2.7.3) (2025-04-08) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common-edge bumped from 2.6.2 to 2.6.3 + +## [2.7.2](https://github.com/launchdarkly/js-core/compare/cloudflare-server-sdk-v2.7.1...cloudflare-server-sdk-v2.7.2) (2025-03-26) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common-edge bumped from 2.6.1 to 2.6.2 + +## [2.7.1](https://github.com/launchdarkly/js-core/compare/cloudflare-server-sdk-v2.7.0...cloudflare-server-sdk-v2.7.1) (2025-03-21) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common-edge bumped from 2.6.0 to 2.6.1 + +## [2.7.0](https://github.com/launchdarkly/js-core/compare/cloudflare-server-sdk-v2.6.5...cloudflare-server-sdk-v2.7.0) (2025-03-17) + + +### Features + +* Add TTL caching for data store ([#801](https://github.com/launchdarkly/js-core/issues/801)) ([c1de485](https://github.com/launchdarkly/js-core/commit/c1de4850c81dff8ad52276c2bfc2a2aeb87bd2d9)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common-edge bumped from 2.5.4 to 2.6.0 + +## [2.6.5](https://github.com/launchdarkly/js-core/compare/cloudflare-server-sdk-v2.6.4...cloudflare-server-sdk-v2.6.5) (2025-02-18) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common-edge bumped from 2.5.3 to 2.5.4 + +## [2.6.4](https://github.com/launchdarkly/js-core/compare/cloudflare-server-sdk-v2.6.3...cloudflare-server-sdk-v2.6.4) (2025-01-22) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common-edge bumped from 2.5.2 to 2.5.3 + ## [2.6.3](https://github.com/launchdarkly/js-core/compare/cloudflare-server-sdk-v2.6.2...cloudflare-server-sdk-v2.6.3) (2024-12-12) diff --git a/packages/sdk/cloudflare/__tests__/index.test.ts b/packages/sdk/cloudflare/__tests__/index.test.ts index 941438c0a5..5b1f760fed 100644 --- a/packages/sdk/cloudflare/__tests__/index.test.ts +++ b/packages/sdk/cloudflare/__tests__/index.test.ts @@ -21,87 +21,155 @@ const namespace = 'LD_KV'; const rootEnvKey = `LD-Env-${clientSideID}`; describe('init', () => { - let kv: KVNamespace; - let ldClient: LDClient; - - beforeAll(async () => { - kv = (await mf.getKVNamespace(namespace)) as unknown as KVNamespace; - await kv.put(rootEnvKey, JSON.stringify(allFlagsSegments)); - ldClient = init(clientSideID, kv); - await ldClient.waitForInitialization(); - }); - - afterAll(() => { - ldClient.close(); - }); + describe('without caching', () => { + let kv: KVNamespace; + let ldClient: LDClient; + + beforeAll(async () => { + kv = (await mf.getKVNamespace(namespace)) as unknown as KVNamespace; + await kv.put(rootEnvKey, JSON.stringify(allFlagsSegments)); + ldClient = init(clientSideID, kv); + await ldClient.waitForInitialization(); + }); - describe('flags', () => { - test('variation default', async () => { - const value = await ldClient.variation(flagKey1, context, false); - expect(value).toBeTruthy(); + afterAll(() => { + ldClient.close(); }); - test('variation default rollout', async () => { - const contextWithEmail = { ...context, email: 'test@yahoo.com' }; - const value = await ldClient.variation(flagKey2, contextWithEmail, false); - const detail = await ldClient.variationDetail(flagKey2, contextWithEmail, false); + describe('flags', () => { + it('variation default', async () => { + const value = await ldClient.variation(flagKey1, context, false); + expect(value).toBeTruthy(); + }); - expect(detail).toEqual({ reason: { kind: 'FALLTHROUGH' }, value: true, variationIndex: 0 }); - expect(value).toBeTruthy(); - }); + it('variation default rollout', async () => { + const contextWithEmail = { ...context, email: 'test@yahoo.com' }; + const value = await ldClient.variation(flagKey2, contextWithEmail, false); + const detail = await ldClient.variationDetail(flagKey2, contextWithEmail, false); - test('rule match', async () => { - const contextWithEmail = { ...context, email: 'test@falsemail.com' }; - const value = await ldClient.variation(flagKey1, contextWithEmail, false); - const detail = await ldClient.variationDetail(flagKey1, contextWithEmail, false); + expect(detail).toEqual({ reason: { kind: 'FALLTHROUGH' }, value: true, variationIndex: 0 }); + expect(value).toBeTruthy(); + }); - expect(detail).toEqual({ - reason: { kind: 'RULE_MATCH', ruleId: 'rule1', ruleIndex: 0 }, - value: false, - variationIndex: 1, + it('rule match', async () => { + const contextWithEmail = { ...context, email: 'test@falsemail.com' }; + const value = await ldClient.variation(flagKey1, contextWithEmail, false); + const detail = await ldClient.variationDetail(flagKey1, contextWithEmail, false); + + expect(detail).toEqual({ + reason: { kind: 'RULE_MATCH', ruleId: 'rule1', ruleIndex: 0 }, + value: false, + variationIndex: 1, + }); + expect(value).toBeFalsy(); }); - expect(value).toBeFalsy(); - }); - test('fallthrough', async () => { - const contextWithEmail = { ...context, email: 'test@yahoo.com' }; - const value = await ldClient.variation(flagKey1, contextWithEmail, false); - const detail = await ldClient.variationDetail(flagKey1, contextWithEmail, false); + it('fallthrough', async () => { + const contextWithEmail = { ...context, email: 'test@yahoo.com' }; + const value = await ldClient.variation(flagKey1, contextWithEmail, false); + const detail = await ldClient.variationDetail(flagKey1, contextWithEmail, false); - expect(detail).toEqual({ reason: { kind: 'FALLTHROUGH' }, value: true, variationIndex: 0 }); - expect(value).toBeTruthy(); + expect(detail).toEqual({ reason: { kind: 'FALLTHROUGH' }, value: true, variationIndex: 0 }); + expect(value).toBeTruthy(); + }); + + it('allFlags fallthrough', async () => { + const allFlags = await ldClient.allFlagsState(context); + + expect(allFlags).toBeDefined(); + expect(allFlags.toJSON()).toEqual({ + $flagsState: { + testFlag1: { debugEventsUntilDate: 2000, variation: 0, version: 2 }, + testFlag2: { debugEventsUntilDate: 2000, variation: 0, version: 2 }, + testFlag3: { debugEventsUntilDate: 2000, variation: 0, version: 2 }, + }, + $valid: true, + testFlag1: true, + testFlag2: true, + testFlag3: true, + }); + }); }); - test('allFlags fallthrough', async () => { - const allFlags = await ldClient.allFlagsState(context); - - expect(allFlags).toBeDefined(); - expect(allFlags.toJSON()).toEqual({ - $flagsState: { - testFlag1: { debugEventsUntilDate: 2000, variation: 0, version: 2 }, - testFlag2: { debugEventsUntilDate: 2000, variation: 0, version: 2 }, - testFlag3: { debugEventsUntilDate: 2000, variation: 0, version: 2 }, - }, - $valid: true, - testFlag1: true, - testFlag2: true, - testFlag3: true, + describe('segments', () => { + it('segment by country', async () => { + const contextWithCountry = { ...context, country: 'australia' }; + const value = await ldClient.variation(flagKey3, contextWithCountry, false); + const detail = await ldClient.variationDetail(flagKey3, contextWithCountry, false); + + expect(detail).toEqual({ + reason: { kind: 'RULE_MATCH', ruleId: 'rule1', ruleIndex: 0 }, + value: false, + variationIndex: 1, + }); + expect(value).toBeFalsy(); }); }); }); - describe('segments', () => { - test('segment by country', async () => { - const contextWithCountry = { ...context, country: 'australia' }; - const value = await ldClient.variation(flagKey3, contextWithCountry, false); - const detail = await ldClient.variationDetail(flagKey3, contextWithCountry, false); + describe('with caching', () => { + it('will cache across multiple variation calls', async () => { + const kv = (await mf.getKVNamespace(namespace)) as unknown as KVNamespace; + await kv.put(rootEnvKey, JSON.stringify(allFlagsSegments)); + const ldClient = init(clientSideID, kv, { cache: { ttl: 60, checkInterval: 600 } }); - expect(detail).toEqual({ - reason: { kind: 'RULE_MATCH', ruleId: 'rule1', ruleIndex: 0 }, - value: false, - variationIndex: 1, - }); - expect(value).toBeFalsy(); + await ldClient.waitForInitialization(); + const spy = jest.spyOn(kv, 'get'); + await ldClient.variation(flagKey1, context, false); + await ldClient.variation(flagKey2, context, false); + ldClient.close(); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('will cache across multiple allFlags calls', async () => { + const kv = (await mf.getKVNamespace(namespace)) as unknown as KVNamespace; + await kv.put(rootEnvKey, JSON.stringify(allFlagsSegments)); + const ldClient = init(clientSideID, kv, { cache: { ttl: 60, checkInterval: 600 } }); + + await ldClient.waitForInitialization(); + const spy = jest.spyOn(kv, 'get'); + await ldClient.allFlagsState(context); + await ldClient.allFlagsState(context); + ldClient.close(); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('will cache between allFlags and variation', async () => { + const kv = (await mf.getKVNamespace(namespace)) as unknown as KVNamespace; + await kv.put(rootEnvKey, JSON.stringify(allFlagsSegments)); + const ldClient = init(clientSideID, kv, { cache: { ttl: 60, checkInterval: 600 } }); + + await ldClient.waitForInitialization(); + const spy = jest.spyOn(kv, 'get'); + await ldClient.variation(flagKey1, context, false); + await ldClient.allFlagsState(context); + ldClient.close(); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('will eventually expire', async () => { + jest.spyOn(Date, 'now').mockImplementation(() => 0); + + const kv = (await mf.getKVNamespace(namespace)) as unknown as KVNamespace; + await kv.put(rootEnvKey, JSON.stringify(allFlagsSegments)); + const ldClient = init(clientSideID, kv, { cache: { ttl: 60, checkInterval: 600 } }); + + await ldClient.waitForInitialization(); + const spy = jest.spyOn(kv, 'get'); + await ldClient.variation(flagKey1, context, false); + await ldClient.variation(flagKey2, context, false); + + expect(spy).toHaveBeenCalledTimes(1); + + jest.spyOn(Date, 'now').mockImplementation(() => 60 * 1000 + 1); + + await ldClient.variation(flagKey2, context, false); + expect(spy).toHaveBeenCalledTimes(2); + + ldClient.close(); }); }); }); diff --git a/packages/sdk/cloudflare/example/package.json b/packages/sdk/cloudflare/example/package.json index 85621693f7..82bffc8fe7 100644 --- a/packages/sdk/cloudflare/example/package.json +++ b/packages/sdk/cloudflare/example/package.json @@ -5,7 +5,7 @@ "module": "./dist/index.mjs", "packageManager": "yarn@3.4.1", "dependencies": { - "@launchdarkly/cloudflare-server-sdk": "2.6.3" + "@launchdarkly/cloudflare-server-sdk": "2.7.4" }, "devDependencies": { "@cloudflare/workers-types": "^4.20230321.0", diff --git a/packages/sdk/cloudflare/jsr.json b/packages/sdk/cloudflare/jsr.json index 3a7baa7fda..22a896e1c5 100644 --- a/packages/sdk/cloudflare/jsr.json +++ b/packages/sdk/cloudflare/jsr.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/cloudflare-server-sdk", - "version": "2.6.3", + "version": "2.7.4", "exports": "./src/index.ts", "publish": { "include": [ diff --git a/packages/sdk/cloudflare/package.json b/packages/sdk/cloudflare/package.json index 4c03c1342c..0a2fc11b3d 100644 --- a/packages/sdk/cloudflare/package.json +++ b/packages/sdk/cloudflare/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/cloudflare-server-sdk", - "version": "2.6.3", + "version": "2.7.4", "description": "Cloudflare LaunchDarkly SDK", "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/sdk/cloudflare", "repository": { @@ -41,7 +41,7 @@ }, "dependencies": { "@cloudflare/workers-types": "^4.20230321.0", - "@launchdarkly/js-server-sdk-common-edge": "2.5.2", + "@launchdarkly/js-server-sdk-common-edge": "2.6.4", "crypto-js": "^4.1.1" }, "devDependencies": { diff --git a/packages/sdk/cloudflare/src/createPlatformInfo.ts b/packages/sdk/cloudflare/src/createPlatformInfo.ts index 71536a8d82..adf3bade99 100644 --- a/packages/sdk/cloudflare/src/createPlatformInfo.ts +++ b/packages/sdk/cloudflare/src/createPlatformInfo.ts @@ -1,7 +1,7 @@ import type { Info, PlatformData, SdkData } from '@launchdarkly/js-server-sdk-common-edge'; const name = '@launchdarkly/cloudflare-server-sdk'; -const version = '2.6.3'; // x-release-please-version +const version = '2.7.4'; // x-release-please-version class CloudflarePlatformInfo implements Info { platformData(): PlatformData { diff --git a/packages/sdk/cloudflare/src/index.ts b/packages/sdk/cloudflare/src/index.ts index 064e6e969b..d4986ad1c5 100644 --- a/packages/sdk/cloudflare/src/index.ts +++ b/packages/sdk/cloudflare/src/index.ts @@ -14,14 +14,28 @@ import { BasicLogger, EdgeFeatureStore, init as initEdge, + internalServer, type LDClient, - type LDOptions, + type LDOptions as LDOptionsCommon, } from '@launchdarkly/js-server-sdk-common-edge'; import createPlatformInfo from './createPlatformInfo'; export * from '@launchdarkly/js-server-sdk-common-edge'; +export type TtlCacheOptions = internalServer.TtlCacheOptions; + +/** + * The Launchdarkly Edge SDKs configuration options. + */ +type LDOptions = { + /** + * Optional TTL cache configuration which allows for caching feature flags in + * memory. + */ + cache?: TtlCacheOptions; +} & LDOptionsCommon; + export type { LDClient }; /** @@ -41,7 +55,7 @@ export type { LDClient }; * @param kvNamespace * The Cloudflare KV configured for LaunchDarkly. * @param options - * Optional configuration settings. The only supported option is logger. + * Optional configuration settings. * @return * The new {@link LDClient} instance. */ @@ -51,9 +65,12 @@ export const init = ( options: LDOptions = {}, ): LDClient => { const logger = options.logger ?? BasicLogger.get(); + + const { cache: _cacheOptions, ...rest } = options; + const cache = options.cache ? new internalServer.TtlCache(options.cache) : undefined; return initEdge(clientSideID, createPlatformInfo(), { - featureStore: new EdgeFeatureStore(kvNamespace, clientSideID, 'Cloudflare', logger), + featureStore: new EdgeFeatureStore(kvNamespace, clientSideID, 'Cloudflare', logger, cache), logger, - ...options, + ...rest, }); }; diff --git a/packages/sdk/fastly/CHANGELOG.md b/packages/sdk/fastly/CHANGELOG.md new file mode 100644 index 0000000000..e7bb918d0d --- /dev/null +++ b/packages/sdk/fastly/CHANGELOG.md @@ -0,0 +1,58 @@ +# Changelog + +## [0.1.5](https://github.com/launchdarkly/js-core/compare/fastly-server-sdk-v0.1.4...fastly-server-sdk-v0.1.5) (2025-04-16) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from 2.14.0 to 2.15.0 + +## [0.1.4](https://github.com/launchdarkly/js-core/compare/fastly-server-sdk-v0.1.3...fastly-server-sdk-v0.1.4) (2025-04-08) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from 2.13.0 to 2.14.0 + +## [0.1.3](https://github.com/launchdarkly/js-core/compare/fastly-server-sdk-v0.1.2...fastly-server-sdk-v0.1.3) (2025-03-26) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from 2.12.1 to 2.13.0 + +## [0.1.2](https://github.com/launchdarkly/js-core/compare/fastly-server-sdk-v0.1.1...fastly-server-sdk-v0.1.2) (2025-03-21) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from 2.12.0 to 2.12.1 + +## [0.1.1](https://github.com/launchdarkly/js-core/compare/fastly-server-sdk-v0.1.0...fastly-server-sdk-v0.1.1) (2025-03-17) + + +### Bug Fixes + +* Remove logging of SDK option configurations ([#806](https://github.com/launchdarkly/js-core/issues/806)) ([a76d196](https://github.com/launchdarkly/js-core/commit/a76d19690a7ef5932c36bfc974affc0a192c2d4f)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from 2.11.1 to 2.12.0 + +## [0.1.0](https://github.com/launchdarkly/js-core/compare/fastly-server-sdk-v0.0.1...fastly-server-sdk-v0.1.0) (2025-03-10) + + +### Features + +* Add Fastly Edge SDK ([#723](https://github.com/launchdarkly/js-core/issues/723)) ([02e0eee](https://github.com/launchdarkly/js-core/commit/02e0eeea8678e66911eb28c5ccca59e4956a1457)) diff --git a/packages/sdk/fastly/README.md b/packages/sdk/fastly/README.md new file mode 100644 index 0000000000..dbf0eb9d87 --- /dev/null +++ b/packages/sdk/fastly/README.md @@ -0,0 +1,67 @@ +# LaunchDarkly SDK for Fastly + +[![NPM][sdk-fastly-npm-badge]][sdk-fastly-npm-link] +[![Actions Status][sdk-fastly-ci-badge]][sdk-fastly-ci] +[![Documentation][sdk-fastly-ghp-badge]][sdk-fastly-ghp-link] +[![NPM][sdk-fastly-dm-badge]][sdk-fastly-npm-link] +[![NPM][sdk-fastly-dt-badge]][sdk-fastly-npm-link] + +The LaunchDarkly SDK for Fastly is designed for use in [Fastly Compute Platform](https://www.fastly.com/documentation/guides/compute/). It follows the server-side LaunchDarkly model for multi-user contexts. It is not intended for use in desktop and embedded systems applications. + +## Install + +```shell +# npm +npm i @launchdarkly/fastly-server-sdk + +# yarn +yarn add @launchdarkly/fastly-server-sdk +``` + +## Usage notes + +- The SDK must be initialized and used when processing requests, not during build-time initialization. +- The SDK caches all KV data during initialization to reduce the number of backend requests needed to fetch KV data. This means changes to feature flags or segments will not be picked up during the lifecycle of a single request instance. +- Events should flushed using the [`waitUntil()` method](https://js-compute-reference-docs.edgecompute.app/docs/globals/FetchEvent/prototype/waitUntil). + +## Quickstart + +See the full [example app](https://github.com/launchdarkly/js-core/tree/main/packages/sdk/fastly/example). + +## Developing this SDK + +```shell +# at js-core repo root +yarn && yarn build && cd packages/sdk/fastly + +# run tests +yarn test +``` + +## Verifying SDK build provenance with the SLSA framework + +LaunchDarkly uses the [SLSA framework](https://slsa.dev/spec/v1.0/about) (Supply-chain Levels for Software Artifacts) to help developers make their supply chain more secure by ensuring the authenticity and build integrity of our published SDK packages. To learn more, see the [provenance guide](PROVENANCE.md). + +## About LaunchDarkly + +- LaunchDarkly is a continuous delivery platform that provides feature flags as a service and allows developers to iterate quickly and safely. We allow you to easily flag your features and manage them from the LaunchDarkly dashboard. With LaunchDarkly, you can: + - Roll out a new feature to a subset of your users (like a group of users who opt-in to a beta tester group), gathering feedback and bug reports from real-world use cases. + - Gradually roll out a feature to an increasing percentage of users, and track the effect that the feature has on key metrics (for instance, how likely is a user to complete a purchase if they have feature A versus feature B?). + - Turn off a feature that you realize is causing performance problems in production, without needing to re-deploy, or even restart the application with a changed configuration file. + - Grant access to certain features based on user attributes, like payment plan (eg: users on the 'gold' plan get access to more features than users in the 'silver' plan). + - Disable parts of your application to facilitate maintenance, without taking everything offline. +- LaunchDarkly provides feature flag SDKs for a wide variety of languages and technologies. Read [our documentation](https://docs.launchdarkly.com/sdk) for a complete list. +- Explore LaunchDarkly + - [launchdarkly.com](https://www.launchdarkly.com/ 'LaunchDarkly Main Website') for more information + - [docs.launchdarkly.com](https://docs.launchdarkly.com/ 'LaunchDarkly Documentation') for our documentation and SDK reference guides + - [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ 'LaunchDarkly API Documentation') for our API documentation + - [blog.launchdarkly.com](https://blog.launchdarkly.com/ 'LaunchDarkly Blog Documentation') for the latest product updates + +[sdk-fastly-ci-badge]: https://github.com/launchdarkly/js-core/actions/workflows/fastly.yml/badge.svg +[sdk-fastly-ci]: https://github.com/launchdarkly/js-core/actions/workflows/fastly.yml +[sdk-fastly-npm-badge]: https://img.shields.io/npm/v/@launchdarkly/fastly-server-sdk.svg?style=flat-square +[sdk-fastly-npm-link]: https://www.npmjs.com/package/@launchdarkly/fastly-server-sdk +[sdk-fastly-ghp-badge]: https://img.shields.io/static/v1?label=GitHub+Pages&message=API+reference&color=00add8 +[sdk-fastly-ghp-link]: https://launchdarkly.github.io/js-core/packages/sdk/fastly/docs/ +[sdk-fastly-dm-badge]: https://img.shields.io/npm/dm/@launchdarkly/fastly-server-sdk.svg?style=flat-square +[sdk-fastly-dt-badge]: https://img.shields.io/npm/dt/@launchdarkly/fastly-server-sdk.svg?style=flat-square diff --git a/packages/sdk/fastly/__mocks__/fastly:kv-store.ts b/packages/sdk/fastly/__mocks__/fastly:kv-store.ts new file mode 100644 index 0000000000..583bcc1d7e --- /dev/null +++ b/packages/sdk/fastly/__mocks__/fastly:kv-store.ts @@ -0,0 +1,8 @@ +export const KVStore = jest.fn().mockImplementation(() => ({ + get: jest.fn(), + put: jest.fn(), + delete: jest.fn(), + getMulti: jest.fn(), + putMulti: jest.fn(), + deleteMulti: jest.fn(), +})); diff --git a/packages/sdk/fastly/__tests__/api/EdgeFeatureStore.test.ts b/packages/sdk/fastly/__tests__/api/EdgeFeatureStore.test.ts new file mode 100644 index 0000000000..24372266ac --- /dev/null +++ b/packages/sdk/fastly/__tests__/api/EdgeFeatureStore.test.ts @@ -0,0 +1,130 @@ +import { AsyncStoreFacade, LDFeatureStore } from '@launchdarkly/js-server-sdk-common'; + +import { EdgeFeatureStore } from '../../src/api/EdgeFeatureStore'; +import mockEdgeProvider from '../utils/mockEdgeProvider'; +import * as testData from './testData.json'; + +describe('EdgeFeatureStore', () => { + const clientSideId = 'client-side-id'; + const kvKey = `LD-Env-${clientSideId}`; + const mockLogger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; + const mockGet = mockEdgeProvider.get as jest.Mock; + let featureStore: LDFeatureStore; + let asyncFeatureStore: AsyncStoreFacade; + + beforeEach(() => { + mockGet.mockImplementation(() => Promise.resolve(JSON.stringify(testData))); + featureStore = new EdgeFeatureStore( + mockEdgeProvider, + clientSideId, + 'MockEdgeProvider', + mockLogger, + ); + asyncFeatureStore = new AsyncStoreFacade(featureStore); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('get', () => { + it('can retrieve valid flag', async () => { + const flag = await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag1'); + + expect(mockGet).toHaveBeenCalledWith(kvKey); + expect(flag).toMatchObject(testData.flags.testFlag1); + }); + + it('returns undefined for invalid flag key', async () => { + const flag = await asyncFeatureStore.get({ namespace: 'features' }, 'invalid'); + + expect(flag).toBeUndefined(); + }); + + it('can retrieve valid segment', async () => { + const segment = await asyncFeatureStore.get({ namespace: 'segments' }, 'testSegment1'); + + expect(mockGet).toHaveBeenCalledWith(kvKey); + expect(segment).toMatchObject(testData.segments.testSegment1); + }); + + it('returns undefined for invalid segment key', async () => { + const segment = await asyncFeatureStore.get({ namespace: 'segments' }, 'invalid'); + + expect(segment).toBeUndefined(); + }); + + it('returns null for invalid kv key', async () => { + mockGet.mockImplementation(() => Promise.resolve(null)); + const flag = await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag1'); + + expect(flag).toBeNull(); + }); + }); + + describe('all', () => { + it('can retrieve all flags', async () => { + const flags = await asyncFeatureStore.all({ namespace: 'features' }); + + expect(mockGet).toHaveBeenCalledWith(kvKey); + expect(flags).toMatchObject(testData.flags); + }); + + it('can retrieve all segments', async () => { + const segment = await asyncFeatureStore.all({ namespace: 'segments' }); + + expect(mockGet).toHaveBeenCalledWith(kvKey); + expect(segment).toMatchObject(testData.segments); + }); + + it('returns empty object for invalid DataKind', async () => { + const flag = await asyncFeatureStore.all({ namespace: 'InvalidDataKind' }); + + expect(flag).toEqual({}); + }); + + it('returns empty object for invalid kv key', async () => { + mockGet.mockImplementation(() => Promise.resolve(null)); + const segment = await asyncFeatureStore.all({ namespace: 'segments' }); + + expect(segment).toEqual({}); + }); + }); + + describe('initialized', () => { + it('returns true when initialized', async () => { + const isInitialized = await asyncFeatureStore.initialized(); + + expect(mockGet).toHaveBeenCalledWith(kvKey); + expect(isInitialized).toBeTruthy(); + }); + + it('returns false when not initialized', async () => { + mockGet.mockImplementation(() => Promise.resolve(null)); + const isInitialized = await asyncFeatureStore.initialized(); + + expect(mockGet).toHaveBeenCalledWith(kvKey); + expect(isInitialized).toBeFalsy(); + }); + }); + + describe('init & getDescription', () => { + it('can initialize', (done) => { + const cb = jest.fn(() => { + done(); + }); + featureStore.init(testData, cb); + }); + + it('can retrieve description', async () => { + const description = featureStore.getDescription?.(); + + expect(description).toEqual('MockEdgeProvider'); + }); + }); +}); diff --git a/packages/sdk/fastly/__tests__/api/LDClient.test.ts b/packages/sdk/fastly/__tests__/api/LDClient.test.ts new file mode 100644 index 0000000000..b4d2e71fe1 --- /dev/null +++ b/packages/sdk/fastly/__tests__/api/LDClient.test.ts @@ -0,0 +1,68 @@ +import { internal } from '@launchdarkly/js-server-sdk-common'; + +import LDClient from '../../src/api/LDClient'; +import { createBasicPlatform } from '../createBasicPlatform'; + +jest.mock('@launchdarkly/js-sdk-common', () => { + const actual = jest.requireActual('@launchdarkly/js-sdk-common'); + return { + ...actual, + ...{ + internal: { + ...actual.internal, + DiagnosticsManager: jest.fn(), + EventProcessor: jest.fn(), + }, + }, + }; +}); + +let mockEventProcessor = internal.EventProcessor as jest.Mock; +beforeEach(() => { + mockEventProcessor = internal.EventProcessor as jest.Mock; + mockEventProcessor.mockClear(); +}); + +describe('Edge LDClient', () => { + it('uses clientSideID endpoints', async () => { + const client = new LDClient('client-side-id', createBasicPlatform().info, { + sendEvents: true, + eventsBackendName: 'launchdarkly', + }); + await client.waitForInitialization({ timeout: 10 }); + const passedConfig = mockEventProcessor.mock.calls[0][0]; + + expect(passedConfig).toMatchObject({ + sendEvents: true, + serviceEndpoints: { + includeAuthorizationHeader: false, + analyticsEventPath: '/events/bulk/client-side-id', + diagnosticEventPath: '/events/diagnostic/client-side-id', + events: 'https://events.launchdarkly.com', + polling: 'https://sdk.launchdarkly.com', + streaming: 'https://stream.launchdarkly.com', + }, + }); + }); + it('uses custom eventsUri when specified', async () => { + const client = new LDClient('client-side-id', createBasicPlatform().info, { + sendEvents: true, + eventsBackendName: 'launchdarkly', + eventsUri: 'https://custom-base-uri.launchdarkly.com', + }); + await client.waitForInitialization({ timeout: 10 }); + const passedConfig = mockEventProcessor.mock.calls[0][0]; + + expect(passedConfig).toMatchObject({ + sendEvents: true, + serviceEndpoints: { + includeAuthorizationHeader: false, + analyticsEventPath: '/events/bulk/client-side-id', + diagnosticEventPath: '/events/diagnostic/client-side-id', + events: 'https://custom-base-uri.launchdarkly.com', + polling: 'https://custom-base-uri.launchdarkly.com', + streaming: 'https://stream.launchdarkly.com', + }, + }); + }); +}); diff --git a/packages/sdk/fastly/__tests__/api/createOptions.test.ts b/packages/sdk/fastly/__tests__/api/createOptions.test.ts new file mode 100644 index 0000000000..02fcc4b45d --- /dev/null +++ b/packages/sdk/fastly/__tests__/api/createOptions.test.ts @@ -0,0 +1,17 @@ +import { BasicLogger } from '@launchdarkly/js-server-sdk-common'; + +import createOptions, { defaultOptions } from '../../src/api/createOptions'; + +describe('createOptions', () => { + test('default options', () => { + expect(createOptions({})).toEqual(defaultOptions); + }); + + test('override logger', () => { + const logger = new BasicLogger({ name: 'test' }); + expect(createOptions({ logger })).toEqual({ + ...defaultOptions, + logger, + }); + }); +}); diff --git a/packages/sdk/fastly/__tests__/api/testData.json b/packages/sdk/fastly/__tests__/api/testData.json new file mode 100644 index 0000000000..b9e5296c03 --- /dev/null +++ b/packages/sdk/fastly/__tests__/api/testData.json @@ -0,0 +1,171 @@ +{ + "flags": { + "testFlag1": { + "key": "testFlag1", + "on": true, + "prerequisites": [], + "targets": [], + "rules": [ + { + "variation": 1, + "id": "rule1", + "clauses": [ + { + "contextKind": "user", + "attribute": "/email", + "op": "contains", + "values": ["gmail"], + "negate": false + } + ], + "trackEvents": false, + "rollout": { + "bucketBy": "bucket", + "variations": [{ "variation": 1, "weight": 100 }] + } + } + ], + "fallthrough": { + "variation": 0 + }, + "offVariation": 1, + "variations": [true, false], + "clientSideAvailability": { + "usingMobileKey": true, + "usingEnvironmentId": true + }, + "clientSide": true, + "salt": "aef830243d6640d0a973be89988e008d", + "trackEvents": false, + "trackEventsFallthrough": false, + "debugEventsUntilDate": 2000, + "version": 2, + "deleted": false + }, + "testFlag2": { + "key": "testFlag2", + "on": true, + "prerequisites": [], + "targets": [], + "rules": [], + "fallthrough": { + "variation": 0, + "rollout": { + "bucketBy": "bucket", + "variations": [{ "variation": 1, "weight": 100 }], + "contextKind:": "user", + "attribute": "/email" + } + }, + "offVariation": 1, + "variations": [true, false], + "clientSideAvailability": { + "usingMobileKey": true, + "usingEnvironmentId": true + }, + "clientSide": true, + "salt": "aef830243d6640d0a973be89988e008d", + "trackEvents": false, + "trackEventsFallthrough": false, + "debugEventsUntilDate": 2000, + "version": 2, + "deleted": false + }, + "testFlag3": { + "key": "testFlag3", + "on": true, + "prerequisites": [], + "targets": [], + "rules": [ + { + "variation": 1, + "id": "rule1", + "clauses": [ + { + "op": "segmentMatch", + "values": ["testSegment1"], + "negate": false + } + ], + "trackEvents": false + } + ], + "fallthrough": { + "variation": 0 + }, + "offVariation": 1, + "variations": [true, false], + "clientSideAvailability": { + "usingMobileKey": true, + "usingEnvironmentId": true + }, + "clientSide": true, + "salt": "aef830243d6640d0a973be89988e008d", + "trackEvents": false, + "trackEventsFallthrough": false, + "debugEventsUntilDate": 2000, + "version": 2, + "deleted": false + } + }, + "segments": { + "testSegment1": { + "name": "testSegment1", + "tags": [], + "creationDate": 1676063792158, + "key": "testSegment1", + "included": [], + "excluded": [], + "includedContexts": [], + "excludedContexts": [], + "_links": { + "parent": { "href": "/api/v2/segments/default/test", "type": "application/json" }, + "self": { + "href": "/api/v2/segments/default/test/beta-users-1", + "type": "application/json" + }, + "site": { "href": "/default/test/segments/beta-users-1", "type": "text/html" } + }, + "rules": [ + { + "id": "rule-country", + "clauses": [ + { + "attribute": "country", + "op": "in", + "values": ["australia"], + "negate": false + } + ] + } + ], + "version": 1, + "deleted": false, + "_access": { "denied": [], "allowed": [] }, + "generation": 1 + }, + "testSegment2": { + "name": "testSegment2", + "tags": [], + "creationDate": 1676063792158, + "key": "testSegment2", + "included": [], + "excluded": [], + "includedContexts": [], + "excludedContexts": [], + "_links": { + "parent": { "href": "/api/v2/segments/default/test", "type": "application/json" }, + "self": { + "href": "/api/v2/segments/default/test/beta-users-1", + "type": "application/json" + }, + "site": { "href": "/default/test/segments/beta-users-1", "type": "text/html" } + }, + "rules": [], + "version": 1, + "deleted": false, + "_access": { "denied": [], "allowed": [] }, + "generation": 1 + } + } +} diff --git a/packages/sdk/fastly/__tests__/createBasicPlatform.ts b/packages/sdk/fastly/__tests__/createBasicPlatform.ts new file mode 100644 index 0000000000..e5139ccec6 --- /dev/null +++ b/packages/sdk/fastly/__tests__/createBasicPlatform.ts @@ -0,0 +1,59 @@ +import { PlatformData, SdkData } from '@launchdarkly/js-server-sdk-common'; + +import { setupCrypto } from './setupCrypto'; + +const setupInfo = () => ({ + platformData: jest.fn( + (): PlatformData => ({ + os: { + name: 'An OS', + version: '1.0.1', + arch: 'An Arch', + }, + name: 'The SDK Name', + additional: { + nodeVersion: '42', + }, + ld_application: { + key: '', + envAttributesVersion: '1.0', + id: 'com.testapp.ld', + name: 'LDApplication.TestApp', + version: '1.1.1', + }, + ld_device: { + key: '', + envAttributesVersion: '1.0', + os: { name: 'Another OS', version: '99', family: 'orange' }, + manufacturer: 'coconut', + }, + }), + ), + sdkData: jest.fn( + (): SdkData => ({ + name: 'An SDK', + version: '2.0.2', + userAgentBase: 'TestUserAgent', + wrapperName: 'Rapper', + wrapperVersion: '1.2.3', + }), + ), +}); + +export const createBasicPlatform = () => ({ + encoding: { + btoa: (s: string) => Buffer.from(s).toString('base64'), + }, + info: setupInfo(), + crypto: setupCrypto(), + requests: { + fetch: jest.fn(), + createEventSource: jest.fn(), + getEventSourceCapabilities: jest.fn(), + }, + storage: { + get: jest.fn(), + set: jest.fn(), + clear: jest.fn(), + }, +}); diff --git a/packages/sdk/fastly/__tests__/createPlatformInfo.test.ts b/packages/sdk/fastly/__tests__/createPlatformInfo.test.ts new file mode 100644 index 0000000000..6fef56b7e2 --- /dev/null +++ b/packages/sdk/fastly/__tests__/createPlatformInfo.test.ts @@ -0,0 +1,19 @@ +import createPlatformInfo from '../src/createPlatformInfo'; + +const version = '0.1.5'; // x-release-please-version + +describe('Fastly Platform Info', () => { + it('platformData shows correct information', () => { + const platformData = createPlatformInfo(); + + expect(platformData.platformData()).toEqual({ + name: 'Fastly Compute', + }); + + expect(platformData.sdkData()).toEqual({ + name: '@launchdarkly/fastly-server-sdk', + version, + userAgentBase: 'FastlyEdgeSDK', + }); + }); +}); diff --git a/packages/sdk/fastly/__tests__/index.test.ts b/packages/sdk/fastly/__tests__/index.test.ts new file mode 100644 index 0000000000..37fa7ee6f8 --- /dev/null +++ b/packages/sdk/fastly/__tests__/index.test.ts @@ -0,0 +1,111 @@ +/// +import { KVStore } from 'fastly:kv-store'; + +import { LDClient } from '../src/api'; +import { init } from '../src/index'; +import * as testData from './utils/testData.json'; + +// Tell Jest to use the manual mock +jest.mock('fastly:kv-store'); + +const sdkKey = 'test-sdk-key'; +const flagKey1 = 'testFlag1'; +const flagKey2 = 'testFlag2'; +const flagKey3 = 'testFlag3'; +const context = { kind: 'user', key: 'test-user-key-1' }; + +describe('init', () => { + let ldClient: LDClient; + let mockKVStore: jest.Mocked; + + beforeAll(async () => { + mockKVStore = new KVStore('test-kv-store') as jest.Mocked; + const testDataString = JSON.stringify(testData); + + mockKVStore.get.mockResolvedValue({ + text: jest.fn().mockResolvedValue(testDataString), + json: jest.fn().mockResolvedValue(testData), + arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(0)), + body: new ReadableStream(), + bodyUsed: false, + metadata: () => null, + metadataText: () => null, + }); + ldClient = init(sdkKey, mockKVStore); + await ldClient.waitForInitialization(); + }); + + afterAll(() => { + ldClient.close(); + }); + + describe('flag tests', () => { + it('evaluates a boolean flag with a variation call', async () => { + const value = await ldClient.variation(flagKey1, context, false); + expect(value).toBeTruthy(); + }); + + it('evaluates a boolean flag with a variation and variation detail call', async () => { + const contextWithEmail = { ...context, email: 'test@yahoo.com' }; + const value = await ldClient.variation(flagKey2, contextWithEmail, false); + const detail = await ldClient.variationDetail(flagKey2, contextWithEmail, false); + + expect(detail).toEqual({ reason: { kind: 'FALLTHROUGH' }, value: true, variationIndex: 0 }); + expect(value).toBeTruthy(); + }); + + it('evaluates a boolean flag with a targeting rule match', async () => { + const contextWithEmail = { ...context, email: 'test@gmail.com' }; + const value = await ldClient.variation(flagKey1, contextWithEmail, false); + const detail = await ldClient.variationDetail(flagKey1, contextWithEmail, false); + + expect(detail).toEqual({ + reason: { kind: 'RULE_MATCH', ruleId: 'rule1', ruleIndex: 0 }, + value: false, + variationIndex: 1, + }); + expect(value).toBeFalsy(); + }); + + it('evaluates a feature flag with a context that does not match any targeting rules', async () => { + const contextWithEmail = { ...context, email: 'test@yahoo.com' }; + const value = await ldClient.variation(flagKey1, contextWithEmail, false); + const detail = await ldClient.variationDetail(flagKey1, contextWithEmail, false); + + expect(detail).toEqual({ reason: { kind: 'FALLTHROUGH' }, value: true, variationIndex: 0 }); + expect(value).toBeTruthy(); + }); + + it('returns allFlagsState for a context', async () => { + const allFlags = await ldClient.allFlagsState(context); + + expect(allFlags).toBeDefined(); + expect(allFlags.toJSON()).toEqual({ + $flagsState: { + testFlag1: { debugEventsUntilDate: 2000, variation: 0, version: 2 }, + testFlag2: { debugEventsUntilDate: 2000, variation: 0, version: 2 }, + testFlag3: { debugEventsUntilDate: 2000, variation: 0, version: 2 }, + }, + $valid: true, + testFlag1: true, + testFlag2: true, + testFlag3: true, + }); + }); + }); + + describe('segment tests', () => { + it('evaluates a boolean flag with a segment targeting rule match', async () => { + const contextWithCountry = { ...context, country: 'australia' }; + const value = await ldClient.variation(flagKey3, contextWithCountry, false); + const detail = await ldClient.variationDetail(flagKey3, contextWithCountry, false); + + expect(detail).toEqual({ + reason: { kind: 'RULE_MATCH', ruleId: 'rule1', ruleIndex: 0 }, + value: false, + variationIndex: 1, + }); + expect(value).toBeFalsy(); + }); + }); +}); diff --git a/packages/sdk/fastly/__tests__/setupCrypto.ts b/packages/sdk/fastly/__tests__/setupCrypto.ts new file mode 100644 index 0000000000..bdf62024fc --- /dev/null +++ b/packages/sdk/fastly/__tests__/setupCrypto.ts @@ -0,0 +1,20 @@ +import { Hasher } from '@launchdarkly/js-server-sdk-common'; + +export const setupCrypto = () => { + let counter = 0; + const hasher = { + update: jest.fn((): Hasher => hasher), + digest: jest.fn(() => '1234567890123456'), + }; + + return { + createHash: jest.fn(() => hasher), + createHmac: jest.fn(), + randomUUID: jest.fn(() => { + counter += 1; + // Will provide a unique value for tests. + // Very much not a UUID of course. + return `${counter}`; + }), + }; +}; diff --git a/packages/sdk/fastly/__tests__/utils/mockEdgeProvider.ts b/packages/sdk/fastly/__tests__/utils/mockEdgeProvider.ts new file mode 100644 index 0000000000..fc237e93fb --- /dev/null +++ b/packages/sdk/fastly/__tests__/utils/mockEdgeProvider.ts @@ -0,0 +1,7 @@ +import { EdgeProvider } from '../../src/api'; + +const mockEdgeProvider: EdgeProvider = { + get: jest.fn(), +}; + +export default mockEdgeProvider; diff --git a/packages/sdk/fastly/__tests__/utils/mockFeatureStore.ts b/packages/sdk/fastly/__tests__/utils/mockFeatureStore.ts new file mode 100644 index 0000000000..037bed69ec --- /dev/null +++ b/packages/sdk/fastly/__tests__/utils/mockFeatureStore.ts @@ -0,0 +1,13 @@ +import type { LDFeatureStore } from '@launchdarkly/js-server-sdk-common'; + +const mockFeatureStore: LDFeatureStore = { + all: jest.fn(), + close: jest.fn(), + init: jest.fn(), + initialized: jest.fn(), + upsert: jest.fn(), + get: jest.fn(), + delete: jest.fn(), +}; + +export default mockFeatureStore; diff --git a/packages/sdk/fastly/__tests__/utils/testData.json b/packages/sdk/fastly/__tests__/utils/testData.json new file mode 100644 index 0000000000..b9e5296c03 --- /dev/null +++ b/packages/sdk/fastly/__tests__/utils/testData.json @@ -0,0 +1,171 @@ +{ + "flags": { + "testFlag1": { + "key": "testFlag1", + "on": true, + "prerequisites": [], + "targets": [], + "rules": [ + { + "variation": 1, + "id": "rule1", + "clauses": [ + { + "contextKind": "user", + "attribute": "/email", + "op": "contains", + "values": ["gmail"], + "negate": false + } + ], + "trackEvents": false, + "rollout": { + "bucketBy": "bucket", + "variations": [{ "variation": 1, "weight": 100 }] + } + } + ], + "fallthrough": { + "variation": 0 + }, + "offVariation": 1, + "variations": [true, false], + "clientSideAvailability": { + "usingMobileKey": true, + "usingEnvironmentId": true + }, + "clientSide": true, + "salt": "aef830243d6640d0a973be89988e008d", + "trackEvents": false, + "trackEventsFallthrough": false, + "debugEventsUntilDate": 2000, + "version": 2, + "deleted": false + }, + "testFlag2": { + "key": "testFlag2", + "on": true, + "prerequisites": [], + "targets": [], + "rules": [], + "fallthrough": { + "variation": 0, + "rollout": { + "bucketBy": "bucket", + "variations": [{ "variation": 1, "weight": 100 }], + "contextKind:": "user", + "attribute": "/email" + } + }, + "offVariation": 1, + "variations": [true, false], + "clientSideAvailability": { + "usingMobileKey": true, + "usingEnvironmentId": true + }, + "clientSide": true, + "salt": "aef830243d6640d0a973be89988e008d", + "trackEvents": false, + "trackEventsFallthrough": false, + "debugEventsUntilDate": 2000, + "version": 2, + "deleted": false + }, + "testFlag3": { + "key": "testFlag3", + "on": true, + "prerequisites": [], + "targets": [], + "rules": [ + { + "variation": 1, + "id": "rule1", + "clauses": [ + { + "op": "segmentMatch", + "values": ["testSegment1"], + "negate": false + } + ], + "trackEvents": false + } + ], + "fallthrough": { + "variation": 0 + }, + "offVariation": 1, + "variations": [true, false], + "clientSideAvailability": { + "usingMobileKey": true, + "usingEnvironmentId": true + }, + "clientSide": true, + "salt": "aef830243d6640d0a973be89988e008d", + "trackEvents": false, + "trackEventsFallthrough": false, + "debugEventsUntilDate": 2000, + "version": 2, + "deleted": false + } + }, + "segments": { + "testSegment1": { + "name": "testSegment1", + "tags": [], + "creationDate": 1676063792158, + "key": "testSegment1", + "included": [], + "excluded": [], + "includedContexts": [], + "excludedContexts": [], + "_links": { + "parent": { "href": "/api/v2/segments/default/test", "type": "application/json" }, + "self": { + "href": "/api/v2/segments/default/test/beta-users-1", + "type": "application/json" + }, + "site": { "href": "/default/test/segments/beta-users-1", "type": "text/html" } + }, + "rules": [ + { + "id": "rule-country", + "clauses": [ + { + "attribute": "country", + "op": "in", + "values": ["australia"], + "negate": false + } + ] + } + ], + "version": 1, + "deleted": false, + "_access": { "denied": [], "allowed": [] }, + "generation": 1 + }, + "testSegment2": { + "name": "testSegment2", + "tags": [], + "creationDate": 1676063792158, + "key": "testSegment2", + "included": [], + "excluded": [], + "includedContexts": [], + "excludedContexts": [], + "_links": { + "parent": { "href": "/api/v2/segments/default/test", "type": "application/json" }, + "self": { + "href": "/api/v2/segments/default/test/beta-users-1", + "type": "application/json" + }, + "site": { "href": "/default/test/segments/beta-users-1", "type": "text/html" } + }, + "rules": [], + "version": 1, + "deleted": false, + "_access": { "denied": [], "allowed": [] }, + "generation": 1 + } + } +} diff --git a/packages/sdk/fastly/__tests__/utils/validateOptions.test.ts b/packages/sdk/fastly/__tests__/utils/validateOptions.test.ts new file mode 100644 index 0000000000..11926be372 --- /dev/null +++ b/packages/sdk/fastly/__tests__/utils/validateOptions.test.ts @@ -0,0 +1,46 @@ +import { BasicLogger } from '@launchdarkly/js-server-sdk-common'; + +import validateOptions from '../../src/utils/validateOptions'; +import mockFeatureStore from './mockFeatureStore'; + +describe('validateOptions', () => { + test('throws without SDK key', () => { + expect(() => { + validateOptions('', {}); + }).toThrow(/You must configure the client with a client-side id/); + }); + + test('throws without featureStore', () => { + expect(() => { + validateOptions('test-sdk-key', {}); + }).toThrow(/You must configure the client with a feature store/); + }); + + test('throws without logger', () => { + expect(() => { + validateOptions('test-sdk-key', { featureStore: mockFeatureStore }); + }).toThrow(/You must configure the client with a logger/); + }); + + test('success valid options', () => { + expect( + validateOptions('test-sdk-key', { + featureStore: mockFeatureStore, + logger: BasicLogger.get(), + sendEvents: false, + }), + ).toBeTruthy(); + }); + + test('throws with invalid options', () => { + expect(() => { + validateOptions('test-sdk-key', { + featureStore: mockFeatureStore, + logger: BasicLogger.get(), + // @ts-ignore + streamUri: 'invalid-option', + proxyOptions: 'another-invalid-option', + }); + }).toThrow(/Invalid configuration: streamUri,proxyOptions not supported/); + }); +}); diff --git a/packages/sdk/fastly/example/.gitignore b/packages/sdk/fastly/example/.gitignore new file mode 100644 index 0000000000..ab78770b99 --- /dev/null +++ b/packages/sdk/fastly/example/.gitignore @@ -0,0 +1,5 @@ +/node_modules +/bin +/build +/pkg +.env diff --git a/packages/sdk/fastly/example/README.md b/packages/sdk/fastly/example/README.md new file mode 100644 index 0000000000..e2c4ba745a --- /dev/null +++ b/packages/sdk/fastly/example/README.md @@ -0,0 +1,65 @@ +# Example test app for Fastly LaunchDarkly SDK + +This is an example test app to showcase the usage of the Fastly LaunchDarkly SDK in a [Fastly Compute](https://docs.fastly.com/products/compute-at-edge) application. The example demonstrates: + +1. Initializing the LaunchDarkly SDK with a Fastly KV Store +2. Evaluating boolean and string feature flags +3. Using multi-kind contexts to include Fastly-specific data +4. Serving different images based on feature flag variations + +Most of the LaunchDarkly-related code can be found in [src/index.ts](src/index.ts). + +#### Photo credits + +Cat photo by [Sergey Semin](https://unsplash.com/@feneek?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash) on [Unsplash](https://unsplash.com/photos/brown-and-white-tabby-cat-DwHULfmhulE?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash). Dog photo by [Taylor Kopel](https://unsplash.com/@taylorkopel?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash) on [Unsplash](https://unsplash.com/photos/yellow-labrador-retriever-puppy-sitting-on-floor-WX4i1Jq_o0Y?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash). + +## Prerequisites + +A node environment of version 16 and yarn are required to develop in this repository. +You will also need the [Fastly CLI](https://developer.fastly.com/learning/tools/cli) installed and a Fastly account to setup +the test data required by this example. If you don't have a Fastly account, you can sign up for a free developer account [here](https://www.fastly.com/signup?tier=free). + +## Setting up your LaunchDarkly environment + +For simplicity, we recommend [creating a new LaunchDarkly project](https://docs.launchdarkly.com/home/organize/projects/?q=create+proj) for this example app. After creating a new project, create the following feature flags: + +- `example-flag` - (Boolean) - This flag is evaluated in the root endpoint +- `animal` - (String) - This flag determines which animal image to show (values: "cat" or "dog") + +## Setting up your development environment + +1. At the root of the js-core repo: + +```shell +yarn && yarn build +``` + +2. Replace `LAUNCHDARKLY_CLIENT_ID` in [src/index.ts](src/index.ts) with your LaunchDarkly SDK key. + +3. Create a new Fastly Compute service in the Fastly UI. + +4. Create a new Fastly KV in the Fastly UI named `launchdarkly`. + +5. Run the following command to install dependencies: + +```shell +yarn +``` + +5. Start the local development server: + +```shell +yarn start +``` + +6. Test the endpoints: + +- Visit `http://127.0.0.1:7676/` for the boolean flag evaluation +- Visit `http://127.0.0.1:7676/animal` to see an image controlled by the string flag +- Visit `http://127.0.0.1:7676/cat` or `http://127.0.0.1:7676/dog` for direct image access + +7. Deploy to Fastly: + +```shell +yarn deploy +``` diff --git a/packages/sdk/fastly/example/fastly.toml b/packages/sdk/fastly/example/fastly.toml new file mode 100644 index 0000000000..21b8c30665 --- /dev/null +++ b/packages/sdk/fastly/example/fastly.toml @@ -0,0 +1,26 @@ +# This file describes a Fastly Compute package. To learn more visit: +# https://www.fastly.com/documentation/reference/compute/fastly-toml + +authors = [] +description = "A basic example of using the LaunchDarkly SDK for Fastly" +language = "javascript" +manifest_version = 3 +name = "LaunchDarkly SDK for Fastly Example" +service_id = "" + +[scripts] +build = "yarn build" +post_init = "yarn install" + +[local_server] + +[local_server.backends] + +[local_server.backends.launchdarkly] +url = "https://events.launchdarkly.com" + +[local_server.kv_stores] + +[[local_server.kv_stores.launchdarkly_local]] +key = "LD-Env-local" +path = "./localData.json" diff --git a/packages/sdk/fastly/example/localData.json b/packages/sdk/fastly/example/localData.json new file mode 100644 index 0000000000..572030202b --- /dev/null +++ b/packages/sdk/fastly/example/localData.json @@ -0,0 +1,67 @@ +{ + "flags": { + "animal": { + "key": "animal", + "on": true, + "prerequisites": [], + "targets": [], + "contextTargets": [], + "rules": [], + "fallthrough": { + "rollout": { + "contextKind": "fastly-request", + "variations": [ + { "variation": 0, "weight": 50000 }, + { "variation": 1, "weight": 50000 } + ], + "bucketBy": "key" + } + }, + "offVariation": 1, + "variations": ["cat", "dog"], + "clientSideAvailability": { "usingMobileKey": false, "usingEnvironmentId": false }, + "clientSide": false, + "salt": "0ab7b96471ff4edb98113157355cbb9f", + "trackEvents": false, + "trackEventsFallthrough": false, + "debugEventsUntilDate": null, + "version": 5, + "deleted": false + }, + "example-flag": { + "key": "example-flag", + "on": true, + "prerequisites": [], + "targets": [], + "contextTargets": [], + "rules": [ + { + "variation": 1, + "id": "8b96123e-759f-4f73-b91e-884ac56d0a06", + "clauses": [ + { + "contextKind": "fastly-request", + "attribute": "fastly_region", + "op": "in", + "values": ["US-West"], + "negate": false + } + ], + "trackEvents": false + } + ], + "fallthrough": { "variation": 0 }, + "offVariation": 1, + "variations": [true, false], + "clientSideAvailability": { "usingMobileKey": false, "usingEnvironmentId": false }, + "clientSide": false, + "salt": "ca17f93252064631bacb2cffea217f20", + "trackEvents": false, + "trackEventsFallthrough": false, + "debugEventsUntilDate": null, + "version": 8, + "deleted": false + } + }, + "segments": {} +} diff --git a/packages/sdk/fastly/example/package.json b/packages/sdk/fastly/example/package.json new file mode 100644 index 0000000000..9f13207dab --- /dev/null +++ b/packages/sdk/fastly/example/package.json @@ -0,0 +1,23 @@ +{ + "name": "fastly-example", + "packageManager": "yarn@3.4.1", + "type": "module", + "engines": { + "node": "^16 || >=18" + }, + "devDependencies": { + "@fastly/cli": "^10.19.0", + "rimraf": "^6.0.1", + "typescript": "^5.7.2" + }, + "dependencies": { + "@fastly/js-compute": "^3.30.1", + "@launchdarkly/fastly-server-sdk": "0.1.5" + }, + "scripts": { + "clean": "rimraf build && rimraf bin", + "build": "tsc && js-compute-runtime build/index.js bin/main.wasm", + "start": "fastly compute serve", + "deploy": "fastly compute publish" + } +} diff --git a/packages/sdk/fastly/example/src/cat.jpg b/packages/sdk/fastly/example/src/cat.jpg new file mode 100644 index 0000000000..2e53fbe4ec Binary files /dev/null and b/packages/sdk/fastly/example/src/cat.jpg differ diff --git a/packages/sdk/fastly/example/src/dog.jpg b/packages/sdk/fastly/example/src/dog.jpg new file mode 100644 index 0000000000..67caee2b53 Binary files /dev/null and b/packages/sdk/fastly/example/src/dog.jpg differ diff --git a/packages/sdk/fastly/example/src/index.ts b/packages/sdk/fastly/example/src/index.ts new file mode 100644 index 0000000000..f0e073c595 --- /dev/null +++ b/packages/sdk/fastly/example/src/index.ts @@ -0,0 +1,118 @@ +/* eslint-disable no-console, @typescript-eslint/no-use-before-define, no-restricted-globals */ +/// +import { env } from 'fastly:env'; +import { includeBytes } from 'fastly:experimental'; +import { KVStore } from 'fastly:kv-store'; + +import { init } from '@launchdarkly/fastly-server-sdk'; +import type { LDMultiKindContext } from '@launchdarkly/js-server-sdk-common'; + +// Set your LaunchDarkly client ID here +const LAUNCHDARKLY_CLIENT_ID = ''; +// Set the KV store name used to store the LaunchDarkly data here +const KV_STORE_NAME = 'launchdarkly'; +// Set the Fastly Backend name used to send LaunchDarkly events here +const EVENTS_BACKEND_NAME = 'launchdarkly'; + +const cat = includeBytes('./src/cat.jpg'); +const dog = includeBytes('./src/dog.jpg'); + +// The entry point for your application. +// +// Use this fetch event listener to define your main request handling logic. It +// could be used to route based on the request properties (such as method or +// path), send the request to a backend, make completely new requests, and/or +// generate synthetic responses. + +addEventListener('fetch', (event) => event.respondWith(handleRequest(event))); + +async function handleRequest(event: FetchEvent) { + // Log service version + console.log('FASTLY_SERVICE_VERSION:', env('FASTLY_SERVICE_VERSION') || 'local'); + + // Get the client request. + const req = event.request; + + // Filter requests that have unexpected methods. + if (!['HEAD', 'GET', 'PURGE'].includes(req.method)) { + return new Response('This method is not allowed', { + status: 405, + }); + } + + const isLocal = env('FASTLY_HOSTNAME') === 'localhost'; + const kvStoreName = isLocal ? 'launchdarkly_local' : KV_STORE_NAME; + const ldClientId = isLocal ? 'local' : LAUNCHDARKLY_CLIENT_ID; + + const store = new KVStore(kvStoreName); + const ldClient = init(ldClientId, store, { + sendEvents: true, + eventsBackendName: EVENTS_BACKEND_NAME, + }); + await ldClient.waitForInitialization(); + + const flagContext: LDMultiKindContext = { + kind: 'multi', + user: { + // In a real-world scenario, you would use get the user key from a cookie, header, or other source + key: 'test-user', + }, + 'fastly-request': { + key: env('FASTLY_TRACE_ID'), + fastly_service_version: env('FASTLY_SERVICE_VERSION'), + fastly_cache_generation: env('FASTLY_CACHE_GENERATION'), + fastly_hostname: env('FASTLY_HOSTNAME'), + fastly_pop: env('FASTLY_POP'), + fastly_region: env('FASTLY_REGION'), + fastly_service_id: env('FASTLY_SERVICE_ID'), + fastly_trace_id: env('FASTLY_TRACE_ID'), + }, + }; + + const url = new URL(req.url); + + if (url.pathname === '/') { + const flagKey = 'example-flag'; + const variationDetail = await ldClient.boolVariationDetail(flagKey, flagContext, false); + + const output = { + flagContext, + flagKey, + variationDetail, + }; + event.waitUntil(ldClient.flush()); + + return new Response(JSON.stringify(output, undefined, 2), { + status: 200, + headers: new Headers({ 'Content-Type': 'application/json' }), + }); + } + + if (url.pathname === '/cat') { + return new Response(cat, { + status: 200, + headers: new Headers({ 'Content-Type': 'image/jpeg' }), + }); + } + if (url.pathname === '/dog') { + return new Response(dog, { + status: 200, + headers: new Headers({ 'Content-Type': 'image/jpeg' }), + }); + } + if (url.pathname === '/animal') { + const animal = await ldClient.stringVariation('animal', flagContext, 'cat'); + const image = animal === 'cat' ? cat : dog; + + event.waitUntil(ldClient.flush()); + return new Response(image, { + status: 200, + headers: new Headers({ 'Content-Type': 'image/jpeg' }), + }); + } + + // // Catch all other requests and return a 404. + return new Response('not found', { + status: 404, + }); +} diff --git a/packages/sdk/fastly/example/tsconfig.json b/packages/sdk/fastly/example/tsconfig.json new file mode 100644 index 0000000000..9f2c981cc9 --- /dev/null +++ b/packages/sdk/fastly/example/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "strict": true, + "module": "ES2022", + "target": "ES2022", + "moduleResolution": "bundler", + "customConditions": ["fastly"], + "esModuleInterop": true, + "lib": ["ES2022"], + "rootDir": "src", + "outDir": "build", + "types": ["@fastly/js-compute"], + "skipLibCheck": true + }, + "include": ["./src/**/*.js", "./src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/sdk/fastly/jest.config.json b/packages/sdk/fastly/jest.config.json new file mode 100644 index 0000000000..6174807746 --- /dev/null +++ b/packages/sdk/fastly/jest.config.json @@ -0,0 +1,9 @@ +{ + "transform": { "^.+\\.ts?$": "ts-jest" }, + "testMatch": ["**/*.test.ts?(x)"], + "testPathIgnorePatterns": ["node_modules", "example", "dist"], + "modulePathIgnorePatterns": ["dist"], + "testEnvironment": "node", + "moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"], + "collectCoverageFrom": ["src/**/*.ts"] +} diff --git a/packages/sdk/fastly/package.json b/packages/sdk/fastly/package.json new file mode 100644 index 0000000000..52ba362b81 --- /dev/null +++ b/packages/sdk/fastly/package.json @@ -0,0 +1,74 @@ +{ + "name": "@launchdarkly/fastly-server-sdk", + "version": "0.1.5", + "packageManager": "yarn@3.4.1", + "description": "Fastly LaunchDarkly SDK", + "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/sdk/fastly", + "repository": { + "type": "git", + "url": "https://github.com/launchdarkly/js-core.git" + }, + "license": "Apache-2.0", + "keywords": [ + "launchdarkly", + "fastly", + "edge", + "compute", + "kv" + ], + "type": "module", + "exports": { + ".": { + "require": { + "types": "./dist/index.d.cts", + "require": "./dist/index.cjs" + }, + "import": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + } + }, + "main": "../dist/index.cjs", + "types": "./dist/index.d.cts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsup && ../../../scripts/replace-version.sh .", + "clean": "rimraf dist", + "tsw": "yarn tsc --watch", + "start": "rimraf dist && yarn tsw", + "lint": "eslint . --ext .ts", + "test": "npx jest --runInBand", + "coverage": "yarn test --coverage", + "prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../.prettierignore", + "check": "yarn prettier && yarn lint && yarn build && yarn test" + }, + "dependencies": { + "@fastly/js-compute": "^3.30.1", + "@launchdarkly/js-server-sdk-common": "2.15.0", + "crypto-js": "^4.2.0" + }, + "devDependencies": { + "@trivago/prettier-plugin-sort-imports": "^5.2.2", + "@types/crypto-js": "^4.2.2", + "@types/jest": "^29.5.14", + "@typescript-eslint/eslint-plugin": "^6.20.0", + "@typescript-eslint/parser": "^6.20.0", + "eslint": "^8.45.0", + "eslint-config-airbnb-base": "^15.0.0", + "eslint-config-airbnb-typescript": "^18.0.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jest": "^28.11.0", + "eslint-plugin-prettier": "^5.2.3", + "jest": "^29.7.0", + "prettier": "^3.4.2", + "rimraf": "^6.0.1", + "ts-jest": "^29.2.5", + "tsup": "^8.3.5", + "typedoc": "^0.27.4", + "typescript": "^5.7.2" + } +} diff --git a/packages/sdk/fastly/src/api/EdgeFeatureStore.ts b/packages/sdk/fastly/src/api/EdgeFeatureStore.ts new file mode 100644 index 0000000000..e639db1523 --- /dev/null +++ b/packages/sdk/fastly/src/api/EdgeFeatureStore.ts @@ -0,0 +1,129 @@ +import type { + DataKind, + LDFeatureStore, + LDFeatureStoreDataStorage, + LDFeatureStoreItem, + LDFeatureStoreKindData, + LDLogger, +} from '@launchdarkly/js-server-sdk-common'; +import { deserializePoll, noop } from '@launchdarkly/js-server-sdk-common'; + +export interface EdgeProvider { + get: (rootKey: string) => Promise; +} + +export class EdgeFeatureStore implements LDFeatureStore { + private readonly _rootKey: string; + private _kvData: string | null = null; + + constructor( + private readonly _edgeProvider: EdgeProvider, + sdkKey: string, + private readonly _description: string, + private _logger: LDLogger, + ) { + this._rootKey = `LD-Env-${sdkKey}`; + } + + /** + * This function is used to lazy load the KV data from the edge provider. This is necessary because Fastly Compute + * has a limit of 32 backend requests (including requests to fetch the KV data). + * https://docs.fastly.com/products/compute-resource-limits + */ + private async _getKVData(): Promise { + if (!this._kvData) { + this._kvData = await this._edgeProvider.get(this._rootKey); + } + return this._kvData; + } + + async get( + kind: DataKind, + dataKey: string, + callback: (res: LDFeatureStoreItem | null) => void, + ): Promise { + const { namespace } = kind; + const kindKey = namespace === 'features' ? 'flags' : namespace; + this._logger.debug(`Requesting ${dataKey} from ${this._rootKey}.${kindKey}`); + + try { + const i = await this._getKVData(); + + if (!i) { + throw new Error(`${this._rootKey}.${kindKey} is not found in KV.`); + } + + const item = deserializePoll(i); + if (!item) { + throw new Error(`Error deserializing ${kindKey}`); + } + + switch (namespace) { + case 'features': + callback(item.flags[dataKey]); + break; + case 'segments': + callback(item.segments[dataKey]); + break; + default: + callback(null); + } + } catch (err) { + this._logger.error(err); + callback(null); + } + } + + async all(kind: DataKind, callback: (res: LDFeatureStoreKindData) => void = noop): Promise { + const { namespace } = kind; + const kindKey = namespace === 'features' ? 'flags' : namespace; + this._logger.debug(`Requesting all from ${this._rootKey}.${kindKey}`); + try { + const i = await this._getKVData(); + if (!i) { + throw new Error(`${this._rootKey}.${kindKey} is not found in KV.`); + } + + const item = deserializePoll(i); + if (!item) { + throw new Error(`Error deserializing ${kindKey}`); + } + + switch (namespace) { + case 'features': + callback(item.flags); + break; + case 'segments': + callback(item.segments); + break; + default: + callback({}); + } + } catch (err) { + this._logger.error(err); + callback({}); + } + } + + async initialized(callback: (isInitialized: boolean) => void = noop): Promise { + const config = await this._getKVData(); + const result = config !== null; + this._logger.debug(`Is ${this._rootKey} initialized? ${result}`); + callback(result); + } + + init(allData: LDFeatureStoreDataStorage, callback: () => void): void { + callback(); + } + + getDescription(): string { + return this._description; + } + + // unused + close = noop; + + delete = noop; + + upsert = noop; +} diff --git a/packages/sdk/fastly/src/api/LDClient.ts b/packages/sdk/fastly/src/api/LDClient.ts new file mode 100644 index 0000000000..443becd83c --- /dev/null +++ b/packages/sdk/fastly/src/api/LDClient.ts @@ -0,0 +1,31 @@ +import { Info, internal, LDClientImpl } from '@launchdarkly/js-server-sdk-common'; + +import EdgePlatform from '../platform'; +import { FastlySDKOptions } from '../utils/validateOptions'; +import createCallbacks from './createCallbacks'; +import createOptions from './createOptions'; + +export const DEFAULT_EVENTS_BACKEND_NAME = 'launchdarkly'; + +/** + * The LaunchDarkly SDK edge client object. + */ +export default class LDClient extends LDClientImpl { + // clientSideID is only used to query the edge key-value store and send analytics, not to initialize with LD servers + constructor(clientSideID: string, platformInfo: Info, options: FastlySDKOptions) { + const { eventsBackendName, ...ldOptions } = options; + const platform = new EdgePlatform( + platformInfo, + eventsBackendName || DEFAULT_EVENTS_BACKEND_NAME, + ); + const internalOptions: internal.LDInternalOptions = { + analyticsEventPath: `/events/bulk/${clientSideID}`, + diagnosticEventPath: `/events/diagnostic/${clientSideID}`, + includeAuthorizationHeader: false, + }; + + const finalOptions = createOptions(ldOptions); + + super(clientSideID, platform, finalOptions, createCallbacks(), internalOptions); + } +} diff --git a/packages/sdk/fastly/src/api/createCallbacks.ts b/packages/sdk/fastly/src/api/createCallbacks.ts new file mode 100644 index 0000000000..922a4b9e08 --- /dev/null +++ b/packages/sdk/fastly/src/api/createCallbacks.ts @@ -0,0 +1,9 @@ +const createCallbacks = () => ({ + onError: () => {}, + onFailed: () => {}, + onReady: () => {}, + onUpdate: () => {}, + hasEventListeners: () => false, +}); + +export default createCallbacks; diff --git a/packages/sdk/fastly/src/api/createOptions.ts b/packages/sdk/fastly/src/api/createOptions.ts new file mode 100644 index 0000000000..4ee08d6dd1 --- /dev/null +++ b/packages/sdk/fastly/src/api/createOptions.ts @@ -0,0 +1,22 @@ +import { BasicLogger, LDOptions } from '@launchdarkly/js-server-sdk-common'; + +export const defaultOptions: LDOptions = { + stream: false, + sendEvents: true, + useLdd: true, + diagnosticOptOut: true, + logger: BasicLogger.get(), +}; + +const createOptions = (options: LDOptions) => { + const finalOptions = { ...defaultOptions, ...options }; + + // The Fastly SDK does not poll LaunchDarkly for updates, so a custom baseUri does not make sense. However, we need + // to set it to something when a custom eventsUri is specified in order to pass validation in sdk-server-common. + if (finalOptions.eventsUri) { + finalOptions.baseUri = finalOptions.eventsUri; + } + return finalOptions; +}; + +export default createOptions; diff --git a/packages/sdk/fastly/src/api/index.ts b/packages/sdk/fastly/src/api/index.ts new file mode 100644 index 0000000000..c4ae612f9d --- /dev/null +++ b/packages/sdk/fastly/src/api/index.ts @@ -0,0 +1,4 @@ +import LDClient from './LDClient'; + +export * from './EdgeFeatureStore'; +export { LDClient }; diff --git a/packages/sdk/fastly/src/createPlatformInfo.ts b/packages/sdk/fastly/src/createPlatformInfo.ts new file mode 100644 index 0000000000..1b9c83e4b5 --- /dev/null +++ b/packages/sdk/fastly/src/createPlatformInfo.ts @@ -0,0 +1,24 @@ +import { Info, PlatformData, SdkData } from '@launchdarkly/js-server-sdk-common'; + +const name = '@launchdarkly/fastly-server-sdk'; +const version = '0.1.5'; // x-release-please-version + +class FastlyPlatformInfo implements Info { + platformData(): PlatformData { + return { + name: 'Fastly Compute', + }; + } + + sdkData(): SdkData { + return { + name, + version, + userAgentBase: 'FastlyEdgeSDK', + }; + } +} + +const createPlatformInfo = () => new FastlyPlatformInfo(); + +export default createPlatformInfo; diff --git a/packages/sdk/fastly/src/index.ts b/packages/sdk/fastly/src/index.ts new file mode 100644 index 0000000000..a1de33b742 --- /dev/null +++ b/packages/sdk/fastly/src/index.ts @@ -0,0 +1,73 @@ +/** + * This is the API reference for the Fastly LaunchDarkly SDK. + * + * In typical usage, you will call {@link init} once per request to obtain an instance of + * {@link LDClient}, which provides access to all of the SDK's functionality. + * + * For more information, see the SDK reference guide. + * + * @packageDocumentation + */ +/// +import { KVStore } from 'fastly:kv-store'; + +import { BasicLogger, LDEvaluationReason } from '@launchdarkly/js-server-sdk-common'; + +import { EdgeFeatureStore, EdgeProvider, LDClient } from './api'; +import { DEFAULT_EVENTS_BACKEND_NAME } from './api/LDClient'; +import createPlatformInfo from './createPlatformInfo'; +import validateOptions, { FastlySDKOptions, LDOptionsCommon } from './utils/validateOptions'; + +export type { + BasicLogger, + FastlySDKOptions, + KVStore, + LDClient, + LDEvaluationReason, + LDOptionsCommon, +}; + +/** + * Creates an instance of the Fastly LaunchDarkly client. + * + * Applications should instantiate a single instance for the lifetime of a request. + * The client will begin attempting to connect to the configured Fastly KV as + * soon as it is created. To determine when it is ready to use, call {@link LDClient.waitForInitialization}. + * + * **Important:** Do **not** try to instantiate `LDClient` with its constructor + * (`new LDClient()/new LDClientImpl()/new LDClient()`); the SDK does not currently support + * this. + * + * @param clientSideId + * The client side ID. This is only used to query the kvStore above, + * not to connect with LaunchDarkly servers. + * @param kvStore + * The Fastly KV store configured for LaunchDarkly. + * @param options + * Optional {@link FastlySDKOptions | configuration settings}. + * @return + * The new {@link LDClient} instance. + */ +export const init = ( + clientSideId: string, + kvStore: KVStore, + options: FastlySDKOptions = { eventsBackendName: DEFAULT_EVENTS_BACKEND_NAME }, +) => { + const logger = options.logger ?? BasicLogger.get(); + + const edgeProvider: EdgeProvider = { + get: async (rootKey: string) => { + const entry = await kvStore.get(rootKey); + return entry ? entry.text() : null; + }, + }; + + const finalOptions = { + featureStore: new EdgeFeatureStore(edgeProvider, clientSideId, 'Fastly', logger), + logger, + ...options, + }; + + validateOptions(clientSideId, finalOptions); + return new LDClient(clientSideId, createPlatformInfo(), finalOptions); +}; diff --git a/packages/sdk/fastly/src/platform/crypto/cryptoJSHasher.ts b/packages/sdk/fastly/src/platform/crypto/cryptoJSHasher.ts new file mode 100644 index 0000000000..4aec221057 --- /dev/null +++ b/packages/sdk/fastly/src/platform/crypto/cryptoJSHasher.ts @@ -0,0 +1,49 @@ +import CryptoJS from 'crypto-js'; + +import { Hasher as LDHasher } from '@launchdarkly/js-server-sdk-common'; + +import { SupportedHashAlgorithm, SupportedOutputEncoding } from './types'; + +export default class CryptoJSHasher implements LDHasher { + private _cryptoJSHasher; + + constructor(algorithm: SupportedHashAlgorithm) { + let algo; + + switch (algorithm) { + case 'sha1': + algo = CryptoJS.algo.SHA1; + break; + case 'sha256': + algo = CryptoJS.algo.SHA256; + break; + default: + throw new Error('unsupported hash algorithm. Only sha1 and sha256 are supported.'); + } + + this._cryptoJSHasher = algo.create(); + } + + digest(encoding: SupportedOutputEncoding): string { + const result = this._cryptoJSHasher.finalize(); + + let enc; + switch (encoding) { + case 'base64': + enc = CryptoJS.enc.Base64; + break; + case 'hex': + enc = CryptoJS.enc.Hex; + break; + default: + throw new Error('unsupported output encoding. Only base64 and hex are supported.'); + } + + return result.toString(enc); + } + + update(data: string): this { + this._cryptoJSHasher.update(data); + return this; + } +} diff --git a/packages/sdk/fastly/src/platform/crypto/cryptoJSHmac.ts b/packages/sdk/fastly/src/platform/crypto/cryptoJSHmac.ts new file mode 100644 index 0000000000..98e8976bb0 --- /dev/null +++ b/packages/sdk/fastly/src/platform/crypto/cryptoJSHmac.ts @@ -0,0 +1,45 @@ +import CryptoJS from 'crypto-js'; + +import { Hmac as LDHmac } from '@launchdarkly/js-server-sdk-common'; + +import { SupportedHashAlgorithm, SupportedOutputEncoding } from './types'; + +export default class CryptoJSHmac implements LDHmac { + private _cryptoJSHmac; + + constructor(algorithm: SupportedHashAlgorithm, key: string) { + let algo; + + switch (algorithm) { + case 'sha1': + algo = CryptoJS.algo.SHA1; + break; + case 'sha256': + algo = CryptoJS.algo.SHA256; + break; + default: + throw new Error('unsupported hash algorithm. Only sha1 and sha256 are supported.'); + } + + this._cryptoJSHmac = CryptoJS.algo.HMAC.create(algo, key); + } + + digest(encoding: SupportedOutputEncoding): string { + const result = this._cryptoJSHmac.finalize(); + + if (encoding === 'base64') { + return result.toString(CryptoJS.enc.Base64); + } + + if (encoding === 'hex') { + return result.toString(CryptoJS.enc.Hex); + } + + throw new Error('unsupported output encoding. Only base64 and hex are supported.'); + } + + update(data: string): this { + this._cryptoJSHmac.update(data); + return this; + } +} diff --git a/packages/sdk/fastly/src/platform/crypto/index.ts b/packages/sdk/fastly/src/platform/crypto/index.ts new file mode 100644 index 0000000000..7a25f59036 --- /dev/null +++ b/packages/sdk/fastly/src/platform/crypto/index.ts @@ -0,0 +1,24 @@ +import type { Crypto, Hasher, Hmac } from '@launchdarkly/js-server-sdk-common'; + +import CryptoJSHasher from './cryptoJSHasher'; +import CryptoJSHmac from './cryptoJSHmac'; +import { SupportedHashAlgorithm } from './types'; + +/** + * Uses crypto-js as substitute to node:crypto because the latter + * is not yet supported in some runtimes. + * https://cryptojs.gitbook.io/docs/ + */ +export default class EdgeCrypto implements Crypto { + createHash(algorithm: SupportedHashAlgorithm): Hasher { + return new CryptoJSHasher(algorithm); + } + + createHmac(algorithm: SupportedHashAlgorithm, key: string): Hmac { + return new CryptoJSHmac(algorithm, key); + } + + randomUUID(): string { + return crypto.randomUUID(); + } +} diff --git a/packages/sdk/fastly/src/platform/crypto/types.ts b/packages/sdk/fastly/src/platform/crypto/types.ts new file mode 100644 index 0000000000..3cf314d1f4 --- /dev/null +++ b/packages/sdk/fastly/src/platform/crypto/types.ts @@ -0,0 +1,2 @@ +export type SupportedHashAlgorithm = 'sha1' | 'sha256'; +export type SupportedOutputEncoding = 'base64' | 'hex'; diff --git a/packages/sdk/fastly/src/platform/index.ts b/packages/sdk/fastly/src/platform/index.ts new file mode 100644 index 0000000000..e4d34ea977 --- /dev/null +++ b/packages/sdk/fastly/src/platform/index.ts @@ -0,0 +1,17 @@ +import type { Crypto, Info, Platform, Requests } from '@launchdarkly/js-server-sdk-common'; + +import EdgeCrypto from './crypto'; +import EdgeRequests from './requests'; + +export default class EdgePlatform implements Platform { + info: Info; + + crypto: Crypto = new EdgeCrypto(); + + requests: Requests; + + constructor(info: Info, eventsBackend: string) { + this.info = info; + this.requests = new EdgeRequests(eventsBackend); + } +} diff --git a/packages/sdk/fastly/src/platform/requests.ts b/packages/sdk/fastly/src/platform/requests.ts new file mode 100644 index 0000000000..3315c3b078 --- /dev/null +++ b/packages/sdk/fastly/src/platform/requests.ts @@ -0,0 +1,34 @@ +/// +import { NullEventSource } from '@launchdarkly/js-server-sdk-common'; +import type { + EventSource, + EventSourceCapabilities, + EventSourceInitDict, + Options, + Requests, + Response, +} from '@launchdarkly/js-server-sdk-common'; + +export default class EdgeRequests implements Requests { + eventsBackend: string; + + constructor(eventsBackend: string) { + this.eventsBackend = eventsBackend; + } + + fetch(url: string, options: Options = {}): Promise { + return fetch(url, { ...options, backend: this.eventsBackend }); + } + + createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): EventSource { + return new NullEventSource(url, eventSourceInitDict); + } + + getEventSourceCapabilities(): EventSourceCapabilities { + return { + readTimeout: false, + headers: false, + customMethod: false, + }; + } +} diff --git a/packages/sdk/fastly/src/utils/validateOptions.ts b/packages/sdk/fastly/src/utils/validateOptions.ts new file mode 100644 index 0000000000..cc629bd462 --- /dev/null +++ b/packages/sdk/fastly/src/utils/validateOptions.ts @@ -0,0 +1,47 @@ +import { LDOptions as LDOptionsCommon, TypeValidators } from '@launchdarkly/js-server-sdk-common'; + +export type { LDOptionsCommon }; +/** + * The Launchdarkly Fastly Compute SDK configuration options. See {@link LDOptionsCommon} for more information on the 'logger', 'sendEvents', and 'eventsUri' options. + */ +export type FastlySDKOptions = Pick & { + /** + * The Fastly Backend name to send LaunchDarkly events. Backends are configured using the Fastly service backend configuration. This option can be ignored if the `sendEvents` option is set to `false`. See [Fastly's Backend documentation](https://developer.fastly.com/reference/api/services/backend/) for more information. The default value is `launchdarkly`. + */ + eventsBackendName?: string; +}; + +/** + * The internal options include featureStore because that's how the LDClient + * implementation expects it. + */ +export type LDOptionsInternal = FastlySDKOptions & Pick; + +const validators = { + clientSideId: TypeValidators.String, + featureStore: TypeValidators.ObjectOrFactory, + logger: TypeValidators.Object, +}; + +const validateOptions = (clientSideId: string, options: LDOptionsInternal) => { + const { eventsBackendName, featureStore, logger, sendEvents, ...rest } = options; + if (!clientSideId || !validators.clientSideId.is(clientSideId)) { + throw new Error('You must configure the client with a client-side id'); + } + + if (!featureStore || !validators.featureStore.is(featureStore)) { + throw new Error('You must configure the client with a feature store'); + } + + if (!logger || !validators.logger.is(logger)) { + throw new Error('You must configure the client with a logger'); + } + + if (JSON.stringify(rest) !== '{}') { + throw new Error(`Invalid configuration: ${Object.keys(rest).toString()} not supported`); + } + + return true; +}; + +export default validateOptions; diff --git a/packages/sdk/fastly/tsconfig.eslint.json b/packages/sdk/fastly/tsconfig.eslint.json new file mode 100644 index 0000000000..56c9b38305 --- /dev/null +++ b/packages/sdk/fastly/tsconfig.eslint.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/sdk/fastly/tsconfig.json b/packages/sdk/fastly/tsconfig.json new file mode 100644 index 0000000000..7ff06dea45 --- /dev/null +++ b/packages/sdk/fastly/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + // Uses "." so it can load package.json. + "rootDir": ".", + "outDir": "dist", + "target": "es2017", + "lib": ["es6"], + "module": "commonjs", + "strict": true, + "noImplicitOverride": true, + "allowSyntheticDefaultImports": true, + "sourceMap": true, + "declaration": true, + "declarationMap": true, // enables importers to jump to source + "resolveJsonModule": true, + "stripInternal": true, + "moduleResolution": "node", + "types": ["jest", "node"], + "skipLibCheck": true + }, + "exclude": ["**/*.test.ts", "dist", "node_modules", "__tests__", "example"] +} diff --git a/packages/sdk/fastly/tsconfig.ref.json b/packages/sdk/fastly/tsconfig.ref.json new file mode 100644 index 0000000000..832c1d8dd7 --- /dev/null +++ b/packages/sdk/fastly/tsconfig.ref.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*", "package.json", "src/**/testData.json"], + "compilerOptions": { + "composite": true + } +} diff --git a/packages/sdk/fastly/tsup.config.ts b/packages/sdk/fastly/tsup.config.ts new file mode 100644 index 0000000000..7ec3b3486b --- /dev/null +++ b/packages/sdk/fastly/tsup.config.ts @@ -0,0 +1,26 @@ +// It is a dev dependency and the linter doesn't understand. +// eslint-disable-next-line import/no-extraneous-dependencies +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: { + index: 'src/index.ts', + }, + minify: true, + format: ['esm', 'cjs'], + splitting: false, + sourcemap: false, + clean: true, + noExternal: ['@launchdarkly/js-server-sdk-common'], + dts: true, + metafile: true, + esbuildOptions(opts) { + // This would normally be `^_(?!meta|_)`, but go doesn't support negative look-ahead assertions, + // so we need to craft something that works without it. + // So start of line followed by a character that isn't followed by m or underscore, but we + // want other things that do start with m, so we need to progressively handle more characters + // of meta with exclusions. + // eslint-disable-next-line no-param-reassign + opts.mangleProps = /^_([^m|_]|m[^e]|me[^t]|met[^a])/; + }, +}); diff --git a/packages/sdk/fastly/typedoc.json b/packages/sdk/fastly/typedoc.json new file mode 100644 index 0000000000..7ac616b544 --- /dev/null +++ b/packages/sdk/fastly/typedoc.json @@ -0,0 +1,5 @@ +{ + "extends": ["../../../typedoc.base.json"], + "entryPoints": ["src/index.ts"], + "out": "docs" +} diff --git a/packages/sdk/react-native/CHANGELOG.md b/packages/sdk/react-native/CHANGELOG.md index 1f2d63a90c..68f6b25fa6 100644 --- a/packages/sdk/react-native/CHANGELOG.md +++ b/packages/sdk/react-native/CHANGELOG.md @@ -1,5 +1,62 @@ # Changelog +## [10.9.9](https://github.com/launchdarkly/js-core/compare/react-native-client-sdk-v10.9.8...react-native-client-sdk-v10.9.9) (2025-04-16) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-client-sdk-common bumped from 1.12.5 to 1.12.6 + +## [10.9.8](https://github.com/launchdarkly/js-core/compare/react-native-client-sdk-v10.9.7...react-native-client-sdk-v10.9.8) (2025-04-08) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-client-sdk-common bumped from 1.12.4 to 1.12.5 + +## [10.9.7](https://github.com/launchdarkly/js-core/compare/react-native-client-sdk-v10.9.6...react-native-client-sdk-v10.9.7) (2025-03-26) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-client-sdk-common bumped from 1.12.3 to 1.12.4 + +## [10.9.6](https://github.com/launchdarkly/js-core/compare/react-native-client-sdk-v10.9.5...react-native-client-sdk-v10.9.6) (2025-02-06) + + +### Bug Fixes + +* Ensure streaming connection is closed on SDK close. ([#774](https://github.com/launchdarkly/js-core/issues/774)) ([f58e746](https://github.com/launchdarkly/js-core/commit/f58e746a089fb0cd5f6169f6c246e1f6515f5047)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-client-sdk-common bumped from 1.12.2 to 1.12.3 + +## [10.9.5](https://github.com/launchdarkly/js-core/compare/react-native-client-sdk-v10.9.4...react-native-client-sdk-v10.9.5) (2025-01-24) + + +### Bug Fixes + +* **react-native:** check for nullability in SettingsManager?.settings ([#758](https://github.com/launchdarkly/js-core/issues/758)) ([3449934](https://github.com/launchdarkly/js-core/commit/3449934027697ac9283aeeeca8df9a76d172fcad)) + +## [10.9.4](https://github.com/launchdarkly/js-core/compare/react-native-client-sdk-v10.9.3...react-native-client-sdk-v10.9.4) (2025-01-22) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-client-sdk-common bumped from 1.12.1 to 1.12.2 + ## [10.9.3](https://github.com/launchdarkly/js-core/compare/react-native-client-sdk-v10.9.2...react-native-client-sdk-v10.9.3) (2024-11-22) diff --git a/packages/sdk/react-native/__tests__/MobileDataManager.test.ts b/packages/sdk/react-native/__tests__/MobileDataManager.test.ts index 27ac0de488..c1f056a68f 100644 --- a/packages/sdk/react-native/__tests__/MobileDataManager.test.ts +++ b/packages/sdk/react-native/__tests__/MobileDataManager.test.ts @@ -54,6 +54,8 @@ describe('given a MobileDataManager with mocked dependencies', () => { let diagnosticsManager: jest.Mocked; let mobileDataManager: MobileDataManager; let logger: LDLogger; + let eventSourceCloseMethod: jest.Mock; + beforeEach(() => { logger = { error: jest.fn(), @@ -61,6 +63,8 @@ describe('given a MobileDataManager with mocked dependencies', () => { info: jest.fn(), debug: jest.fn(), }; + eventSourceCloseMethod = jest.fn(); + config = { logger, maxCachedContexts: 5, @@ -94,7 +98,7 @@ describe('given a MobileDataManager with mocked dependencies', () => { options, onclose: jest.fn(), addEventListener: jest.fn(), - close: jest.fn(), + close: eventSourceCloseMethod, })), fetch: mockedFetch, getEventSourceCapabilities: jest.fn(), @@ -223,6 +227,23 @@ describe('given a MobileDataManager with mocked dependencies', () => { expect(platform.requests.fetch).not.toHaveBeenCalled(); }); + it('makes no connection when closed', async () => { + mobileDataManager.close(); + + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + await mobileDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + + expect(platform.requests.createEventSource).not.toHaveBeenCalled(); + expect(platform.requests.fetch).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith( + '[MobileDataManager] Identify called after data manager was closed.', + ); + }); + it('should load cached flags and resolve the identify', async () => { const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; @@ -299,4 +320,25 @@ describe('given a MobileDataManager with mocked dependencies', () => { expect(identifyResolve).toHaveBeenCalled(); expect(identifyReject).not.toHaveBeenCalled(); }); + + it('closes the event source when the data manager is closed', async () => { + const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); + const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; + const identifyResolve = jest.fn(); + const identifyReject = jest.fn(); + + await mobileDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + expect(platform.requests.createEventSource).toHaveBeenCalled(); + + mobileDataManager.close(); + expect(eventSourceCloseMethod).toHaveBeenCalled(); + + // Verify a subsequent identify doesn't create a new event source + await mobileDataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + expect(platform.requests.createEventSource).toHaveBeenCalledTimes(1); + + expect(logger.debug).toHaveBeenCalledWith( + '[MobileDataManager] Identify called after data manager was closed.', + ); + }); }); diff --git a/packages/sdk/react-native/package.json b/packages/sdk/react-native/package.json index 5cb458efc8..76cede1a7b 100644 --- a/packages/sdk/react-native/package.json +++ b/packages/sdk/react-native/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/react-native-client-sdk", - "version": "10.9.3", + "version": "10.9.9", "description": "React Native LaunchDarkly SDK", "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/sdk/react-native", "repository": { @@ -41,7 +41,7 @@ "react-native": "*" }, "dependencies": { - "@launchdarkly/js-client-sdk-common": "1.12.1", + "@launchdarkly/js-client-sdk-common": "1.12.6", "@react-native-async-storage/async-storage": "^1.21.0", "base64-js": "^1.5.1" }, diff --git a/packages/sdk/react-native/src/MobileDataManager.ts b/packages/sdk/react-native/src/MobileDataManager.ts index bf50fdea00..1f1d0357b6 100644 --- a/packages/sdk/react-native/src/MobileDataManager.ts +++ b/packages/sdk/react-native/src/MobileDataManager.ts @@ -58,6 +58,10 @@ export default class MobileDataManager extends BaseDataManager { context: Context, identifyOptions?: LDIdentifyOptions, ): Promise { + if (this.closed) { + this._debugLog('Identify called after data manager was closed.'); + return; + } this.context = context; const offline = this.connectionMode === 'offline'; // In offline mode we do not support waiting for results. @@ -140,6 +144,11 @@ export default class MobileDataManager extends BaseDataManager { } async setConnectionMode(mode: ConnectionMode): Promise { + if (this.closed) { + this._debugLog('setting connection mode after data manager was closed'); + return; + } + if (this.connectionMode === mode) { this._debugLog(`setConnectionMode ignored. Mode is already '${mode}'.`); return; diff --git a/packages/sdk/react-native/src/platform/locale.ts b/packages/sdk/react-native/src/platform/locale.ts index 00b5de0147..3fff16028b 100644 --- a/packages/sdk/react-native/src/platform/locale.ts +++ b/packages/sdk/react-native/src/platform/locale.ts @@ -6,7 +6,7 @@ import { NativeModules, Platform } from 'react-native'; */ const locale = Platform.OS === 'ios' - ? NativeModules.SettingsManager?.settings.AppleLocale // iOS + ? NativeModules.SettingsManager?.settings?.AppleLocale // iOS : NativeModules.I18nManager?.localeIdentifier; export default locale; diff --git a/packages/sdk/server-ai/CHANGELOG.md b/packages/sdk/server-ai/CHANGELOG.md index c1c7def3dc..02aa576d60 100644 --- a/packages/sdk/server-ai/CHANGELOG.md +++ b/packages/sdk/server-ai/CHANGELOG.md @@ -1,5 +1,110 @@ # Changelog +## [0.9.6](https://github.com/launchdarkly/js-core/compare/server-sdk-ai-v0.9.5...server-sdk-ai-v0.9.6) (2025-04-16) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/js-server-sdk-common bumped from 2.14.0 to 2.15.0 + * peerDependencies + * @launchdarkly/js-server-sdk-common bumped from 2.x to 2.15.0 + +## [0.9.5](https://github.com/launchdarkly/js-core/compare/server-sdk-ai-v0.9.4...server-sdk-ai-v0.9.5) (2025-04-08) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/js-server-sdk-common bumped from 2.13.0 to 2.14.0 + * peerDependencies + * @launchdarkly/js-server-sdk-common bumped from 2.x to 2.14.0 + +## [0.9.4](https://github.com/launchdarkly/js-core/compare/server-sdk-ai-v0.9.3...server-sdk-ai-v0.9.4) (2025-03-26) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/js-server-sdk-common bumped from 2.12.1 to 2.13.0 + * peerDependencies + * @launchdarkly/js-server-sdk-common bumped from 2.x to 2.13.0 + +## [0.9.3](https://github.com/launchdarkly/js-core/compare/server-sdk-ai-v0.9.2...server-sdk-ai-v0.9.3) (2025-03-21) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/js-server-sdk-common bumped from 2.12.0 to 2.12.1 + * peerDependencies + * @launchdarkly/js-server-sdk-common bumped from 2.x to 2.12.1 + +## [0.9.2](https://github.com/launchdarkly/js-core/compare/server-sdk-ai-v0.9.1...server-sdk-ai-v0.9.2) (2025-03-17) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/js-server-sdk-common bumped from 2.11.1 to 2.12.0 + * peerDependencies + * @launchdarkly/js-server-sdk-common bumped from 2.x to 2.12.0 + +## [0.9.1](https://github.com/launchdarkly/js-core/compare/server-sdk-ai-v0.9.0...server-sdk-ai-v0.9.1) (2025-02-18) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/js-server-sdk-common bumped from 2.11.0 to 2.11.1 + * peerDependencies + * @launchdarkly/js-server-sdk-common bumped from 2.x to 2.11.1 + +## [0.9.0](https://github.com/launchdarkly/js-core/compare/server-sdk-ai-v0.8.2...server-sdk-ai-v0.9.0) (2025-02-06) + + +### Features + +* add support for versioned metrics for AI Configs ([#773](https://github.com/launchdarkly/js-core/issues/773)) ([a3f756f](https://github.com/launchdarkly/js-core/commit/a3f756f3c3207a068115b147d5c7439e204b7ae4)) + +## [0.8.2](https://github.com/launchdarkly/js-core/compare/server-sdk-ai-v0.8.1...server-sdk-ai-v0.8.2) (2025-01-27) + + +### Bug Fixes + +* **docs:** Node.js AI SDK: modelConfig --> config in readme ([#765](https://github.com/launchdarkly/js-core/issues/765)) ([4d46117](https://github.com/launchdarkly/js-core/commit/4d4611700e7eebd9a7d6f8fd596a7a4ff3310802)) + +## [0.8.1](https://github.com/launchdarkly/js-core/compare/server-sdk-ai-v0.8.0...server-sdk-ai-v0.8.1) (2025-01-24) + + +### Bug Fixes + +* Correct documentation for AI Config function. ([#754](https://github.com/launchdarkly/js-core/issues/754)) ([0bdb0be](https://github.com/launchdarkly/js-core/commit/0bdb0be6b0e0213c5139af9008884ea74be197b1)) + +## [0.8.0](https://github.com/launchdarkly/js-core/compare/server-sdk-ai-v0.7.1...server-sdk-ai-v0.8.0) (2025-01-23) + + +### Features + +* track timeToFirstToken in LDAIConfigTracker ([#749](https://github.com/launchdarkly/js-core/issues/749)) ([c97674f](https://github.com/launchdarkly/js-core/commit/c97674fe521bcfe14dc6e0679bf25e293a2a1ad1)) + +## [0.7.1](https://github.com/launchdarkly/js-core/compare/server-sdk-ai-v0.7.0...server-sdk-ai-v0.7.1) (2025-01-22) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/js-server-sdk-common bumped from 2.10.0 to 2.11.0 + * peerDependencies + * @launchdarkly/js-server-sdk-common bumped from 2.x to 2.11.0 + ## [0.7.0](https://github.com/launchdarkly/js-core/compare/server-sdk-ai-v0.6.0...server-sdk-ai-v0.7.0) (2024-12-17) @@ -39,11 +144,11 @@ ### ⚠ BREAKING CHANGES -* Updated AI config interface. ([#697](https://github.com/launchdarkly/js-core/issues/697)) +* Updated AI Config interface. ([#697](https://github.com/launchdarkly/js-core/issues/697)) ### Features -* Updated AI config interface. ([#697](https://github.com/launchdarkly/js-core/issues/697)) ([cd72ea8](https://github.com/launchdarkly/js-core/commit/cd72ea8193888b0635b5beffa0a877b18294777e)) +* Updated AI Config interface. ([#697](https://github.com/launchdarkly/js-core/issues/697)) ([cd72ea8](https://github.com/launchdarkly/js-core/commit/cd72ea8193888b0635b5beffa0a877b18294777e)) ## [0.3.0](https://github.com/launchdarkly/js-core/compare/server-sdk-ai-v0.2.1...server-sdk-ai-v0.3.0) (2024-11-15) diff --git a/packages/sdk/server-ai/README.md b/packages/sdk/server-ai/README.md index 27468467f1..cf50074c4c 100644 --- a/packages/sdk/server-ai/README.md +++ b/packages/sdk/server-ai/README.md @@ -21,8 +21,7 @@ ## Quick Setup -This assumes that you have already installed the LaunchDarkly Node.js SDK, or a compatible edge -SDK. +This assumes that you have already installed the LaunchDarkly Node.js (server-side) SDK, or a compatible edge SDK. 1. Install this package with `npm` or `yarn`: @@ -40,7 +39,7 @@ const aiClient = initAi(ldClient); 3. Evaluate a model configuration: ```typescript -const config = await aiClient.modelConfig( +const config = await aiClient.config( aiConfigKey!, context, { enabled: false }, diff --git a/packages/sdk/server-ai/__tests__/LDAIConfigTrackerImpl.test.ts b/packages/sdk/server-ai/__tests__/LDAIConfigTrackerImpl.test.ts index 73ccd8e2c2..483d097889 100644 --- a/packages/sdk/server-ai/__tests__/LDAIConfigTrackerImpl.test.ts +++ b/packages/sdk/server-ai/__tests__/LDAIConfigTrackerImpl.test.ts @@ -14,25 +14,38 @@ const mockLdClient: LDClientMin = { const testContext: LDContext = { kind: 'user', key: 'test-user' }; const configKey = 'test-config'; const variationKey = 'v1'; +const version = 1; beforeEach(() => { jest.clearAllMocks(); }); it('tracks duration', () => { - const tracker = new LDAIConfigTrackerImpl(mockLdClient, configKey, variationKey, testContext); + const tracker = new LDAIConfigTrackerImpl( + mockLdClient, + configKey, + variationKey, + version, + testContext, + ); tracker.trackDuration(1000); expect(mockTrack).toHaveBeenCalledWith( '$ld:ai:duration:total', testContext, - { configKey, variationKey }, + { configKey, variationKey, version }, 1000, ); }); it('tracks duration of async function', async () => { - const tracker = new LDAIConfigTrackerImpl(mockLdClient, configKey, variationKey, testContext); + const tracker = new LDAIConfigTrackerImpl( + mockLdClient, + configKey, + variationKey, + version, + testContext, + ); jest.spyOn(global.Date, 'now').mockReturnValueOnce(1000).mockReturnValueOnce(2000); const result = await tracker.trackDurationOf(async () => 'test-result'); @@ -41,49 +54,91 @@ it('tracks duration of async function', async () => { expect(mockTrack).toHaveBeenCalledWith( '$ld:ai:duration:total', testContext, - { configKey, variationKey }, + { configKey, variationKey, version }, + 1000, + ); +}); + +it('tracks time to first token', () => { + const tracker = new LDAIConfigTrackerImpl( + mockLdClient, + configKey, + variationKey, + version, + testContext, + ); + tracker.trackTimeToFirstToken(1000); + + expect(mockTrack).toHaveBeenCalledWith( + '$ld:ai:tokens:ttf', + testContext, + { configKey, variationKey, version }, 1000, ); }); it('tracks positive feedback', () => { - const tracker = new LDAIConfigTrackerImpl(mockLdClient, configKey, variationKey, testContext); + const tracker = new LDAIConfigTrackerImpl( + mockLdClient, + configKey, + variationKey, + version, + testContext, + ); tracker.trackFeedback({ kind: LDFeedbackKind.Positive }); expect(mockTrack).toHaveBeenCalledWith( '$ld:ai:feedback:user:positive', testContext, - { configKey, variationKey }, + { configKey, variationKey, version }, 1, ); }); it('tracks negative feedback', () => { - const tracker = new LDAIConfigTrackerImpl(mockLdClient, configKey, variationKey, testContext); + const tracker = new LDAIConfigTrackerImpl( + mockLdClient, + configKey, + variationKey, + version, + testContext, + ); tracker.trackFeedback({ kind: LDFeedbackKind.Negative }); expect(mockTrack).toHaveBeenCalledWith( '$ld:ai:feedback:user:negative', testContext, - { configKey, variationKey }, + { configKey, variationKey, version }, 1, ); }); it('tracks success', () => { - const tracker = new LDAIConfigTrackerImpl(mockLdClient, configKey, variationKey, testContext); + const tracker = new LDAIConfigTrackerImpl( + mockLdClient, + configKey, + variationKey, + version, + testContext, + ); tracker.trackSuccess(); expect(mockTrack).toHaveBeenCalledWith( '$ld:ai:generation', testContext, - { configKey, variationKey }, + { configKey, variationKey, version }, 1, ); }); it('tracks OpenAI usage', async () => { - const tracker = new LDAIConfigTrackerImpl(mockLdClient, configKey, variationKey, testContext); + const tracker = new LDAIConfigTrackerImpl( + mockLdClient, + configKey, + variationKey, + version, + testContext, + ); jest.spyOn(global.Date, 'now').mockReturnValueOnce(1000).mockReturnValueOnce(2000); const TOTAL_TOKENS = 100; @@ -101,41 +156,47 @@ it('tracks OpenAI usage', async () => { expect(mockTrack).toHaveBeenCalledWith( '$ld:ai:duration:total', testContext, - { configKey, variationKey }, + { configKey, variationKey, version }, 1000, ); expect(mockTrack).toHaveBeenCalledWith( '$ld:ai:generation', testContext, - { configKey, variationKey }, + { configKey, variationKey, version }, 1, ); expect(mockTrack).toHaveBeenCalledWith( '$ld:ai:tokens:total', testContext, - { configKey, variationKey }, + { configKey, variationKey, version }, TOTAL_TOKENS, ); expect(mockTrack).toHaveBeenCalledWith( '$ld:ai:tokens:input', testContext, - { configKey, variationKey }, + { configKey, variationKey, version }, PROMPT_TOKENS, ); expect(mockTrack).toHaveBeenCalledWith( '$ld:ai:tokens:output', testContext, - { configKey, variationKey }, + { configKey, variationKey, version }, COMPLETION_TOKENS, ); }); it('tracks error when OpenAI metrics function throws', async () => { - const tracker = new LDAIConfigTrackerImpl(mockLdClient, configKey, variationKey, testContext); + const tracker = new LDAIConfigTrackerImpl( + mockLdClient, + configKey, + variationKey, + version, + testContext, + ); jest.spyOn(global.Date, 'now').mockReturnValueOnce(1000).mockReturnValueOnce(2000); const error = new Error('OpenAI API error'); @@ -148,27 +209,33 @@ it('tracks error when OpenAI metrics function throws', async () => { expect(mockTrack).toHaveBeenCalledWith( '$ld:ai:duration:total', testContext, - { configKey, variationKey }, + { configKey, variationKey, version }, 1000, ); expect(mockTrack).toHaveBeenCalledWith( '$ld:ai:generation', testContext, - { configKey, variationKey }, + { configKey, variationKey, version }, 1, ); expect(mockTrack).toHaveBeenCalledWith( '$ld:ai:generation:error', testContext, - { configKey, variationKey }, + { configKey, variationKey, version }, 1, ); }); it('tracks Bedrock conversation with successful response', () => { - const tracker = new LDAIConfigTrackerImpl(mockLdClient, configKey, variationKey, testContext); + const tracker = new LDAIConfigTrackerImpl( + mockLdClient, + configKey, + variationKey, + version, + testContext, + ); const TOTAL_TOKENS = 100; const PROMPT_TOKENS = 49; @@ -189,41 +256,47 @@ it('tracks Bedrock conversation with successful response', () => { expect(mockTrack).toHaveBeenCalledWith( '$ld:ai:generation', testContext, - { configKey, variationKey }, + { configKey, variationKey, version }, 1, ); expect(mockTrack).toHaveBeenCalledWith( '$ld:ai:duration:total', testContext, - { configKey, variationKey }, + { configKey, variationKey, version }, 500, ); expect(mockTrack).toHaveBeenCalledWith( '$ld:ai:tokens:total', testContext, - { configKey, variationKey }, + { configKey, variationKey, version }, TOTAL_TOKENS, ); expect(mockTrack).toHaveBeenCalledWith( '$ld:ai:tokens:input', testContext, - { configKey, variationKey }, + { configKey, variationKey, version }, PROMPT_TOKENS, ); expect(mockTrack).toHaveBeenCalledWith( '$ld:ai:tokens:output', testContext, - { configKey, variationKey }, + { configKey, variationKey, version }, COMPLETION_TOKENS, ); }); it('tracks Bedrock conversation with error response', () => { - const tracker = new LDAIConfigTrackerImpl(mockLdClient, configKey, variationKey, testContext); + const tracker = new LDAIConfigTrackerImpl( + mockLdClient, + configKey, + variationKey, + version, + testContext, + ); const response = { $metadata: { httpStatusCode: 400 }, @@ -235,20 +308,26 @@ it('tracks Bedrock conversation with error response', () => { expect(mockTrack).toHaveBeenCalledWith( '$ld:ai:generation', testContext, - { configKey, variationKey }, + { configKey, variationKey, version }, 1, ); expect(mockTrack).toHaveBeenCalledWith( '$ld:ai:generation:error', testContext, - { configKey, variationKey }, + { configKey, variationKey, version }, 1, ); }); it('tracks tokens', () => { - const tracker = new LDAIConfigTrackerImpl(mockLdClient, configKey, variationKey, testContext); + const tracker = new LDAIConfigTrackerImpl( + mockLdClient, + configKey, + variationKey, + version, + testContext, + ); const TOTAL_TOKENS = 100; const PROMPT_TOKENS = 49; @@ -263,27 +342,33 @@ it('tracks tokens', () => { expect(mockTrack).toHaveBeenCalledWith( '$ld:ai:tokens:total', testContext, - { configKey, variationKey }, + { configKey, variationKey, version }, TOTAL_TOKENS, ); expect(mockTrack).toHaveBeenCalledWith( '$ld:ai:tokens:input', testContext, - { configKey, variationKey }, + { configKey, variationKey, version }, PROMPT_TOKENS, ); expect(mockTrack).toHaveBeenCalledWith( '$ld:ai:tokens:output', testContext, - { configKey, variationKey }, + { configKey, variationKey, version }, COMPLETION_TOKENS, ); }); it('only tracks non-zero token counts', () => { - const tracker = new LDAIConfigTrackerImpl(mockLdClient, configKey, variationKey, testContext); + const tracker = new LDAIConfigTrackerImpl( + mockLdClient, + configKey, + variationKey, + version, + testContext, + ); tracker.trackTokens({ total: 0, @@ -301,7 +386,7 @@ it('only tracks non-zero token counts', () => { expect(mockTrack).toHaveBeenCalledWith( '$ld:ai:tokens:input', testContext, - { configKey, variationKey }, + { configKey, variationKey, version }, 50, ); @@ -314,7 +399,13 @@ it('only tracks non-zero token counts', () => { }); it('returns empty summary when no metrics tracked', () => { - const tracker = new LDAIConfigTrackerImpl(mockLdClient, configKey, variationKey, testContext); + const tracker = new LDAIConfigTrackerImpl( + mockLdClient, + configKey, + variationKey, + version, + testContext, + ); const summary = tracker.getSummary(); @@ -322,7 +413,13 @@ it('returns empty summary when no metrics tracked', () => { }); it('summarizes tracked metrics', () => { - const tracker = new LDAIConfigTrackerImpl(mockLdClient, configKey, variationKey, testContext); + const tracker = new LDAIConfigTrackerImpl( + mockLdClient, + configKey, + variationKey, + version, + testContext, + ); tracker.trackDuration(1000); tracker.trackTokens({ @@ -350,7 +447,13 @@ it('summarizes tracked metrics', () => { }); it('tracks duration when async function throws', async () => { - const tracker = new LDAIConfigTrackerImpl(mockLdClient, configKey, variationKey, testContext); + const tracker = new LDAIConfigTrackerImpl( + mockLdClient, + configKey, + variationKey, + version, + testContext, + ); jest.spyOn(global.Date, 'now').mockReturnValueOnce(1000).mockReturnValueOnce(2000); const error = new Error('test error'); @@ -363,26 +466,32 @@ it('tracks duration when async function throws', async () => { expect(mockTrack).toHaveBeenCalledWith( '$ld:ai:duration:total', testContext, - { configKey, variationKey }, + { configKey, variationKey, version }, 1000, ); }); it('tracks error', () => { - const tracker = new LDAIConfigTrackerImpl(mockLdClient, configKey, variationKey, testContext); + const tracker = new LDAIConfigTrackerImpl( + mockLdClient, + configKey, + variationKey, + version, + testContext, + ); tracker.trackError(); expect(mockTrack).toHaveBeenCalledWith( '$ld:ai:generation', testContext, - { configKey, variationKey }, + { configKey, variationKey, version }, 1, ); expect(mockTrack).toHaveBeenCalledWith( '$ld:ai:generation:error', testContext, - { configKey, variationKey }, + { configKey, variationKey, version }, 1, ); }); diff --git a/packages/sdk/server-ai/examples/bedrock/README.md b/packages/sdk/server-ai/examples/bedrock/README.md index a997129f82..13e5b4f900 100644 --- a/packages/sdk/server-ai/examples/bedrock/README.md +++ b/packages/sdk/server-ai/examples/bedrock/README.md @@ -24,7 +24,7 @@ yarn build Before running the example, make sure to set the following environment variables: - `LAUNCHDARKLY_SDK_KEY`: Your LaunchDarkly SDK key -- `LAUNCHDARKLY_AI_CONFIG_KEY`: Your LaunchDarkly AI configuration key (defaults to 'sample-ai-config' if not set) +- `LAUNCHDARKLY_AI_CONFIG_KEY`: Your LaunchDarkly AI Config key (defaults to 'sample-ai-config' if not set) Additionally, ensure you have proper AWS credentials configured to access Bedrock services. diff --git a/packages/sdk/server-ai/examples/bedrock/package.json b/packages/sdk/server-ai/examples/bedrock/package.json index 9432b2b3f1..dabb67bf51 100644 --- a/packages/sdk/server-ai/examples/bedrock/package.json +++ b/packages/sdk/server-ai/examples/bedrock/package.json @@ -24,7 +24,7 @@ "dependencies": { "@aws-sdk/client-bedrock-runtime": "^3.679.0", "@launchdarkly/node-server-sdk": "^9.7.1", - "@launchdarkly/server-sdk-ai": "0.7.0" + "@launchdarkly/server-sdk-ai": "0.9.6" }, "devDependencies": { "@trivago/prettier-plugin-sort-imports": "^4.1.1", diff --git a/packages/sdk/server-ai/examples/openai/README.md b/packages/sdk/server-ai/examples/openai/README.md index ca285018dc..aa59cf7a2e 100644 --- a/packages/sdk/server-ai/examples/openai/README.md +++ b/packages/sdk/server-ai/examples/openai/README.md @@ -24,7 +24,7 @@ yarn build Before running the example, make sure to set the following environment variables: - `LAUNCHDARKLY_SDK_KEY`: Your LaunchDarkly SDK key -- `LAUNCHDARKLY_AI_CONFIG_KEY`: Your LaunchDarkly AI configuration key (defaults to 'sample-ai-config' if not set) +- `LAUNCHDARKLY_AI_CONFIG_KEY`: Your LaunchDarkly AI Config key (defaults to 'sample-ai-config' if not set) - `OPENAI_API_KEY`: Your OpenAI API key ## Usage @@ -46,4 +46,4 @@ yarn start ## Note -This example uses OpenAI's chat completions API. Make sure your LaunchDarkly AI configuration is set up correctly to work with OpenAI's models and API structure. +This example uses OpenAI's chat completions API. Make sure your LaunchDarkly AI Config is set up correctly to work with OpenAI's models and API structure. diff --git a/packages/sdk/server-ai/examples/openai/package.json b/packages/sdk/server-ai/examples/openai/package.json index 364e4c75eb..7660f0764d 100644 --- a/packages/sdk/server-ai/examples/openai/package.json +++ b/packages/sdk/server-ai/examples/openai/package.json @@ -22,7 +22,7 @@ "license": "Apache-2.0", "dependencies": { "@launchdarkly/node-server-sdk": "^9.7.1", - "@launchdarkly/server-sdk-ai": "0.7.0", + "@launchdarkly/server-sdk-ai": "0.9.6", "openai": "^4.58.1" }, "devDependencies": { diff --git a/packages/sdk/server-ai/package.json b/packages/sdk/server-ai/package.json index 2302d4d620..741d919f79 100644 --- a/packages/sdk/server-ai/package.json +++ b/packages/sdk/server-ai/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/server-sdk-ai", - "version": "0.7.0", + "version": "0.9.6", "description": "LaunchDarkly AI SDK for Server-Side JavaScript", "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/sdk/server-ai", "repository": { @@ -29,7 +29,7 @@ "mustache": "^4.2.0" }, "devDependencies": { - "@launchdarkly/js-server-sdk-common": "2.10.0", + "@launchdarkly/js-server-sdk-common": "2.15.0", "@trivago/prettier-plugin-sort-imports": "^4.1.1", "@types/jest": "^29.5.3", "@types/mustache": "^4.2.5", diff --git a/packages/sdk/server-ai/src/LDAIClientImpl.ts b/packages/sdk/server-ai/src/LDAIClientImpl.ts index 965c49ea7d..bca8431cce 100644 --- a/packages/sdk/server-ai/src/LDAIClientImpl.ts +++ b/packages/sdk/server-ai/src/LDAIClientImpl.ts @@ -13,6 +13,7 @@ import { LDClientMin } from './LDClientMin'; interface LDMeta { variationKey: string; enabled: boolean; + version?: number; } /** @@ -45,6 +46,8 @@ export class LDAIClientImpl implements LDAIClient { key, // eslint-disable-next-line no-underscore-dangle value._ldMeta?.variationKey ?? '', + // eslint-disable-next-line no-underscore-dangle + value._ldMeta?.version ?? 1, context, ); // eslint-disable-next-line no-underscore-dangle diff --git a/packages/sdk/server-ai/src/LDAIConfigTrackerImpl.ts b/packages/sdk/server-ai/src/LDAIConfigTrackerImpl.ts index c7906d271f..0972a5eee5 100644 --- a/packages/sdk/server-ai/src/LDAIConfigTrackerImpl.ts +++ b/packages/sdk/server-ai/src/LDAIConfigTrackerImpl.ts @@ -13,13 +13,15 @@ export class LDAIConfigTrackerImpl implements LDAIConfigTracker { private _ldClient: LDClientMin, private _configKey: string, private _variationKey: string, + private _version: number, private _context: LDContext, ) {} - private _getTrackData(): { variationKey: string; configKey: string } { + private _getTrackData(): { variationKey: string; configKey: string; version: number } { return { variationKey: this._variationKey, configKey: this._configKey, + version: this._version, }; } @@ -41,6 +43,16 @@ export class LDAIConfigTrackerImpl implements LDAIConfigTracker { } } + trackTimeToFirstToken(timeToFirstTokenMs: number) { + this._trackedMetrics.timeToFirstTokenMs = timeToFirstTokenMs; + this._ldClient.track( + '$ld:ai:tokens:ttf', + this._context, + this._getTrackData(), + timeToFirstTokenMs, + ); + } + trackFeedback(feedback: { kind: LDFeedbackKind }): void { this._trackedMetrics.feedback = feedback; if (feedback.kind === LDFeedbackKind.Positive) { diff --git a/packages/sdk/server-ai/src/api/LDAIClient.ts b/packages/sdk/server-ai/src/api/LDAIClient.ts index 43dbab3b27..4bf5f617e0 100644 --- a/packages/sdk/server-ai/src/api/LDAIClient.ts +++ b/packages/sdk/server-ai/src/api/LDAIClient.ts @@ -7,23 +7,22 @@ import { LDAIConfig, LDAIDefaults } from './config/LDAIConfig'; */ export interface LDAIClient { /** - * Retrieves and processes an AI configuration based on the provided key, LaunchDarkly context, - * and variables. This includes the model configuration and the processed prompts. + * Retrieves and processes an AI Config based on the provided key, LaunchDarkly context, + * and variables. This includes the model configuration and the customized messages. * - * @param key The key of the AI configuration. + * @param key The key of the AI Config. * @param context The LaunchDarkly context object that contains relevant information about the * current environment, user, or session. This context may influence how the configuration is * processed or personalized. + * @param defaultValue A fallback value containing model configuration and messages. This will + * be used if the configuration is not available from LaunchDarkly. * @param variables A map of key-value pairs representing dynamic variables to be injected into - * the prompt template. The keys correspond to placeholders within the template, and the values + * the message content. The keys correspond to placeholders within the template, and the values * are the corresponding replacements. - * @param defaultValue A fallback value containing model configuration and prompts. This will - * be used if the configurationuration is not available from launchdarkly. * - * @returns The AI configurationuration including a processed prompt after all variables have been - * substituted in the stored prompt template. This will also include a `tracker` used to track - * the state of the AI operation. If the configuration cannot be accessed from LaunchDarkly, then - * the return value will include information from the defaultValue. + * @returns The AI `config`, customized `messages`, and a `tracker`. If the configuration cannot be accessed from + * LaunchDarkly, then the return value will include information from the `defaultValue`. The returned `tracker` can + * be used to track AI operation metrics (latency, token usage, etc.). * * @example * ``` @@ -34,7 +33,7 @@ export interface LDAIClient { * enabled: false, * }; * - * const result = modelConfig(key, context, defaultValue, variables); + * const result = config(key, context, defaultValue, variables); * // Output: * { * enabled: true, @@ -44,7 +43,7 @@ export interface LDAIClient { * maxTokens: 4096, * userDefinedKey: "myValue", * }, - * prompt: [ + * messages: [ * { * role: "system", * content: "You are an amazing GPT." diff --git a/packages/sdk/server-ai/src/api/config/LDAIConfig.ts b/packages/sdk/server-ai/src/api/config/LDAIConfig.ts index 21949f7115..308ff02529 100644 --- a/packages/sdk/server-ai/src/api/config/LDAIConfig.ts +++ b/packages/sdk/server-ai/src/api/config/LDAIConfig.ts @@ -42,7 +42,7 @@ export interface LDMessage { } /** - * AI configuration and tracker. + * AI Config and tracker. */ export interface LDAIConfig { /** diff --git a/packages/sdk/server-ai/src/api/config/LDAIConfigTracker.ts b/packages/sdk/server-ai/src/api/config/LDAIConfigTracker.ts index 9cfc55c86f..2f92aa9386 100644 --- a/packages/sdk/server-ai/src/api/config/LDAIConfigTracker.ts +++ b/packages/sdk/server-ai/src/api/config/LDAIConfigTracker.ts @@ -23,6 +23,11 @@ export interface LDAIMetricSummary { * Any sentiment about the generation. */ feedback?: { kind: LDFeedbackKind }; + + /** + * Time to first token for this generation. + */ + timeToFirstTokenMs?: number; } /** @@ -62,6 +67,13 @@ export interface LDAIConfigTracker { */ trackFeedback(feedback: { kind: LDFeedbackKind }): void; + /** + * Track the time to first token for this generation. + * + * @param timeToFirstTokenMs The duration in milliseconds. + */ + trackTimeToFirstToken(timeToFirstTokenMs: number): void; + /** * Track the duration of execution of the provided function. * diff --git a/packages/sdk/server-node/CHANGELOG.md b/packages/sdk/server-node/CHANGELOG.md index 0520035b03..bbb626006e 100644 --- a/packages/sdk/server-node/CHANGELOG.md +++ b/packages/sdk/server-node/CHANGELOG.md @@ -2,6 +2,84 @@ All notable changes to `@launchdarkly/node-server-sdk` will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). +## [9.9.0](https://github.com/launchdarkly/js-core/compare/node-server-sdk-v9.8.0...node-server-sdk-v9.9.0) (2025-04-16) + + +### Features + +* Environment ID support for hooks ([#823](https://github.com/launchdarkly/js-core/issues/823)) ([63dc9f9](https://github.com/launchdarkly/js-core/commit/63dc9f9f1300c598e79be27909f8195ac66d54ef)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from 2.14.0 to 2.15.0 + +## [9.8.0](https://github.com/launchdarkly/js-core/compare/node-server-sdk-v9.7.7...node-server-sdk-v9.8.0) (2025-04-08) + + +### Features + +* Option to use gzip to compress event ([#814](https://github.com/launchdarkly/js-core/issues/814)) ([4e91431](https://github.com/launchdarkly/js-core/commit/4e914317d31378e2a1eaed5aa03e0ac6beac43d5)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from 2.13.0 to 2.14.0 + +## [9.7.7](https://github.com/launchdarkly/js-core/compare/node-server-sdk-v9.7.6...node-server-sdk-v9.7.7) (2025-03-26) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from 2.12.1 to 2.13.0 + +## [9.7.6](https://github.com/launchdarkly/js-core/compare/node-server-sdk-v9.7.5...node-server-sdk-v9.7.6) (2025-03-21) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from 2.12.0 to 2.12.1 + +## [9.7.5](https://github.com/launchdarkly/js-core/compare/node-server-sdk-v9.7.4...node-server-sdk-v9.7.5) (2025-03-17) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from 2.11.1 to 2.12.0 + +## [9.7.4](https://github.com/launchdarkly/js-core/compare/node-server-sdk-v9.7.3...node-server-sdk-v9.7.4) (2025-02-18) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from 2.11.0 to 2.11.1 + +## [9.7.3](https://github.com/launchdarkly/js-core/compare/node-server-sdk-v9.7.2...node-server-sdk-v9.7.3) (2025-01-22) + + +### Bug Fixes + +* Fix typo in proxy-authorization header for basic authentication. ([#720](https://github.com/launchdarkly/js-core/issues/720)) ([220b6d6](https://github.com/launchdarkly/js-core/commit/220b6d6d34331d271ca30f0cae363c734fcc38bf)), closes [#718](https://github.com/launchdarkly/js-core/issues/718) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from 2.10.0 to 2.11.0 + ## [9.7.2](https://github.com/launchdarkly/js-core/compare/node-server-sdk-v9.7.1...node-server-sdk-v9.7.2) (2024-11-14) diff --git a/packages/sdk/server-node/__tests__/platform/NodeRequests.test.ts b/packages/sdk/server-node/__tests__/platform/NodeRequests.test.ts index ab4b7168f8..608c9cdc29 100644 --- a/packages/sdk/server-node/__tests__/platform/NodeRequests.test.ts +++ b/packages/sdk/server-node/__tests__/platform/NodeRequests.test.ts @@ -8,73 +8,76 @@ const TEXT_RESPONSE = 'Test Text'; const JSON_RESPONSE = '{"text": "value"}'; interface TestRequestData { - body: string; + body: string | Buffer; method: string | undefined; headers: http.IncomingHttpHeaders; } -describe('given a default instance of NodeRequests', () => { - let resolve: (value: TestRequestData | PromiseLike) => void; - let promise: Promise; - let server: http.Server; - let resetResolve: () => void; - let resetPromise: Promise; - - beforeEach(() => { - resetPromise = new Promise((res) => { - resetResolve = res; - }); +let resolve: (value: TestRequestData | PromiseLike) => void; +let promise: Promise; +let server: http.Server; +let resetResolve: () => void; +let resetPromise: Promise; - promise = new Promise((res) => { - resolve = res; +beforeEach(() => { + resetPromise = new Promise((res) => { + resetResolve = res; + }); + + promise = new Promise((res) => { + resolve = res; + }); + server = http.createServer({ keepAlive: false }, (req, res) => { + const chunks: any[] = []; + req.on('data', (chunk) => { + chunks.push(chunk); }); - server = http.createServer({ keepAlive: false }, (req, res) => { - const chunks: any[] = []; - req.on('data', (chunk) => { - chunks.push(chunk); - }); - req.on('end', () => { - resolve({ - method: req.method, - body: Buffer.concat(chunks).toString(), - headers: req.headers, - }); + req.on('end', () => { + resolve({ + method: req.method, + body: + req.headers['content-encoding'] === 'gzip' + ? Buffer.concat(chunks) + : Buffer.concat(chunks).toString(), + headers: req.headers, }); + }); + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.setHeader('Connection', 'close'); + if ((req.url?.indexOf('json') || -1) >= 0) { + res.end(JSON_RESPONSE); + } else if ((req.url?.indexOf('interrupt') || -1) >= 0) { + res.destroy(); + } else if ((req.url?.indexOf('404') || -1) >= 0) { + res.statusCode = 404; + res.end(); + } else if ((req.url?.indexOf('reset') || -1) >= 0) { res.statusCode = 200; - res.setHeader('Content-Type', 'text/plain'); - res.setHeader('Connection', 'close'); - if ((req.url?.indexOf('json') || -1) >= 0) { - res.end(JSON_RESPONSE); - } else if ((req.url?.indexOf('interrupt') || -1) >= 0) { + res.flushHeaders(); + res.write('potato'); + setTimeout(() => { res.destroy(); - } else if ((req.url?.indexOf('404') || -1) >= 0) { - res.statusCode = 404; - res.end(); - } else if ((req.url?.indexOf('reset') || -1) >= 0) { - res.statusCode = 200; - res.flushHeaders(); - res.write('potato'); - setTimeout(() => { - res.destroy(); - resetResolve(); - }, 0); - } else if ((req.url?.indexOf('gzip') || -1) >= 0) { - res.setHeader('Content-Encoding', 'gzip'); - res.end(zlib.gzipSync(Buffer.from(JSON_RESPONSE, 'utf8'))); - } else { - res.end(TEXT_RESPONSE); - } - }); - server.listen(PORT); + resetResolve(); + }, 0); + } else if ((req.url?.indexOf('gzip') || -1) >= 0) { + res.setHeader('Content-Encoding', 'gzip'); + res.end(zlib.gzipSync(Buffer.from(JSON_RESPONSE, 'utf8'))); + } else { + res.end(TEXT_RESPONSE); + } }); + server.listen(PORT); +}); - afterEach( - async () => - new Promise((resolveClose) => { - server.close(resolveClose); - }), - ); +afterEach( + async () => + new Promise((resolveClose) => { + server.close(resolveClose); + }), +); +describe('given a default instance of NodeRequests', () => { const requests = new NodeRequests(); it('can make a basic get request', async () => { const res = await requests.fetch(`http://localhost:${PORT}`); @@ -120,6 +123,17 @@ describe('given a default instance of NodeRequests', () => { expect(serverResult.body).toEqual('BODY TEXT'); }); + it('can make a basic post ignoring compressBodyIfPossible', async () => { + await requests.fetch(`http://localhost:${PORT}`, { + method: 'POST', + body: 'BODY TEXT', + compressBodyIfPossible: true, + }); + const serverResult = await promise; + expect(serverResult.method).toEqual('POST'); + expect(serverResult.body).toEqual('BODY TEXT'); + }); + it('can make a request with headers', async () => { await requests.fetch(`http://localhost:${PORT}`, { method: 'POST', @@ -166,3 +180,30 @@ describe('given a default instance of NodeRequests', () => { expect(serverResult.body).toEqual(''); }); }); + +describe('given an instance of NodeRequests with enableEventCompression turned on', () => { + const requests = new NodeRequests(undefined, undefined, undefined, true); + it('can make a basic post with compressBodyIfPossible enabled', async () => { + await requests.fetch(`http://localhost:${PORT}`, { + method: 'POST', + body: 'BODY TEXT', + compressBodyIfPossible: true, + }); + const serverResult = await promise; + expect(serverResult.method).toEqual('POST'); + expect(serverResult.headers['content-encoding']).toEqual('gzip'); + expect(serverResult.body).toEqual(zlib.gzipSync('BODY TEXT')); + }); + + it('can make a basic post with compressBodyIfPossible disabled', async () => { + await requests.fetch(`http://localhost:${PORT}`, { + method: 'POST', + body: 'BODY TEXT', + compressBodyIfPossible: false, + }); + const serverResult = await promise; + expect(serverResult.method).toEqual('POST'); + expect(serverResult.headers['content-encoding']).toBeUndefined(); + expect(serverResult.body).toEqual('BODY TEXT'); + }); +}); diff --git a/packages/sdk/server-node/package.json b/packages/sdk/server-node/package.json index 24024688ce..b6cf46e9c8 100644 --- a/packages/sdk/server-node/package.json +++ b/packages/sdk/server-node/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/node-server-sdk", - "version": "9.7.2", + "version": "9.9.0", "description": "LaunchDarkly Server-Side SDK for Node.js", "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/sdk/server-node", "repository": { @@ -45,9 +45,9 @@ }, "license": "Apache-2.0", "dependencies": { - "@launchdarkly/js-server-sdk-common": "2.10.0", + "@launchdarkly/js-server-sdk-common": "2.15.0", "https-proxy-agent": "^5.0.1", - "launchdarkly-eventsource": "2.0.3" + "launchdarkly-eventsource": "2.1.0" }, "devDependencies": { "@trivago/prettier-plugin-sort-imports": "^4.1.1", diff --git a/packages/sdk/server-node/src/platform/NodePlatform.ts b/packages/sdk/server-node/src/platform/NodePlatform.ts index 7a5fed5cbd..d72c6e79cd 100644 --- a/packages/sdk/server-node/src/platform/NodePlatform.ts +++ b/packages/sdk/server-node/src/platform/NodePlatform.ts @@ -16,6 +16,11 @@ export default class NodePlatform implements platform.Platform { constructor(options: LDOptions) { this.info = new NodeInfo(options); - this.requests = new NodeRequests(options.tlsParams, options.proxyOptions, options.logger); + this.requests = new NodeRequests( + options.tlsParams, + options.proxyOptions, + options.logger, + options.enableEventCompression, + ); } } diff --git a/packages/sdk/server-node/src/platform/NodeRequests.ts b/packages/sdk/server-node/src/platform/NodeRequests.ts index eb95a64623..da7b4931b7 100644 --- a/packages/sdk/server-node/src/platform/NodeRequests.ts +++ b/packages/sdk/server-node/src/platform/NodeRequests.ts @@ -5,6 +5,8 @@ import { HttpsProxyAgentOptions } from 'https-proxy-agent'; // No types for the event source. // @ts-ignore import { EventSource as LDEventSource } from 'launchdarkly-eventsource'; +import { promisify } from 'util'; +import * as zlib from 'zlib'; import { EventSourceCapabilities, @@ -16,6 +18,8 @@ import { import NodeResponse from './NodeResponse'; +const gzip = promisify(zlib.gzip); + function processTlsOptions(tlsOptions: LDTLSOptions): https.AgentOptions { const options: https.AgentOptions & { [index: string]: any } = { ca: tlsOptions.ca, @@ -101,25 +105,44 @@ export default class NodeRequests implements platform.Requests { private _hasProxyAuth: boolean = false; - constructor(tlsOptions?: LDTLSOptions, proxyOptions?: LDProxyOptions, logger?: LDLogger) { + private _enableBodyCompression: boolean = false; + + constructor( + tlsOptions?: LDTLSOptions, + proxyOptions?: LDProxyOptions, + logger?: LDLogger, + enableEventCompression?: boolean, + ) { this._agent = createAgent(tlsOptions, proxyOptions, logger); this._hasProxy = !!proxyOptions; this._hasProxyAuth = !!proxyOptions?.auth; + this._enableBodyCompression = !!enableEventCompression; } - fetch(url: string, options: platform.Options = {}): Promise { + async fetch(url: string, options: platform.Options = {}): Promise { const isSecure = url.startsWith('https://'); const impl = isSecure ? https : http; + const headers = { ...options.headers }; + let bodyData: String | Buffer | undefined = options.body; + // For get requests we are going to automatically support compressed responses. // Note this does not affect SSE as the event source is not using this fetch implementation. - const headers = - options.method?.toLowerCase() === 'get' - ? { - ...options.headers, - 'accept-encoding': 'gzip', - } - : options.headers; + if (options.method?.toLowerCase() === 'get') { + headers['accept-encoding'] = 'gzip'; + } + // For post requests we are going to support compressed post bodies if the + // enableEventCompression config setting is true and the compressBodyIfPossible + // option is true. + else if ( + this._enableBodyCompression && + !!options.compressBodyIfPossible && + options.method?.toLowerCase() === 'post' && + options.body + ) { + headers['content-encoding'] = 'gzip'; + bodyData = await gzip(Buffer.from(options.body, 'utf8')); + } return new Promise((resolve, reject) => { const req = impl.request( @@ -133,8 +156,8 @@ export default class NodeRequests implements platform.Requests { (res) => resolve(new NodeResponse(res)), ); - if (options.body) { - req.write(options.body); + if (bodyData) { + req.write(bodyData); } req.on('error', (err) => { diff --git a/packages/sdk/svelte/playwright.config.ts b/packages/sdk/svelte/playwright.config.ts index 1c5d7a1fd3..b59a0e42e0 100644 --- a/packages/sdk/svelte/playwright.config.ts +++ b/packages/sdk/svelte/playwright.config.ts @@ -1,12 +1,12 @@ import type { PlaywrightTestConfig } from '@playwright/test'; const config: PlaywrightTestConfig = { - webServer: { - command: 'npm run build && npm run preview', - port: 4173 - }, - testDir: 'tests', - testMatch: /(.+\.)?(test|spec)\.[jt]s/ + webServer: { + command: 'npm run build && npm run preview', + port: 4173, + }, + testDir: 'tests', + testMatch: /(.+\.)?(test|spec)\.[jt]s/, }; export default config; diff --git a/packages/sdk/svelte/svelte.config.js b/packages/sdk/svelte/svelte.config.js index 734094d0b6..bec8d59d65 100644 --- a/packages/sdk/svelte/svelte.config.js +++ b/packages/sdk/svelte/svelte.config.js @@ -11,8 +11,8 @@ const config = { // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. // If your environment is not supported or you settled on a specific environment, switch out the adapter. // See https://kit.svelte.dev/docs/adapters for more information about adapters. - adapter: adapter() - } + adapter: adapter(), + }, }; export default config; diff --git a/packages/sdk/svelte/vite.config.ts b/packages/sdk/svelte/vite.config.ts index eef9c1da2c..ab8e95ad3a 100644 --- a/packages/sdk/svelte/vite.config.ts +++ b/packages/sdk/svelte/vite.config.ts @@ -1,5 +1,6 @@ import { sveltekit } from '@sveltejs/kit/vite'; import path from 'path'; +// eslint-disable-next-line import/no-extraneous-dependencies import { defineConfig } from 'vitest/config'; export default defineConfig({ diff --git a/packages/sdk/vercel/CHANGELOG.md b/packages/sdk/vercel/CHANGELOG.md index 23b7a1d361..60de86fdec 100644 --- a/packages/sdk/vercel/CHANGELOG.md +++ b/packages/sdk/vercel/CHANGELOG.md @@ -20,6 +20,69 @@ All notable changes to the LaunchDarkly SDK for Vercel Edge Config will be docum * dependencies * @launchdarkly/js-server-sdk-common-edge bumped from 2.2.1 to 2.2.2 +## [1.3.28](https://github.com/launchdarkly/js-core/compare/vercel-server-sdk-v1.3.27...vercel-server-sdk-v1.3.28) (2025-04-16) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common-edge bumped from 2.6.3 to 2.6.4 + +## [1.3.27](https://github.com/launchdarkly/js-core/compare/vercel-server-sdk-v1.3.26...vercel-server-sdk-v1.3.27) (2025-04-08) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common-edge bumped from 2.6.2 to 2.6.3 + +## [1.3.26](https://github.com/launchdarkly/js-core/compare/vercel-server-sdk-v1.3.25...vercel-server-sdk-v1.3.26) (2025-03-26) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common-edge bumped from 2.6.1 to 2.6.2 + +## [1.3.25](https://github.com/launchdarkly/js-core/compare/vercel-server-sdk-v1.3.24...vercel-server-sdk-v1.3.25) (2025-03-21) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common-edge bumped from 2.6.0 to 2.6.1 + +## [1.3.24](https://github.com/launchdarkly/js-core/compare/vercel-server-sdk-v1.3.23...vercel-server-sdk-v1.3.24) (2025-03-17) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common-edge bumped from 2.5.4 to 2.6.0 + +## [1.3.23](https://github.com/launchdarkly/js-core/compare/vercel-server-sdk-v1.3.22...vercel-server-sdk-v1.3.23) (2025-02-18) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common-edge bumped from 2.5.3 to 2.5.4 + +## [1.3.22](https://github.com/launchdarkly/js-core/compare/vercel-server-sdk-v1.3.21...vercel-server-sdk-v1.3.22) (2025-01-22) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common-edge bumped from 2.5.2 to 2.5.3 + ## [1.3.21](https://github.com/launchdarkly/js-core/compare/vercel-server-sdk-v1.3.20...vercel-server-sdk-v1.3.21) (2024-11-14) diff --git a/packages/sdk/vercel/package.json b/packages/sdk/vercel/package.json index 75e9127c9c..c50de76763 100644 --- a/packages/sdk/vercel/package.json +++ b/packages/sdk/vercel/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/vercel-server-sdk", - "version": "1.3.21", + "version": "1.3.28", "description": "LaunchDarkly Server-Side SDK for Vercel Edge", "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/sdk/vercel", "repository": { @@ -36,7 +36,7 @@ "check": "yarn prettier && yarn lint && yarn build && yarn test" }, "dependencies": { - "@launchdarkly/js-server-sdk-common-edge": "2.5.2", + "@launchdarkly/js-server-sdk-common-edge": "2.6.4", "@vercel/edge-config": "^1.1.0", "crypto-js": "^4.1.1" }, diff --git a/packages/shared/akamai-edgeworker-sdk/CHANGELOG.md b/packages/shared/akamai-edgeworker-sdk/CHANGELOG.md index 0d22c3e389..2ac65acc3a 100644 --- a/packages/shared/akamai-edgeworker-sdk/CHANGELOG.md +++ b/packages/shared/akamai-edgeworker-sdk/CHANGELOG.md @@ -86,6 +86,87 @@ All notable changes to the LaunchDarkly SDK for Akamai Workers will be documente * dependencies * @launchdarkly/js-server-sdk-common bumped from ^2.2.1 to ^2.2.2 +## [2.0.5](https://github.com/launchdarkly/js-core/compare/akamai-edgeworker-sdk-common-v2.0.4...akamai-edgeworker-sdk-common-v2.0.5) (2025-04-16) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from ^2.14.0 to ^2.15.0 + +## [2.0.4](https://github.com/launchdarkly/js-core/compare/akamai-edgeworker-sdk-common-v2.0.3...akamai-edgeworker-sdk-common-v2.0.4) (2025-04-08) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from ^2.13.0 to ^2.14.0 + +## [2.0.3](https://github.com/launchdarkly/js-core/compare/akamai-edgeworker-sdk-common-v2.0.2...akamai-edgeworker-sdk-common-v2.0.3) (2025-03-26) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from ^2.12.1 to ^2.13.0 + +## [2.0.2](https://github.com/launchdarkly/js-core/compare/akamai-edgeworker-sdk-common-v2.0.1...akamai-edgeworker-sdk-common-v2.0.2) (2025-03-21) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from ^2.12.0 to ^2.12.1 + +## [2.0.1](https://github.com/launchdarkly/js-core/compare/akamai-edgeworker-sdk-common-v2.0.0...akamai-edgeworker-sdk-common-v2.0.1) (2025-03-17) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from ^2.11.1 to ^2.12.0 + +## [2.0.0](https://github.com/launchdarkly/js-core/compare/akamai-edgeworker-sdk-common-v1.4.1...akamai-edgeworker-sdk-common-v2.0.0) (2025-02-26) + + +### ⚠ BREAKING CHANGES + +* Replace prefetch behavior with simple TTL cache ([#786](https://github.com/launchdarkly/js-core/issues/786)) + +### Features + +* Replace prefetch behavior with simple TTL cache ([#786](https://github.com/launchdarkly/js-core/issues/786)) ([48b48cf](https://github.com/launchdarkly/js-core/commit/48b48cf69d518dc70a557ffd1dfb0209aee0b124)) + +## [1.4.1](https://github.com/launchdarkly/js-core/compare/akamai-edgeworker-sdk-common-v1.4.0...akamai-edgeworker-sdk-common-v1.4.1) (2025-02-18) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from ^2.11.0 to ^2.11.1 + +## [1.4.0](https://github.com/launchdarkly/js-core/compare/akamai-edgeworker-sdk-common-v1.3.3...akamai-edgeworker-sdk-common-v1.4.0) (2025-01-30) + + +### Features + +* Add cacheTtlMs option ([#760](https://github.com/launchdarkly/js-core/issues/760)) ([4f961dd](https://github.com/launchdarkly/js-core/commit/4f961dd16fd10f5bb55dd2116d26b218944bfeb2)) + +## [1.3.3](https://github.com/launchdarkly/js-core/compare/akamai-edgeworker-sdk-common-v1.3.2...akamai-edgeworker-sdk-common-v1.3.3) (2025-01-22) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from ^2.10.0 to ^2.11.0 + ## [1.3.2](https://github.com/launchdarkly/js-core/compare/akamai-edgeworker-sdk-common-v1.3.1...akamai-edgeworker-sdk-common-v1.3.2) (2024-11-14) diff --git a/packages/shared/akamai-edgeworker-sdk/__tests__/featureStore/cache.test.ts b/packages/shared/akamai-edgeworker-sdk/__tests__/featureStore/cache.test.ts new file mode 100644 index 0000000000..1e4212769b --- /dev/null +++ b/packages/shared/akamai-edgeworker-sdk/__tests__/featureStore/cache.test.ts @@ -0,0 +1,113 @@ +import { AsyncStoreFacade, LDFeatureStore } from '@launchdarkly/js-server-sdk-common'; + +import { EdgeFeatureStore, EdgeProvider } from '../../src/featureStore'; +import * as testData from '../testData.json'; + +describe('EdgeFeatureStore', () => { + const sdkKey = 'sdkKey'; + const mockLogger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; + const mockEdgeProvider: EdgeProvider = { + get: jest.fn(), + }; + const mockGet = mockEdgeProvider.get as jest.Mock; + let featureStore: LDFeatureStore; + let asyncFeatureStore: AsyncStoreFacade; + + describe('with infinite cache', () => { + beforeEach(() => { + mockGet.mockImplementation(() => Promise.resolve(JSON.stringify(testData))); + featureStore = new EdgeFeatureStore( + mockEdgeProvider, + sdkKey, + 'MockEdgeProvider', + mockLogger, + 0, + ); + asyncFeatureStore = new AsyncStoreFacade(featureStore); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('will cache the initial request', async () => { + await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag1'); + await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag1'); + await asyncFeatureStore.all({ namespace: 'features' }); + + expect(mockGet).toHaveBeenCalledTimes(1); + }); + }); + + describe('with cache disabled', () => { + beforeEach(() => { + mockGet.mockImplementation(() => Promise.resolve(JSON.stringify(testData))); + featureStore = new EdgeFeatureStore( + mockEdgeProvider, + sdkKey, + 'MockEdgeProvider', + mockLogger, + -1, + ); + asyncFeatureStore = new AsyncStoreFacade(featureStore); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('caches nothing', async () => { + await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag1'); + await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag1'); + await asyncFeatureStore.all({ namespace: 'features' }); + + expect(mockGet).toHaveBeenCalledTimes(3); + }); + }); + + describe('with finite cache', () => { + beforeEach(() => { + mockGet.mockImplementation(() => Promise.resolve(JSON.stringify(testData))); + featureStore = new EdgeFeatureStore( + mockEdgeProvider, + sdkKey, + 'MockEdgeProvider', + mockLogger, + 100, + ); + asyncFeatureStore = new AsyncStoreFacade(featureStore); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('expires after configured duration', async () => { + jest.spyOn(Date, 'now').mockImplementation(() => 0); + await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag1'); + await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag1'); + await asyncFeatureStore.all({ namespace: 'features' }); + + expect(mockGet).toHaveBeenCalledTimes(1); + + jest.spyOn(Date, 'now').mockImplementation(() => 99); + await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag1'); + await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag1'); + await asyncFeatureStore.all({ namespace: 'features' }); + + expect(mockGet).toHaveBeenCalledTimes(1); + + jest.spyOn(Date, 'now').mockImplementation(() => 100); + await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag1'); + await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag1'); + await asyncFeatureStore.all({ namespace: 'features' }); + + expect(mockGet).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/shared/akamai-edgeworker-sdk/__tests__/featureStore/index.test.ts b/packages/shared/akamai-edgeworker-sdk/__tests__/featureStore/index.test.ts index ce243c6ed3..df51cd6107 100644 --- a/packages/shared/akamai-edgeworker-sdk/__tests__/featureStore/index.test.ts +++ b/packages/shared/akamai-edgeworker-sdk/__tests__/featureStore/index.test.ts @@ -21,7 +21,13 @@ describe('EdgeFeatureStore', () => { beforeEach(() => { mockGet.mockImplementation(() => Promise.resolve(JSON.stringify(testData))); - featureStore = new EdgeFeatureStore(mockEdgeProvider, sdkKey, 'MockEdgeProvider', mockLogger); + featureStore = new EdgeFeatureStore( + mockEdgeProvider, + sdkKey, + 'MockEdgeProvider', + mockLogger, + 0, + ); asyncFeatureStore = new AsyncStoreFacade(featureStore); }); diff --git a/packages/shared/akamai-edgeworker-sdk/__tests__/index.test.ts b/packages/shared/akamai-edgeworker-sdk/__tests__/index.test.ts index 9b0264f820..411905af00 100644 --- a/packages/shared/akamai-edgeworker-sdk/__tests__/index.test.ts +++ b/packages/shared/akamai-edgeworker-sdk/__tests__/index.test.ts @@ -6,6 +6,7 @@ const createClient = (sdkKey: string, mockLogger: LDLogger, mockEdgeProvider: Ed sdkKey, options: { logger: mockLogger, + cacheTtlMs: 0, }, featureStoreProvider: mockEdgeProvider, platformName: 'platform-name', @@ -40,14 +41,6 @@ describe('EdgeWorker', () => { it('should call edge providers get method only once', async () => { const client = createClient(sdkKey, mockLogger, mockEdgeProvider); await client.waitForInitialization(); - await client.allFlagsState({ kind: 'multi', l: { key: 'key' } }); - - expect(mockGet).toHaveBeenCalledTimes(1); - }); - - it('should call edge providers get method only 3 times', async () => { - const client = createClient(sdkKey, mockLogger, mockEdgeProvider); - await client.waitForInitialization(); const context: LDMultiKindContext = { kind: 'multi', l: { key: 'key' } }; @@ -55,7 +48,7 @@ describe('EdgeWorker', () => { await client.variation('testFlag1', context, false); await client.variationDetail('testFlag1', context, false); - expect(mockGet).toHaveBeenCalledTimes(3); + expect(mockGet).toHaveBeenCalledTimes(1); }); it('should successfully return data for allFlagsState', async () => { diff --git a/packages/shared/akamai-edgeworker-sdk/__tests__/platform/requests.test.ts b/packages/shared/akamai-edgeworker-sdk/__tests__/platform/requests.test.ts index e70836b070..fbd2555992 100644 --- a/packages/shared/akamai-edgeworker-sdk/__tests__/platform/requests.test.ts +++ b/packages/shared/akamai-edgeworker-sdk/__tests__/platform/requests.test.ts @@ -5,7 +5,7 @@ import EdgeRequests from '../../src/platform/requests'; const TEXT_RESPONSE = ''; const JSON_RESPONSE = {}; -describe('given a default instance of requets', () => { +describe('given a default instance of requests', () => { const requests = new EdgeRequests(); describe('fetch', () => { diff --git a/packages/shared/akamai-edgeworker-sdk/__tests__/utils/validateOptions.test.ts b/packages/shared/akamai-edgeworker-sdk/__tests__/utils/validateOptions.test.ts index 4126a9fe81..8953cb6a4a 100644 --- a/packages/shared/akamai-edgeworker-sdk/__tests__/utils/validateOptions.test.ts +++ b/packages/shared/akamai-edgeworker-sdk/__tests__/utils/validateOptions.test.ts @@ -26,7 +26,7 @@ const mockOptions = ({ }) => { const mockLogger = logger ?? BasicLogger.get(); const mockFeatureStore = - featureStore ?? new EdgeFeatureStore(edgeProvider, SDK_KEY, 'validationTest', mockLogger); + featureStore ?? new EdgeFeatureStore(edgeProvider, SDK_KEY, 'validationTest', mockLogger, 0); return { featureStore: allowEmptyFS ? undefined : mockFeatureStore, diff --git a/packages/shared/akamai-edgeworker-sdk/package.json b/packages/shared/akamai-edgeworker-sdk/package.json index 2b9f12c6f9..2e38c931c1 100644 --- a/packages/shared/akamai-edgeworker-sdk/package.json +++ b/packages/shared/akamai-edgeworker-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/akamai-edgeworker-sdk-common", - "version": "1.3.2", + "version": "2.0.5", "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/shared/akamai-edge-sdk", "repository": { "type": "git", @@ -55,7 +55,7 @@ "typescript": "5.1.6" }, "dependencies": { - "@launchdarkly/js-server-sdk-common": "^2.10.0", + "@launchdarkly/js-server-sdk-common": "^2.15.0", "crypto-js": "^4.1.1" } } diff --git a/packages/shared/akamai-edgeworker-sdk/src/api/LDClient.ts b/packages/shared/akamai-edgeworker-sdk/src/api/LDClient.ts index a34fc73a30..b5f9cf9fec 100644 --- a/packages/shared/akamai-edgeworker-sdk/src/api/LDClient.ts +++ b/packages/shared/akamai-edgeworker-sdk/src/api/LDClient.ts @@ -2,15 +2,9 @@ import { LDClientImpl, LDClient as LDClientType, - LDContext, - LDEvaluationDetail, - LDFlagsState, - LDFlagsStateOptions, - LDFlagValue, LDOptions, } from '@launchdarkly/js-server-sdk-common'; -import CacheableStoreProvider from '../featureStore/cacheableStoreProvider'; import EdgePlatform from '../platform'; import { createCallbacks, createOptions } from '../utils'; @@ -20,53 +14,20 @@ export interface CustomLDOptions extends LDOptions {} * The LaunchDarkly Akamai SDK edge client object. */ class LDClient extends LDClientImpl { - private _cacheableStoreProvider!: CacheableStoreProvider; - // sdkKey is only used to query featureStore, not to initialize with LD servers - constructor( - sdkKey: string, - platform: EdgePlatform, - options: LDOptions, - storeProvider: CacheableStoreProvider, - ) { + constructor(sdkKey: string, platform: EdgePlatform, options: LDOptions) { const finalOptions = createOptions(options); super(sdkKey, platform, finalOptions, createCallbacks(finalOptions.logger)); - this._cacheableStoreProvider = storeProvider; - } - - override waitForInitialization(): Promise { - // we need to resolve the promise immediately because Akamai's runtime doesnt - // have a setimeout so everything executes synchronously. - return Promise.resolve(this); } - override async variation( - key: string, - context: LDContext, - defaultValue: LDFlagValue, - callback?: (err: any, res: LDFlagValue) => void, - ): Promise { - await this._cacheableStoreProvider.prefetchPayloadFromOriginStore(); - return super.variation(key, context, defaultValue, callback); + override initialized(): boolean { + return true; } - override async variationDetail( - key: string, - context: LDContext, - defaultValue: LDFlagValue, - callback?: (err: any, res: LDEvaluationDetail) => void, - ): Promise { - await this._cacheableStoreProvider.prefetchPayloadFromOriginStore(); - return super.variationDetail(key, context, defaultValue, callback); - } - - override async allFlagsState( - context: LDContext, - options?: LDFlagsStateOptions, - callback?: (err: Error | null, res: LDFlagsState) => void, - ): Promise { - await this._cacheableStoreProvider.prefetchPayloadFromOriginStore(); - return super.allFlagsState(context, options, callback); + override waitForInitialization(): Promise { + // we need to resolve the promise immediately because Akamai's runtime doesn't + // have a setTimeout so everything executes synchronously. + return Promise.resolve(this); } } diff --git a/packages/shared/akamai-edgeworker-sdk/src/featureStore/cache.ts b/packages/shared/akamai-edgeworker-sdk/src/featureStore/cache.ts new file mode 100644 index 0000000000..3e8b021b3a --- /dev/null +++ b/packages/shared/akamai-edgeworker-sdk/src/featureStore/cache.ts @@ -0,0 +1,43 @@ +interface CacheItem { + value: any; + expiration: number; +} + +export default class Cache { + private _cache: CacheItem | undefined; + + constructor(private readonly _cacheTtlMs: number) {} + + get(): any | undefined { + // If the cacheTtlMs is less than 0, the cache is disabled. + if (this._cacheTtlMs < 0) { + return undefined; + } + + // If there isn't a cached item, we must return undefined. + if (this._cache === undefined) { + return undefined; + } + + // A cacheTtlMs of 0 is infinite caching, so we can always return the + // value. + // + // We also want to return the value if it hasn't expired. + if (this._cacheTtlMs === 0 || Date.now() < this._cache.expiration) { + return this._cache.value; + } + + // If you have gotten this far, the cache is stale. Better to drop it as a + // way to short-circuit checking the freshness again. + this._cache = undefined; + + return undefined; + } + + set(value: any): void { + this._cache = { + value, + expiration: Date.now() + this._cacheTtlMs, + }; + } +} diff --git a/packages/shared/akamai-edgeworker-sdk/src/featureStore/cacheableStoreProvider.ts b/packages/shared/akamai-edgeworker-sdk/src/featureStore/cacheableStoreProvider.ts deleted file mode 100644 index 11aecdf658..0000000000 --- a/packages/shared/akamai-edgeworker-sdk/src/featureStore/cacheableStoreProvider.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { EdgeProvider } from '.'; - -/** - * Wraps around an edge provider to cache a copy of the sdk payload locally an explicit request is made to refetch data from the origin. - * The wrapper is neccessary to ensure that we dont make redundant sub-requests from Akamai to fetch an entire environment payload. - */ -export default class CacheableStoreProvider implements EdgeProvider { - cache: string | null | undefined; - - constructor( - private readonly _edgeProvider: EdgeProvider, - private readonly _rootKey: string, - ) {} - - /** - * Get data from the edge provider feature store. - * @param rootKey - * @returns - */ - async get(rootKey: string): Promise { - if (!this.cache) { - this.cache = await this._edgeProvider.get(rootKey); - } - - return this.cache; - } - - /** - * Invalidates cache and fetch environment payload data from origin. The result of this data is cached in memory. - * You should only call this function within a feature store to pre-fetch and cache payload data in environments - * where its expensive to make multiple outbound requests to the origin - * @param rootKey - * @returns - */ - async prefetchPayloadFromOriginStore(rootKey?: string): Promise { - this.cache = undefined; // clear the cache so that new data can be fetched from the origin - return this.get(rootKey || this._rootKey); - } -} diff --git a/packages/shared/akamai-edgeworker-sdk/src/featureStore/index.ts b/packages/shared/akamai-edgeworker-sdk/src/featureStore/index.ts index cf968f3916..2aee85b65f 100644 --- a/packages/shared/akamai-edgeworker-sdk/src/featureStore/index.ts +++ b/packages/shared/akamai-edgeworker-sdk/src/featureStore/index.ts @@ -8,6 +8,8 @@ import type { } from '@launchdarkly/js-server-sdk-common'; import { deserializePoll, noop } from '@launchdarkly/js-server-sdk-common'; +import Cache from './cache'; + export interface EdgeProvider { get: (rootKey: string) => Promise; } @@ -21,14 +23,17 @@ export const buildRootKey = (sdkKey: string) => `LD-Env-${sdkKey}`; export class EdgeFeatureStore implements LDFeatureStore { private readonly _rootKey: string; + private _cache: Cache; constructor( private readonly _edgeProvider: EdgeProvider, private readonly _sdkKey: string, private readonly _description: string, private _logger: LDLogger, + _cacheTtlMs: number, ) { this._rootKey = buildRootKey(this._sdkKey); + this._cache = new Cache(_cacheTtlMs); } async get( @@ -41,23 +46,14 @@ export class EdgeFeatureStore implements LDFeatureStore { this._logger.debug(`Requesting ${dataKey} from ${this._rootKey}.${kindKey}`); try { - const i = await this._edgeProvider.get(this._rootKey); - - if (!i) { - throw new Error(`${this._rootKey}.${kindKey} is not found in KV.`); - } - - const item = deserializePoll(i); - if (!item) { - throw new Error(`Error deserializing ${kindKey}`); - } + const storePayload = await this._getStorePayload(); switch (namespace) { case 'features': - callback(item.flags[dataKey]); + callback(storePayload.flags[dataKey]); break; case 'segments': - callback(item.segments[dataKey]); + callback(storePayload.segments[dataKey]); break; default: callback(null); @@ -73,22 +69,14 @@ export class EdgeFeatureStore implements LDFeatureStore { const kindKey = namespace === 'features' ? 'flags' : namespace; this._logger.debug(`Requesting all from ${this._rootKey}.${kindKey}`); try { - const i = await this._edgeProvider.get(this._rootKey); - if (!i) { - throw new Error(`${this._rootKey}.${kindKey} is not found in KV.`); - } - - const item = deserializePoll(i); - if (!item) { - throw new Error(`Error deserializing ${kindKey}`); - } + const storePayload = await this._getStorePayload(); switch (namespace) { case 'features': - callback(item.flags); + callback(storePayload.flags); break; case 'segments': - callback(item.segments); + callback(storePayload.segments); break; default: throw new Error(`Unsupported DataKind: ${namespace}`); @@ -99,6 +87,39 @@ export class EdgeFeatureStore implements LDFeatureStore { } } + // This method is used to retrieve the environment payload from the edge + // provider. It will cache the payload for the duration of the cacheTtlMs. + + /** + * This method is used to retrieve the environment payload from the edge + * provider. It will cache the payload for the duration of the cacheTtlMs. + * + * @returns + */ + private async _getStorePayload(): Promise< + Exclude, undefined> + > { + let payload = this._cache.get(); + if (payload !== undefined) { + return payload; + } + + const providerData = await this._edgeProvider.get(this._rootKey); + + if (!providerData) { + throw new Error(`${this._rootKey} is not found in KV.`); + } + + payload = deserializePoll(providerData); + if (!payload) { + throw new Error(`Error deserializing ${this._rootKey}`); + } + + this._cache.set(payload); + + return payload; + } + async initialized(callback: (isInitialized: boolean) => void = noop): Promise { const config = await this._edgeProvider.get(this._rootKey); const result = config !== null; diff --git a/packages/shared/akamai-edgeworker-sdk/src/index.ts b/packages/shared/akamai-edgeworker-sdk/src/index.ts index fb0713ee74..36c4225aae 100644 --- a/packages/shared/akamai-edgeworker-sdk/src/index.ts +++ b/packages/shared/akamai-edgeworker-sdk/src/index.ts @@ -1,8 +1,7 @@ import { BasicLogger, LDOptions as LDOptionsCommon } from '@launchdarkly/js-server-sdk-common'; import LDClient from './api/LDClient'; -import { buildRootKey, EdgeFeatureStore, EdgeProvider } from './featureStore'; -import CacheableStoreProvider from './featureStore/cacheableStoreProvider'; +import { EdgeFeatureStore, EdgeProvider } from './featureStore'; import EdgePlatform from './platform'; import createPlatformInfo from './platform/info'; import { validateOptions } from './utils'; @@ -12,7 +11,13 @@ import { validateOptions } from './utils'; * supported. sendEvents is unsupported and is only included as a beta * preview. */ -type LDOptions = Pick; +type LDOptions = { + /** + * The time-to-live for the cache in milliseconds. The default is 100ms. A + * value of 0 will cache indefinitely. + */ + cacheTtlMs?: number; +} & Pick; /** * The internal options include featureStore because that's how the LDClient @@ -33,15 +38,25 @@ type BaseSDKParams = { }; export const init = (params: BaseSDKParams): LDClient => { - const { sdkKey, options = {}, featureStoreProvider, platformName, sdkName, sdkVersion } = params; + const { + sdkKey, + options: inputOptions = {}, + featureStoreProvider, + platformName, + sdkName, + sdkVersion, + } = params; - const logger = options.logger ?? BasicLogger.get(); + const logger = inputOptions.logger ?? BasicLogger.get(); + const { cacheTtlMs, ...options } = inputOptions as any; - const cachableStoreProvider = new CacheableStoreProvider( + const featureStore = new EdgeFeatureStore( featureStoreProvider, - buildRootKey(sdkKey), + sdkKey, + 'Akamai', + logger, + cacheTtlMs ?? 100, ); - const featureStore = new EdgeFeatureStore(cachableStoreProvider, sdkKey, 'Akamai', logger); const ldOptions: LDOptionsCommon = { featureStore, @@ -53,5 +68,5 @@ export const init = (params: BaseSDKParams): LDClient => { validateOptions(params.sdkKey, ldOptions); const platform = createPlatformInfo(platformName, sdkName, sdkVersion); - return new LDClient(sdkKey, new EdgePlatform(platform), ldOptions, cachableStoreProvider); + return new LDClient(sdkKey, new EdgePlatform(platform), ldOptions); }; diff --git a/packages/shared/common/CHANGELOG.md b/packages/shared/common/CHANGELOG.md index ee1090e57e..90557ef65f 100644 --- a/packages/shared/common/CHANGELOG.md +++ b/packages/shared/common/CHANGELOG.md @@ -2,6 +2,44 @@ All notable changes to `@launchdarkly/js-sdk-common` will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). +## [2.16.0](https://github.com/launchdarkly/js-core/compare/js-sdk-common-v2.15.0...js-sdk-common-v2.16.0) (2025-04-16) + + +### Features + +* Environment ID support for hooks ([#823](https://github.com/launchdarkly/js-core/issues/823)) ([63dc9f9](https://github.com/launchdarkly/js-core/commit/63dc9f9f1300c598e79be27909f8195ac66d54ef)) + +## [2.15.0](https://github.com/launchdarkly/js-core/compare/js-sdk-common-v2.14.0...js-sdk-common-v2.15.0) (2025-04-08) + + +### Features + +* Option to use gzip to compress event ([#814](https://github.com/launchdarkly/js-core/issues/814)) ([4e91431](https://github.com/launchdarkly/js-core/commit/4e914317d31378e2a1eaed5aa03e0ac6beac43d5)) + +## [2.14.0](https://github.com/launchdarkly/js-core/compare/js-sdk-common-v2.13.0...js-sdk-common-v2.14.0) (2025-03-26) + + +### Features + +* Support inline context for custom and migration events ([6aadf04](https://github.com/launchdarkly/js-core/commit/6aadf0463968f89bc3df10023267244c2ade1b31)) + + +### Bug Fixes + +* Deprecate LDMigrationOpEvent.contextKeys in favor of LDMigrationOpEvent.context ([6aadf04](https://github.com/launchdarkly/js-core/commit/6aadf0463968f89bc3df10023267244c2ade1b31)) + +## [2.13.0](https://github.com/launchdarkly/js-core/compare/js-sdk-common-v2.12.0...js-sdk-common-v2.13.0) (2025-01-22) + + +### Features + +* Adds StreamingProcessor for FDv2 to sdk-server package. ([#707](https://github.com/launchdarkly/js-core/issues/707)) ([7f5c275](https://github.com/launchdarkly/js-core/commit/7f5c2750dcc8341d049d7e736ca21ec36e168703)) + + +### Bug Fixes + +* Remove outdated reference to geolocation. ([#719](https://github.com/launchdarkly/js-core/issues/719)) ([0eeb3b6](https://github.com/launchdarkly/js-core/commit/0eeb3b6472419d257bf52c4ab3ae33864eae1902)) + ## [2.12.0](https://github.com/launchdarkly/js-core/compare/js-sdk-common-v2.11.0...js-sdk-common-v2.12.0) (2024-11-04) diff --git a/packages/shared/common/__tests__/internal/events/EventProcessor.test.ts b/packages/shared/common/__tests__/internal/events/EventProcessor.test.ts index 82de976796..49456024b7 100644 --- a/packages/shared/common/__tests__/internal/events/EventProcessor.test.ts +++ b/packages/shared/common/__tests__/internal/events/EventProcessor.test.ts @@ -671,9 +671,7 @@ describe('given an event processor', () => { key: 'eventkey', data: { thing: 'stuff' }, creationDate: 1000, - contextKeys: { - user: 'userKey', - }, + context: { ...user, kind: 'user' }, }, ]); }); @@ -701,9 +699,7 @@ describe('given an event processor', () => { key: 'eventkey', data: { thing: 'stuff' }, creationDate: 1000, - contextKeys: { - user: 'anon-user', - }, + context: { ...anonUser, kind: 'user' }, }, ]); }); @@ -733,9 +729,7 @@ describe('given an event processor', () => { key: 'eventkey', data: { thing: 'stuff' }, creationDate: 1000, - contextKeys: { - user: 'userKey', - }, + context: { ...user, kind: 'user' }, metricValue: 1.5, }, ]); diff --git a/packages/shared/common/__tests__/internal/events/EventSender.test.ts b/packages/shared/common/__tests__/internal/events/EventSender.test.ts index 63a6130dc8..65c6d06866 100644 --- a/packages/shared/common/__tests__/internal/events/EventSender.test.ts +++ b/packages/shared/common/__tests__/internal/events/EventSender.test.ts @@ -133,6 +133,7 @@ describe('given an event sender', () => { expect(mockFetch).toHaveBeenCalledTimes(1); expect(mockFetch).toHaveBeenCalledWith(`${basicConfig.serviceEndpoints.events}/bulk`, { body: JSON.stringify(testEventData1), + compressBodyIfPossible: true, headers: analyticsHeaders(uuid), method: 'POST', keepalive: true, @@ -150,6 +151,7 @@ describe('given an event sender', () => { expect(mockFetch).toHaveBeenCalledTimes(2); expect(mockFetch).toHaveBeenNthCalledWith(1, `${basicConfig.serviceEndpoints.events}/bulk`, { body: JSON.stringify(testEventData1), + compressBodyIfPossible: true, headers: analyticsHeaders(uuid), method: 'POST', keepalive: true, @@ -159,6 +161,7 @@ describe('given an event sender', () => { `${basicConfig.serviceEndpoints.events}/diagnostic`, { body: JSON.stringify(testEventData2), + compressBodyIfPossible: true, headers: diagnosticHeaders, method: 'POST', keepalive: true, diff --git a/packages/shared/common/__tests__/internal/metadata/InitMetadata.test.ts b/packages/shared/common/__tests__/internal/metadata/InitMetadata.test.ts new file mode 100644 index 0000000000..5d6d132ba4 --- /dev/null +++ b/packages/shared/common/__tests__/internal/metadata/InitMetadata.test.ts @@ -0,0 +1,17 @@ +import { initMetadataFromHeaders } from '../../../src/internal/metadata'; + +it('handles passing undefined headers', () => { + expect(initMetadataFromHeaders()).toBeUndefined(); +}); + +it('handles missing x-ld-envid header', () => { + expect(initMetadataFromHeaders({})).toBeUndefined(); +}); + +it('retrieves environmentId from headers', () => { + expect(initMetadataFromHeaders({ 'x-ld-envid': '12345' })).toEqual({ environmentId: '12345' }); +}); + +it('retrieves environmentId from mixed case header', () => { + expect(initMetadataFromHeaders({ 'X-LD-EnvId': '12345' })).toEqual({ environmentId: '12345' }); +}); diff --git a/packages/shared/common/package.json b/packages/shared/common/package.json index 707d75bc40..7285f611ec 100644 --- a/packages/shared/common/package.json +++ b/packages/shared/common/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/js-sdk-common", - "version": "2.12.0", + "version": "2.16.0", "type": "module", "main": "./dist/esm/index.mjs", "types": "./dist/esm/index.d.ts", diff --git a/packages/shared/common/src/api/platform/EventSource.ts b/packages/shared/common/src/api/platform/EventSource.ts index f44fc830b2..3c0d920700 100644 --- a/packages/shared/common/src/api/platform/EventSource.ts +++ b/packages/shared/common/src/api/platform/EventSource.ts @@ -4,13 +4,13 @@ export type EventName = string; export type EventListener = (event?: { data?: any }) => void; export type ProcessStreamResponse = { deserializeData: (data: string) => any; - processJson: (json: any) => void; + processJson: (json: any, initHeaders?: { [key: string]: string }) => void; }; export interface EventSource { onclose: (() => void) | undefined; onerror: ((err?: HttpErrorResponse) => void) | undefined; - onopen: (() => void) | undefined; + onopen: ((e: { headers?: { [key: string]: string } }) => void) | undefined; onretrying: ((e: { delayMillis: number }) => void) | undefined; addEventListener(type: EventName, listener: EventListener): void; diff --git a/packages/shared/common/src/api/platform/Requests.ts b/packages/shared/common/src/api/platform/Requests.ts index 8b0438d20f..6eea3b8e89 100644 --- a/packages/shared/common/src/api/platform/Requests.ts +++ b/packages/shared/common/src/api/platform/Requests.ts @@ -75,6 +75,11 @@ export interface Options { headers?: Record; method?: string; body?: string; + /** + * Gzip compress the post body only if the underlying SDK framework supports it + * and the config option enableEventCompression is set to true. + */ + compressBodyIfPossible?: boolean; timeout?: number; /** * For use in browser environments. Platform support will be best effort for this field. diff --git a/packages/shared/common/src/internal/events/EventProcessor.ts b/packages/shared/common/src/internal/events/EventProcessor.ts index 76c7bf1acc..e6963e5efe 100644 --- a/packages/shared/common/src/internal/events/EventProcessor.ts +++ b/packages/shared/common/src/internal/events/EventProcessor.ts @@ -32,7 +32,7 @@ interface CustomOutputEvent { kind: 'custom'; creationDate: number; key: string; - contextKeys: Record; + context: FilteredContext; data?: any; metricValue?: number; samplingRatio?: number; @@ -83,9 +83,11 @@ interface PageviewOutputEvent { */ type DiagnosticEvent = any; -interface MigrationOutputEvent extends Omit { +interface MigrationOutputEvent extends Omit { // Make the sampling ratio optional so we can omit it when it is one. samplingRatio?: number; + // Context is optional because contextKeys is supported for backwards compatbility and may be provided instead of context. + context?: FilteredContext; } type OutputEvent = @@ -236,6 +238,7 @@ export default class EventProcessor implements LDEventProcessor { if (shouldSample(inputEvent.samplingRatio)) { const migrationEvent: MigrationOutputEvent = { ...inputEvent, + context: inputEvent.context ? this._contextFilter.filter(inputEvent.context) : undefined, }; if (migrationEvent.samplingRatio === 1) { delete migrationEvent.samplingRatio; @@ -331,7 +334,7 @@ export default class EventProcessor implements LDEventProcessor { kind: 'custom', creationDate: event.creationDate, key: event.key, - contextKeys: event.context.kindsAndKeys, + context: this._contextFilter.filter(event.context), }; if (event.samplingRatio !== 1) { diff --git a/packages/shared/common/src/internal/events/EventSender.ts b/packages/shared/common/src/internal/events/EventSender.ts index dbb46a4304..0c16752403 100644 --- a/packages/shared/common/src/internal/events/EventSender.ts +++ b/packages/shared/common/src/internal/events/EventSender.ts @@ -64,6 +64,7 @@ export default class EventSender implements LDEventSender { const { status, headers: resHeaders } = await this._requests.fetch(uri, { headers, body: JSON.stringify(events), + compressBodyIfPossible: true, method: 'POST', // When sending events from browser environments the request should be completed even // if the user is navigating away from the page. diff --git a/packages/shared/common/src/internal/events/InputMigrationEvent.ts b/packages/shared/common/src/internal/events/InputMigrationEvent.ts index 0e8b3a2223..724f1235da 100644 --- a/packages/shared/common/src/internal/events/InputMigrationEvent.ts +++ b/packages/shared/common/src/internal/events/InputMigrationEvent.ts @@ -2,12 +2,17 @@ // shared implementation contains minimal typing. If/When migration events are // to be supported by client-side SDKs the appropriate types would be moved // to the common implementation. +import Context from '../../Context'; export default interface InputMigrationEvent { kind: 'migration_op'; operation: string; creationDate: number; - contextKeys: Record; + /** + * @deprecated Use 'context' instead. + */ + contextKeys?: Record; + context?: Context; evaluation: any; measurements: any[]; samplingRatio: number; diff --git a/packages/shared/common/src/internal/index.ts b/packages/shared/common/src/internal/index.ts index 282da8f91f..ae6f5cb844 100644 --- a/packages/shared/common/src/internal/index.ts +++ b/packages/shared/common/src/internal/index.ts @@ -3,3 +3,4 @@ export * from './diagnostics'; export * from './evaluation'; export * from './events'; export * from './fdv2'; +export * from './metadata'; diff --git a/packages/shared/common/src/internal/metadata/InitMetadata.ts b/packages/shared/common/src/internal/metadata/InitMetadata.ts new file mode 100644 index 0000000000..db67fa3aef --- /dev/null +++ b/packages/shared/common/src/internal/metadata/InitMetadata.ts @@ -0,0 +1,26 @@ +/** + * Metadata used to initialize an LDFeatureStore. + */ +export interface InitMetadata { + environmentId: string; +} + +/** + * Creates an InitMetadata object from initialization headers. + * + * @param initHeaders Initialization headers received when establishing + * a streaming or polling connection to LD. + * @returns InitMetadata object, or undefined if initHeaders is undefined + * or missing the required header values. + */ +export function initMetadataFromHeaders(initHeaders?: { + [key: string]: string; +}): InitMetadata | undefined { + if (initHeaders) { + const envIdKey = Object.keys(initHeaders).find((key) => key.toLowerCase() === 'x-ld-envid'); + if (envIdKey) { + return { environmentId: initHeaders[envIdKey] }; + } + } + return undefined; +} diff --git a/packages/shared/common/src/internal/metadata/index.ts b/packages/shared/common/src/internal/metadata/index.ts new file mode 100644 index 0000000000..7e96b4a998 --- /dev/null +++ b/packages/shared/common/src/internal/metadata/index.ts @@ -0,0 +1,3 @@ +import { InitMetadata, initMetadataFromHeaders } from './InitMetadata'; + +export { InitMetadata, initMetadataFromHeaders }; diff --git a/packages/shared/sdk-client/CHANGELOG.md b/packages/shared/sdk-client/CHANGELOG.md index 0caccc416f..5cdc76f76b 100644 --- a/packages/shared/sdk-client/CHANGELOG.md +++ b/packages/shared/sdk-client/CHANGELOG.md @@ -1,5 +1,48 @@ # Changelog +## [1.12.6](https://github.com/launchdarkly/js-core/compare/js-client-sdk-common-v1.12.5...js-client-sdk-common-v1.12.6) (2025-04-16) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-sdk-common bumped from 2.15.0 to 2.16.0 + +## [1.12.5](https://github.com/launchdarkly/js-core/compare/js-client-sdk-common-v1.12.4...js-client-sdk-common-v1.12.5) (2025-04-08) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-sdk-common bumped from 2.14.0 to 2.15.0 + +## [1.12.4](https://github.com/launchdarkly/js-core/compare/js-client-sdk-common-v1.12.3...js-client-sdk-common-v1.12.4) (2025-03-26) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-sdk-common bumped from 2.13.0 to 2.14.0 + +## [1.12.3](https://github.com/launchdarkly/js-core/compare/js-client-sdk-common-v1.12.2...js-client-sdk-common-v1.12.3) (2025-02-06) + + +### Bug Fixes + +* Ensure streaming connection is closed on SDK close. ([#774](https://github.com/launchdarkly/js-core/issues/774)) ([f58e746](https://github.com/launchdarkly/js-core/commit/f58e746a089fb0cd5f6169f6c246e1f6515f5047)) + +## [1.12.2](https://github.com/launchdarkly/js-core/compare/js-client-sdk-common-v1.12.1...js-client-sdk-common-v1.12.2) (2025-01-22) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-sdk-common bumped from 2.12.0 to 2.13.0 + ## [1.12.1](https://github.com/launchdarkly/js-core/compare/js-client-sdk-common-v1.12.0...js-client-sdk-common-v1.12.1) (2024-11-22) diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.hooks.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.hooks.test.ts index 9d9c6172f7..42054630de 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.hooks.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.hooks.test.ts @@ -308,3 +308,49 @@ it('should not execute hooks for prerequisite evaluations', async () => { }, ); }); + +it('should execute afterTrack hooks when tracking events', async () => { + const testHook: Hook = { + beforeEvaluation: jest.fn(), + afterEvaluation: jest.fn(), + beforeIdentify: jest.fn(), + afterIdentify: jest.fn(), + afterTrack: jest.fn(), + getMetadata(): HookMetadata { + return { + name: 'test hook', + }; + }, + }; + + const platform = createBasicPlatform(); + const factory = makeTestDataManagerFactory('sdk-key', platform, { + disableNetwork: true, + }); + const client = new LDClientImpl( + 'sdk-key', + AutoEnvAttributes.Disabled, + platform, + { + sendEvents: false, + hooks: [testHook], + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, + }, + factory, + ); + + await client.identify({ kind: 'user', key: 'user-key' }); + client.track('test', { test: 'data' }, 42); + + expect(testHook.afterTrack).toHaveBeenCalledWith({ + key: 'test', + context: { kind: 'user', key: 'user-key' }, + data: { test: 'data' }, + metricValue: 42, + }); +}); diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts index b760e94104..2241c81c00 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts @@ -413,4 +413,21 @@ describe('sdk-client object', () => { }), ); }); + + test('closes event source when client is closed', async () => { + const carContext: LDContext = { kind: 'car', key: 'test-car' }; + + const mockCreateEventSource = jest.fn((streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateEvents('put', [{ data: JSON.stringify(defaultPutResponse) }]); + return mockEventSource; + }); + mockPlatform.requests.createEventSource = mockCreateEventSource; + + await ldc.identify(carContext); + expect(mockEventSource.closed).toBe(false); + + await ldc.close(); + expect(mockEventSource.closed).toBe(true); + }); }); diff --git a/packages/shared/sdk-client/__tests__/HookRunner.test.ts b/packages/shared/sdk-client/__tests__/hooks/HookRunner.test.ts similarity index 71% rename from packages/shared/sdk-client/__tests__/HookRunner.test.ts rename to packages/shared/sdk-client/__tests__/hooks/HookRunner.test.ts index 85d704df49..7ecca84ff2 100644 --- a/packages/shared/sdk-client/__tests__/HookRunner.test.ts +++ b/packages/shared/sdk-client/__tests__/hooks/HookRunner.test.ts @@ -1,7 +1,7 @@ import { LDContext, LDEvaluationDetail, LDLogger } from '@launchdarkly/js-sdk-common'; -import { Hook, IdentifySeriesResult } from '../src/api/integrations/Hooks'; -import HookRunner from '../src/HookRunner'; +import { Hook, IdentifySeriesResult } from '../../src/api/integrations/Hooks'; +import HookRunner from '../../src/HookRunner'; describe('given a hook runner and test hook', () => { let logger: LDLogger; @@ -22,6 +22,7 @@ describe('given a hook runner and test hook', () => { afterEvaluation: jest.fn(), beforeIdentify: jest.fn(), afterIdentify: jest.fn(), + afterTrack: jest.fn(), }; hookRunner = new HookRunner(logger, [testHook]); @@ -301,4 +302,125 @@ describe('given a hook runner and test hook', () => { ), ); }); + + it('should execute afterTrack hooks', () => { + const context: LDContext = { kind: 'user', key: 'user-123' }; + const key = 'test'; + const data = { test: 'data' }; + const metricValue = 42; + + const trackContext = { + key, + context, + data, + metricValue, + }; + + testHook.afterTrack = jest.fn(); + + hookRunner.afterTrack(trackContext); + + expect(testHook.afterTrack).toHaveBeenCalledWith(trackContext); + }); + + it('should handle errors in afterTrack hooks', () => { + const errorHook: Hook = { + getMetadata: jest.fn().mockReturnValue({ name: 'Error Hook' }), + afterTrack: jest.fn().mockImplementation(() => { + throw new Error('Hook error'); + }), + }; + + const errorHookRunner = new HookRunner(logger, [errorHook]); + + errorHookRunner.afterTrack({ + key: 'test', + context: { kind: 'user', key: 'user-123' }, + }); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining( + 'An error was encountered in "afterTrack" of the "Error Hook" hook: Error: Hook error', + ), + ); + }); + + it('should skip afterTrack execution if there are no hooks', () => { + const emptyHookRunner = new HookRunner(logger, []); + + emptyHookRunner.afterTrack({ + key: 'test', + context: { kind: 'user', key: 'user-123' }, + }); + + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('executes hook stages in the specified order', () => { + const beforeEvalOrder: string[] = []; + const afterEvalOrder: string[] = []; + const beforeIdentifyOrder: string[] = []; + const afterIdentifyOrder: string[] = []; + const afterTrackOrder: string[] = []; + + const createMockHook = (id: string): Hook => ({ + getMetadata: jest.fn().mockReturnValue({ name: `Hook ${id}` }), + beforeEvaluation: jest.fn().mockImplementation((_context, data) => { + beforeEvalOrder.push(id); + return data; + }), + afterEvaluation: jest.fn().mockImplementation((_context, data, _detail) => { + afterEvalOrder.push(id); + return data; + }), + beforeIdentify: jest.fn().mockImplementation((_context, data) => { + beforeIdentifyOrder.push(id); + return data; + }), + afterIdentify: jest.fn().mockImplementation((_context, data, _result) => { + afterIdentifyOrder.push(id); + return data; + }), + afterTrack: jest.fn().mockImplementation(() => { + afterTrackOrder.push(id); + }), + }); + + const hookA = createMockHook('a'); + const hookB = createMockHook('b'); + const hookC = createMockHook('c'); + + const runner = new HookRunner(logger, [hookA, hookB]); + runner.addHook(hookC); + + // Test evaluation order + runner.withEvaluation('flagKey', { kind: 'user', key: 'bob' }, 'default', () => ({ + value: false, + reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' }, + variationIndex: null, + })); + + // Test identify order + const identifyCallback = runner.identify({ kind: 'user', key: 'bob' }, 1000); + identifyCallback({ status: 'completed' }); + + // Test track order + runner.afterTrack({ + key: 'test', + context: { kind: 'user', key: 'bob' }, + data: { test: 'data' }, + metricValue: 42, + }); + + // Verify evaluation hooks order + expect(beforeEvalOrder).toEqual(['a', 'b', 'c']); + expect(afterEvalOrder).toEqual(['c', 'b', 'a']); + + // Verify identify hooks order + expect(beforeIdentifyOrder).toEqual(['a', 'b', 'c']); + expect(afterIdentifyOrder).toEqual(['c', 'b', 'a']); + + // Verify track hooks order + expect(afterTrackOrder).toEqual(['c', 'b', 'a']); + }); }); diff --git a/packages/shared/sdk-client/package.json b/packages/shared/sdk-client/package.json index 5cbef61df8..8100faf03b 100644 --- a/packages/shared/sdk-client/package.json +++ b/packages/shared/sdk-client/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/js-client-sdk-common", - "version": "1.12.1", + "version": "1.12.6", "type": "module", "main": "./dist/esm/index.mjs", "types": "./dist/esm/index.d.ts", @@ -43,7 +43,7 @@ }, "license": "Apache-2.0", "dependencies": { - "@launchdarkly/js-sdk-common": "2.12.0" + "@launchdarkly/js-sdk-common": "2.16.0" }, "devDependencies": { "@rollup/plugin-commonjs": "^25.0.0", diff --git a/packages/shared/sdk-client/src/DataManager.ts b/packages/shared/sdk-client/src/DataManager.ts index d3b33de1bc..bca06a6830 100644 --- a/packages/shared/sdk-client/src/DataManager.ts +++ b/packages/shared/sdk-client/src/DataManager.ts @@ -43,6 +43,11 @@ export interface DataManager { context: Context, identifyOptions?: LDIdentifyOptions, ): Promise; + + /** + * Closes the data manager. Any active connections are closed. + */ + close(): void; } /** @@ -69,6 +74,7 @@ export abstract class BaseDataManager implements DataManager { private _connectionParams?: ConnectionParams; protected readonly dataSourceStatusManager: DataSourceStatusManager; private readonly _dataSourceEventHandler: DataSourceEventHandler; + protected closed = false; constructor( protected readonly platform: Platform, @@ -221,4 +227,9 @@ export abstract class BaseDataManager implements DataManager { }, }; } + + public close() { + this.updateProcessor?.close(); + this.closed = true; + } } diff --git a/packages/shared/sdk-client/src/HookRunner.ts b/packages/shared/sdk-client/src/HookRunner.ts index 8380bfba11..87bda82cdd 100644 --- a/packages/shared/sdk-client/src/HookRunner.ts +++ b/packages/shared/sdk-client/src/HookRunner.ts @@ -7,12 +7,14 @@ import { IdentifySeriesContext, IdentifySeriesData, IdentifySeriesResult, + TrackSeriesContext, } from './api/integrations/Hooks'; import { LDEvaluationDetail } from './api/LDEvaluationDetail'; const UNKNOWN_HOOK_NAME = 'unknown hook'; const BEFORE_EVALUATION_STAGE_NAME = 'beforeEvaluation'; const AFTER_EVALUATION_STAGE_NAME = 'afterEvaluation'; +const AFTER_TRACK_STAGE_NAME = 'afterTrack'; function tryExecuteStage( logger: LDLogger, @@ -114,6 +116,21 @@ function executeAfterIdentify( } } +function executeAfterTrack(logger: LDLogger, hooks: Hook[], hookContext: TrackSeriesContext) { + // This iterates in reverse, versus reversing a shallow copy of the hooks, + // for efficiency. + for (let hookIndex = hooks.length - 1; hookIndex >= 0; hookIndex -= 1) { + const hook = hooks[hookIndex]; + tryExecuteStage( + logger, + AFTER_TRACK_STAGE_NAME, + getHookName(logger, hook), + () => hook?.afterTrack?.(hookContext), + undefined, + ); + } +} + export default class HookRunner { private readonly _hooks: Hook[] = []; @@ -164,4 +181,12 @@ export default class HookRunner { addHook(hook: Hook): void { this._hooks.push(hook); } + + afterTrack(hookContext: TrackSeriesContext): void { + if (this._hooks.length === 0) { + return; + } + const hooks: Hook[] = [...this._hooks]; + executeAfterTrack(this._logger, hooks, hookContext); + } } diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index b61d0c5c49..b23530087f 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -11,7 +11,6 @@ import { LDHeaders, LDLogger, Platform, - subsystem, timedPromise, TypeValidators, } from '@launchdarkly/js-sdk-common'; @@ -48,7 +47,6 @@ export default class LDClientImpl implements LDClient { private readonly _diagnosticsManager?: internal.DiagnosticsManager; private _eventProcessor?: internal.EventProcessor; readonly logger: LDLogger; - private _updateProcessor?: subsystem.LDStreamProcessor; private readonly _highTimeoutThreshold: number = 15; @@ -153,7 +151,7 @@ export default class LDClientImpl implements LDClient { async close(): Promise { await this.flush(); this._eventProcessor?.close(); - this._updateProcessor?.close(); + this.dataManager.close(); this.logger.debug('Closed event processor and data source.'); } @@ -309,6 +307,14 @@ export default class LDClientImpl implements LDClient { this._eventFactoryDefault.customEvent(key, this._checkedContext!, data, metricValue), ), ); + + this._hookRunner.afterTrack({ + key, + // The context is pre-checked above, so we know it can be unwrapped. + context: this._uncheckedContext!, + data, + metricValue, + }); } private _variationInternal( diff --git a/packages/shared/sdk-client/src/api/integrations/Hooks.ts b/packages/shared/sdk-client/src/api/integrations/Hooks.ts index 5382b26516..a453775bba 100644 --- a/packages/shared/sdk-client/src/api/integrations/Hooks.ts +++ b/packages/shared/sdk-client/src/api/integrations/Hooks.ts @@ -89,6 +89,28 @@ export interface IdentifySeriesResult { status: IdentifySeriesStatus; } +/** + * Contextual information provided to track stages. + */ +export interface TrackSeriesContext { + /** + * The key for the event being tracked. + */ + readonly key: string; + /** + * The context associated with the track operation. + */ + readonly context: LDContext; + /** + * The data associated with the track operation. + */ + readonly data?: unknown; + /** + * The metric value associated with the track operation. + */ + readonly metricValue?: number; +} + /** * Interface for extending SDK functionality via hooks. */ @@ -178,4 +200,13 @@ export interface Hook { data: IdentifySeriesData, result: IdentifySeriesResult, ): IdentifySeriesData; + + /** + * This method is called during the execution of the track process after the event + * has been enqueued. + * + * @param hookContext Contains information about the track operation being performed. This is not + * mutable. + */ + afterTrack?(hookContext: TrackSeriesContext): void; } diff --git a/packages/shared/sdk-server-edge/CHANGELOG.md b/packages/shared/sdk-server-edge/CHANGELOG.md index b6d43a158d..473b48bfee 100644 --- a/packages/shared/sdk-server-edge/CHANGELOG.md +++ b/packages/shared/sdk-server-edge/CHANGELOG.md @@ -96,6 +96,79 @@ * dependencies * @launchdarkly/js-server-sdk-common bumped from 2.2.1 to 2.2.2 +## [2.6.4](https://github.com/launchdarkly/js-core/compare/js-server-sdk-common-edge-v2.6.3...js-server-sdk-common-edge-v2.6.4) (2025-04-16) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from 2.14.0 to 2.15.0 + +## [2.6.3](https://github.com/launchdarkly/js-core/compare/js-server-sdk-common-edge-v2.6.2...js-server-sdk-common-edge-v2.6.3) (2025-04-08) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from 2.13.0 to 2.14.0 + +## [2.6.2](https://github.com/launchdarkly/js-core/compare/js-server-sdk-common-edge-v2.6.1...js-server-sdk-common-edge-v2.6.2) (2025-03-26) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from 2.12.1 to 2.13.0 + +## [2.6.1](https://github.com/launchdarkly/js-core/compare/js-server-sdk-common-edge-v2.6.0...js-server-sdk-common-edge-v2.6.1) (2025-03-21) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from 2.12.0 to 2.12.1 + +## [2.6.0](https://github.com/launchdarkly/js-core/compare/js-server-sdk-common-edge-v2.5.4...js-server-sdk-common-edge-v2.6.0) (2025-03-17) + + +### Features + +* Add TTL caching for data store ([#801](https://github.com/launchdarkly/js-core/issues/801)) ([c1de485](https://github.com/launchdarkly/js-core/commit/c1de4850c81dff8ad52276c2bfc2a2aeb87bd2d9)) + + +### Bug Fixes + +* Remove logging of SDK option configurations ([#806](https://github.com/launchdarkly/js-core/issues/806)) ([a76d196](https://github.com/launchdarkly/js-core/commit/a76d19690a7ef5932c36bfc974affc0a192c2d4f)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from 2.11.1 to 2.12.0 + +## [2.5.4](https://github.com/launchdarkly/js-core/compare/js-server-sdk-common-edge-v2.5.3...js-server-sdk-common-edge-v2.5.4) (2025-02-18) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from 2.11.0 to 2.11.1 + +## [2.5.3](https://github.com/launchdarkly/js-core/compare/js-server-sdk-common-edge-v2.5.2...js-server-sdk-common-edge-v2.5.3) (2025-01-22) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-server-sdk-common bumped from 2.10.0 to 2.11.0 + ## [2.5.2](https://github.com/launchdarkly/js-core/compare/js-server-sdk-common-edge-v2.5.1...js-server-sdk-common-edge-v2.5.2) (2024-11-14) diff --git a/packages/shared/sdk-server-edge/package.json b/packages/shared/sdk-server-edge/package.json index b38716b013..1c82f851ea 100644 --- a/packages/shared/sdk-server-edge/package.json +++ b/packages/shared/sdk-server-edge/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/js-server-sdk-common-edge", - "version": "2.5.2", + "version": "2.6.4", "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/shared/sdk-server-edge", "repository": { "type": "git", @@ -36,7 +36,7 @@ "check": "yarn prettier && yarn lint && yarn build && yarn test && yarn doc" }, "dependencies": { - "@launchdarkly/js-server-sdk-common": "2.10.0", + "@launchdarkly/js-server-sdk-common": "2.15.0", "crypto-js": "^4.1.1" }, "devDependencies": { diff --git a/packages/shared/sdk-server-edge/src/api/EdgeFeatureStore.ts b/packages/shared/sdk-server-edge/src/api/EdgeFeatureStore.ts index 55cedb31fe..5f3f68b00b 100644 --- a/packages/shared/sdk-server-edge/src/api/EdgeFeatureStore.ts +++ b/packages/shared/sdk-server-edge/src/api/EdgeFeatureStore.ts @@ -8,6 +8,8 @@ import type { } from '@launchdarkly/js-server-sdk-common'; import { deserializePoll, noop } from '@launchdarkly/js-server-sdk-common'; +import Cache from './cache'; + export interface EdgeProvider { get: (rootKey: string) => Promise; } @@ -20,6 +22,7 @@ export class EdgeFeatureStore implements LDFeatureStore { sdkKey: string, private readonly _description: string, private _logger: LDLogger, + private _cache?: Cache, ) { this._rootKey = `LD-Env-${sdkKey}`; } @@ -34,23 +37,14 @@ export class EdgeFeatureStore implements LDFeatureStore { this._logger.debug(`Requesting ${dataKey} from ${this._rootKey}.${kindKey}`); try { - const i = await this._edgeProvider.get(this._rootKey); - - if (!i) { - throw new Error(`${this._rootKey}.${kindKey} is not found in KV.`); - } - - const item = deserializePoll(i); - if (!item) { - throw new Error(`Error deserializing ${kindKey}`); - } + const storePayload = await this._getStorePayload(); switch (namespace) { case 'features': - callback(item.flags[dataKey]); + callback(storePayload.flags[dataKey]); break; case 'segments': - callback(item.segments[dataKey]); + callback(storePayload.segments[dataKey]); break; default: callback(null); @@ -66,22 +60,14 @@ export class EdgeFeatureStore implements LDFeatureStore { const kindKey = namespace === 'features' ? 'flags' : namespace; this._logger.debug(`Requesting all from ${this._rootKey}.${kindKey}`); try { - const i = await this._edgeProvider.get(this._rootKey); - if (!i) { - throw new Error(`${this._rootKey}.${kindKey} is not found in KV.`); - } - - const item = deserializePoll(i); - if (!item) { - throw new Error(`Error deserializing ${kindKey}`); - } + const storePayload = await this._getStorePayload(); switch (namespace) { case 'features': - callback(item.flags); + callback(storePayload.flags); break; case 'segments': - callback(item.segments); + callback(storePayload.segments); break; default: callback({}); @@ -92,6 +78,34 @@ export class EdgeFeatureStore implements LDFeatureStore { } } + /** + * This method is used to retrieve the environment payload from the edge + * provider. If a cache is provided, it will serve from that. + */ + private async _getStorePayload(): Promise< + Exclude, undefined> + > { + let payload = this._cache?.get(this._rootKey); + if (payload !== undefined) { + return payload; + } + + const providerData = await this._edgeProvider.get(this._rootKey); + + if (!providerData) { + throw new Error(`${this._rootKey} is not found in KV.`); + } + + payload = deserializePoll(providerData); + if (!payload) { + throw new Error(`Error deserializing ${this._rootKey}`); + } + + this._cache?.set(this._rootKey, payload); + + return payload; + } + async initialized(callback: (isInitialized: boolean) => void = noop): Promise { const config = await this._edgeProvider.get(this._rootKey); const result = config !== null; @@ -116,8 +130,11 @@ export class EdgeFeatureStore implements LDFeatureStore { return this._description; } + close(): void { + return this._cache?.close(); + } + // unused - close = noop; delete = noop; diff --git a/packages/shared/sdk-server-edge/src/api/cache.ts b/packages/shared/sdk-server-edge/src/api/cache.ts new file mode 100644 index 0000000000..e33ad61b46 --- /dev/null +++ b/packages/shared/sdk-server-edge/src/api/cache.ts @@ -0,0 +1,24 @@ +/** + * General-purpose cache interface. + * + * This is used by the SDK to cache feature flags and other data. The SDK does + * not assume any particular implementation of the cache, so you can provide + * your own. + */ +export default interface Cache { + /** + * Get a value from the cache. Returning `undefined` means the key was not found. + */ + get(key: string): any; + + /** + * Set a value in the cache. + */ + set(key: string, value: any): void; + + /** + * The close method offers a way to clean up any resources used by the cache + * on shutdown. + */ + close(): void; +} diff --git a/packages/shared/sdk-server-edge/src/api/createOptions.ts b/packages/shared/sdk-server-edge/src/api/createOptions.ts index e98fd7aa1c..3d5fb2d403 100644 --- a/packages/shared/sdk-server-edge/src/api/createOptions.ts +++ b/packages/shared/sdk-server-edge/src/api/createOptions.ts @@ -8,10 +8,6 @@ export const defaultOptions: LDOptions = { logger: BasicLogger.get(), }; -const createOptions = (options: LDOptions) => { - const finalOptions = { ...defaultOptions, ...options }; - finalOptions.logger?.debug(`Using LD options: ${JSON.stringify(finalOptions)}`); - return finalOptions; -}; +const createOptions = (options: LDOptions) => ({ ...defaultOptions, ...options }); export default createOptions; diff --git a/packages/shared/sdk-server-edge/src/api/index.ts b/packages/shared/sdk-server-edge/src/api/index.ts index c4ae612f9d..569e65c0c7 100644 --- a/packages/shared/sdk-server-edge/src/api/index.ts +++ b/packages/shared/sdk-server-edge/src/api/index.ts @@ -1,4 +1,5 @@ +import Cache from './cache'; import LDClient from './LDClient'; export * from './EdgeFeatureStore'; -export { LDClient }; +export { LDClient, Cache }; diff --git a/packages/shared/sdk-server-edge/src/index.ts b/packages/shared/sdk-server-edge/src/index.ts index 24a6c49b77..b539aaa499 100644 --- a/packages/shared/sdk-server-edge/src/index.ts +++ b/packages/shared/sdk-server-edge/src/index.ts @@ -7,12 +7,12 @@ */ import type { Info } from '@launchdarkly/js-server-sdk-common'; -import { EdgeFeatureStore, EdgeProvider, LDClient } from './api'; +import { Cache, EdgeFeatureStore, EdgeProvider, LDClient } from './api'; import validateOptions, { LDOptions, LDOptionsInternal } from './utils/validateOptions'; export * from '@launchdarkly/js-server-sdk-common'; export { EdgeFeatureStore }; -export type { LDClient, LDOptions, EdgeProvider }; +export type { LDClient, LDOptions, EdgeProvider, Cache }; /** * Do not use this function directly. diff --git a/packages/shared/sdk-server/CHANGELOG.md b/packages/shared/sdk-server/CHANGELOG.md index 00af9302f7..bf701c3f4d 100644 --- a/packages/shared/sdk-server/CHANGELOG.md +++ b/packages/shared/sdk-server/CHANGELOG.md @@ -8,6 +8,88 @@ All notable changes to `@launchdarkly/js-server-sdk-common` will be documented i * dependencies * @launchdarkly/js-sdk-common bumped from 2.3.0 to 2.3.1 +## [2.15.0](https://github.com/launchdarkly/js-core/compare/js-server-sdk-common-v2.14.0...js-server-sdk-common-v2.15.0) (2025-04-16) + + +### Features + +* Environment ID support for hooks ([#823](https://github.com/launchdarkly/js-core/issues/823)) ([63dc9f9](https://github.com/launchdarkly/js-core/commit/63dc9f9f1300c598e79be27909f8195ac66d54ef)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-sdk-common bumped from 2.15.0 to 2.16.0 + +## [2.14.0](https://github.com/launchdarkly/js-core/compare/js-server-sdk-common-v2.13.0...js-server-sdk-common-v2.14.0) (2025-04-08) + + +### Features + +* Option to use gzip to compress event ([#814](https://github.com/launchdarkly/js-core/issues/814)) ([4e91431](https://github.com/launchdarkly/js-core/commit/4e914317d31378e2a1eaed5aa03e0ac6beac43d5)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-sdk-common bumped from 2.14.0 to 2.15.0 + +## [2.13.0](https://github.com/launchdarkly/js-core/compare/js-server-sdk-common-v2.12.1...js-server-sdk-common-v2.13.0) (2025-03-26) + + +### Features + +* Support inline context for custom and migration events ([6aadf04](https://github.com/launchdarkly/js-core/commit/6aadf0463968f89bc3df10023267244c2ade1b31)) + + +### Bug Fixes + +* Deprecate LDMigrationOpEvent.contextKeys in favor of LDMigrationOpEvent.context ([6aadf04](https://github.com/launchdarkly/js-core/commit/6aadf0463968f89bc3df10023267244c2ade1b31)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-sdk-common bumped from 2.13.0 to 2.14.0 + +## [2.12.1](https://github.com/launchdarkly/js-core/compare/js-server-sdk-common-v2.12.0...js-server-sdk-common-v2.12.1) (2025-03-21) + + +### Bug Fixes + +* Fix cancelling timeout when waitForInitialization throws an exception ([#808](https://github.com/launchdarkly/js-core/issues/808)) ([bb3c950](https://github.com/launchdarkly/js-core/commit/bb3c95041fc41100b11eb698c7662b2442d46fd1)) + +## [2.12.0](https://github.com/launchdarkly/js-core/compare/js-server-sdk-common-v2.11.1...js-server-sdk-common-v2.12.0) (2025-03-17) + + +### Features + +* Export internalServer module for internal LD usage ([#804](https://github.com/launchdarkly/js-core/issues/804)) ([ec43ac8](https://github.com/launchdarkly/js-core/commit/ec43ac8af03c778d8d0ac2bd6213f9d54bf011ac)) + +## [2.11.1](https://github.com/launchdarkly/js-core/compare/js-server-sdk-common-v2.11.0...js-server-sdk-common-v2.11.1) (2025-02-18) + + +### Bug Fixes + +* Fix issue where flush callback could be called twice. ([#779](https://github.com/launchdarkly/js-core/issues/779)) ([c377e89](https://github.com/launchdarkly/js-core/commit/c377e890f9af71f1658f3303217118206496a602)) + +## [2.11.0](https://github.com/launchdarkly/js-core/compare/js-server-sdk-common-v2.10.0...js-server-sdk-common-v2.11.0) (2025-01-22) + + +### Features + +* Adds StreamingProcessor for FDv2 to sdk-server package. ([#707](https://github.com/launchdarkly/js-core/issues/707)) ([7f5c275](https://github.com/launchdarkly/js-core/commit/7f5c2750dcc8341d049d7e736ca21ec36e168703)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @launchdarkly/js-sdk-common bumped from 2.12.0 to 2.13.0 + ## [2.10.0](https://github.com/launchdarkly/js-core/compare/js-server-sdk-common-v2.9.1...js-server-sdk-common-v2.10.0) (2024-11-14) diff --git a/packages/shared/sdk-server/__tests__/LDClientImpl.events.test.ts b/packages/shared/sdk-server/__tests__/LDClientImpl.events.test.ts new file mode 100644 index 0000000000..0646df80ff --- /dev/null +++ b/packages/shared/sdk-server/__tests__/LDClientImpl.events.test.ts @@ -0,0 +1,118 @@ +import { LDClientImpl } from '../src'; +import { createBasicPlatform } from './createBasicPlatform'; +import TestLogger from './Logger'; +import makeCallbacks from './makeCallbacks'; + +it('flushes events successfully and executes the callback', async () => { + const platform = createBasicPlatform(); + platform.requests.fetch.mockImplementation(() => + Promise.resolve({ status: 200, headers: new Headers() }), + ); + + const client = new LDClientImpl( + 'sdk-key-events', + platform, + { + logger: new TestLogger(), + stream: false, + }, + makeCallbacks(false), + ); + + client.identify({ key: 'user' }); + client.variation('dev-test-flag', { key: 'user' }, false); + + const flushCallback = jest.fn(); + + await client.flush(flushCallback); + + expect(platform.requests.fetch).toHaveBeenCalledWith( + 'https://events.launchdarkly.com/bulk', + expect.objectContaining({ + method: 'POST', + body: expect.any(String), + }), + ); + expect(flushCallback).toHaveBeenCalledWith(null, true); + expect(flushCallback).toHaveBeenCalledTimes(1); +}); + +it('flushes events successfully', async () => { + const platform = createBasicPlatform(); + platform.requests.fetch.mockImplementation(() => + Promise.resolve({ status: 200, headers: new Headers() }), + ); + + const client = new LDClientImpl( + 'sdk-key-events', + platform, + { + logger: new TestLogger(), + stream: false, + }, + makeCallbacks(false), + ); + + client.identify({ key: 'user' }); + client.variation('dev-test-flag', { key: 'user' }, false); + + await client.flush(); + + expect(platform.requests.fetch).toHaveBeenCalledWith( + 'https://events.launchdarkly.com/bulk', + expect.objectContaining({ + method: 'POST', + body: expect.any(String), + }), + ); +}); + +it('calls error callback once when flush fails with http status code', async () => { + const platform = createBasicPlatform(); + platform.requests.fetch.mockImplementation(() => + Promise.resolve({ status: 401, headers: new Headers() }), + ); + + const flushCallback = jest.fn(); + const client = new LDClientImpl( + 'sdk-key-events', + platform, + { + logger: new TestLogger(), + stream: false, + }, + makeCallbacks(false), + ); + + client.identify({ key: 'user' }); + client.variation('dev-test-flag', { key: 'user' }, false); + + await client.flush(flushCallback); + + expect(flushCallback).toHaveBeenCalledWith(expect.any(Error), false); + expect(flushCallback).toHaveBeenCalledTimes(1); +}); + +it('calls error callback once when flush fails with exception', async () => { + const platform = createBasicPlatform(); + platform.requests.fetch.mockImplementation(() => Promise.reject(new Error('test error'))); + + const flushCallback = jest.fn(); + const client = new LDClientImpl( + 'sdk-key-events', + platform, + { + logger: new TestLogger(), + stream: false, + }, + makeCallbacks(false), + ); + + client.identify({ key: 'user' }); + client.variation('dev-test-flag', { key: 'user' }, false); + + await client.flush(flushCallback); + + expect(flushCallback).toHaveBeenCalledWith(expect.any(Error), false); + expect(flushCallback).toHaveBeenCalledTimes(1); +}); diff --git a/packages/shared/sdk-server/__tests__/MigrationOpEventConversion.test.ts b/packages/shared/sdk-server/__tests__/MigrationOpEventConversion.test.ts new file mode 100644 index 0000000000..4cd8915d21 --- /dev/null +++ b/packages/shared/sdk-server/__tests__/MigrationOpEventConversion.test.ts @@ -0,0 +1,86 @@ +import { LDContext, LDMigrationOpEvent, LDMigrationStage } from '../src'; +import migrationOpEventToInputEvent from '../src/MigrationOpEventConversion'; + +const baseEvent: LDMigrationOpEvent = { + kind: 'migration_op', + operation: 'read', + creationDate: new Date().getTime(), + evaluation: { + default: LDMigrationStage.Off, + key: 'flag', + reason: { kind: 'FALLTHROUGH' }, + value: LDMigrationStage.Off, + }, + measurements: [], + samplingRatio: 1, +}; + +it('handles event without either context or contextKeys', () => { + expect(migrationOpEventToInputEvent(baseEvent)).toBeUndefined(); +}); + +it('handles event with only context', () => { + const outEvent = migrationOpEventToInputEvent({ + ...baseEvent, + context: { key: 'user-key' }, + }); + expect(outEvent).toBeDefined(); + expect(outEvent?.context?.key()).toEqual('user-key'); + expect(outEvent?.context?.kind).toEqual('user'); + expect(outEvent?.contextKeys).toBeUndefined(); +}); + +it('handles event with only contextKeys', () => { + const outEvent = migrationOpEventToInputEvent({ + ...baseEvent, + contextKeys: { user: 'bob' }, + }); + expect(outEvent).toBeDefined(); + expect(outEvent?.context).toBeUndefined(); + expect(outEvent?.contextKeys).toEqual({ user: 'bob' }); +}); + +it('handles invalid context', () => { + const outEvent = migrationOpEventToInputEvent({ + ...baseEvent, + context: {} as LDContext, + }); + expect(outEvent).toBeUndefined(); +}); + +it('handles invalid context even if contextKeys is provided', () => { + const outEvent = migrationOpEventToInputEvent({ + ...baseEvent, + context: {} as LDContext, + contextKeys: { user: 'bob' }, + }); + expect(outEvent).toBeUndefined(); +}); + +it('handles invalid key in contextKeys', () => { + const outEvent = migrationOpEventToInputEvent({ + ...baseEvent, + contextKeys: { kind: 'user' }, + }); + expect(outEvent).toBeUndefined(); +}); + +it('handles invalid value in contextKeys', () => { + const outEvent = migrationOpEventToInputEvent({ + ...baseEvent, + contextKeys: { user: '' }, + }); + expect(outEvent).toBeUndefined(); +}); + +it('uses context if both context and contextKeys are provided', () => { + const outEvent = migrationOpEventToInputEvent({ + ...baseEvent, + context: { key: 'user-key' }, + contextKeys: { user: 'bob' }, + }); + expect(outEvent).toBeDefined(); + expect(outEvent?.context?.key()).toEqual('user-key'); + expect(outEvent?.context?.kind).toEqual('user'); + expect(outEvent?.contextKeys).toBeUndefined(); +}); diff --git a/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts b/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts index 4c44f80bff..36dfe31b21 100644 --- a/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts +++ b/packages/shared/sdk-server/__tests__/MigrationOpTracker.test.ts @@ -1,4 +1,4 @@ -import { LDMigrationStage } from '../src'; +import { LDContext, LDMigrationStage } from '../src'; import { LDMigrationOrigin } from '../src/api/LDMigration'; import MigrationOpTracker from '../src/MigrationOpTracker'; import TestLogger, { LogLevel } from './Logger'; @@ -6,7 +6,7 @@ import TestLogger, { LogLevel } from './Logger'; it('does not generate an event if an op is not set', () => { const tracker = new MigrationOpTracker( 'flag', - { user: 'bob' }, + { key: 'user-key' }, LDMigrationStage.Off, LDMigrationStage.Off, { @@ -20,9 +20,15 @@ it('does not generate an event if an op is not set', () => { }); it('does not generate an event with missing context keys', () => { - const tracker = new MigrationOpTracker('flag', {}, LDMigrationStage.Off, LDMigrationStage.Off, { - kind: 'FALLTHROUGH', - }); + const tracker = new MigrationOpTracker( + 'flag', + {} as LDContext, + LDMigrationStage.Off, + LDMigrationStage.Off, + { + kind: 'FALLTHROUGH', + }, + ); // Set the op otherwise/invoked that would prevent an event as well. tracker.op('write'); @@ -52,7 +58,7 @@ it('does not generate an event with empty flag key', () => { it('generates an event if the minimal requirements are met.', () => { const tracker = new MigrationOpTracker( 'flag', - { user: 'bob' }, + { key: 'user-key' }, LDMigrationStage.Off, LDMigrationStage.Off, { @@ -64,7 +70,7 @@ it('generates an event if the minimal requirements are met.', () => { tracker.invoked('old'); expect(tracker.createEvent()).toMatchObject({ - contextKeys: { user: 'bob' }, + context: { key: 'user-key' }, evaluation: { default: 'off', key: 'flag', reason: { kind: 'FALLTHROUGH' }, value: 'off' }, kind: 'migration_op', measurements: [ @@ -82,7 +88,7 @@ it('generates an event if the minimal requirements are met.', () => { it('can include the variation in the event', () => { const tracker = new MigrationOpTracker( 'flag', - { user: 'bob' }, + { key: 'user-key' }, LDMigrationStage.Off, LDMigrationStage.Off, { @@ -96,7 +102,7 @@ it('can include the variation in the event', () => { tracker.invoked('old'); expect(tracker.createEvent()).toMatchObject({ - contextKeys: { user: 'bob' }, + context: { key: 'user-key' }, evaluation: { default: 'off', key: 'flag', @@ -120,7 +126,7 @@ it('can include the variation in the event', () => { it('can include the version in the event', () => { const tracker = new MigrationOpTracker( 'flag', - { user: 'bob' }, + { key: 'user-key' }, LDMigrationStage.Off, LDMigrationStage.Off, { @@ -135,7 +141,7 @@ it('can include the version in the event', () => { tracker.invoked('old'); expect(tracker.createEvent()).toMatchObject({ - contextKeys: { user: 'bob' }, + context: { key: 'user-key' }, evaluation: { default: 'off', key: 'flag', @@ -159,7 +165,7 @@ it('can include the version in the event', () => { it('includes errors if at least one is set', () => { const tracker = new MigrationOpTracker( 'flag', - { user: 'bob' }, + { key: 'user-key' }, LDMigrationStage.Off, LDMigrationStage.Off, { @@ -181,7 +187,7 @@ it('includes errors if at least one is set', () => { const trackerB = new MigrationOpTracker( 'flag', - { user: 'bob' }, + { key: 'user-key' }, LDMigrationStage.Off, LDMigrationStage.Off, { @@ -205,7 +211,7 @@ it('includes errors if at least one is set', () => { it('includes latency if at least one measurement exists', () => { const tracker = new MigrationOpTracker( 'flag', - { user: 'bob' }, + { key: 'user-key' }, LDMigrationStage.Off, LDMigrationStage.Off, { @@ -227,7 +233,7 @@ it('includes latency if at least one measurement exists', () => { const trackerB = new MigrationOpTracker( 'flag', - { user: 'bob' }, + { key: 'user-key' }, LDMigrationStage.Off, LDMigrationStage.Off, { @@ -251,7 +257,7 @@ it('includes latency if at least one measurement exists', () => { it('includes if the result was consistent', () => { const tracker = new MigrationOpTracker( 'flag', - { user: 'bob' }, + { key: 'user-key' }, LDMigrationStage.Off, LDMigrationStage.Off, { @@ -274,7 +280,7 @@ it('includes if the result was consistent', () => { it('includes if the result was inconsistent', () => { const tracker = new MigrationOpTracker( 'flag', - { user: 'bob' }, + { key: 'user-key' }, LDMigrationStage.Off, LDMigrationStage.Off, { @@ -297,7 +303,7 @@ it('includes if the result was inconsistent', () => { it.each(['old', 'new'])('includes which single origins were invoked', (origin) => { const tracker = new MigrationOpTracker( 'flag', - { user: 'bob' }, + { key: 'user-key' }, LDMigrationStage.Off, LDMigrationStage.Off, { @@ -317,7 +323,7 @@ it.each(['old', 'new'])('includes which single origins were invoked', (origin) = it('includes when both origins were invoked', () => { const tracker = new MigrationOpTracker( 'flag', - { user: 'bob' }, + { key: 'user-key' }, LDMigrationStage.Off, LDMigrationStage.Off, { @@ -339,7 +345,7 @@ it('can handle exceptions thrown in the consistency check method', () => { const logger = new TestLogger(); const tracker = new MigrationOpTracker( 'flag', - { user: 'bob' }, + { key: 'user-key' }, LDMigrationStage.Off, LDMigrationStage.Off, { @@ -376,7 +382,7 @@ it.each([ (invoke_old, invoke_new, measure_old, measure_new) => { const tracker = new MigrationOpTracker( 'flag', - { user: 'bob' }, + { key: 'user-key' }, LDMigrationStage.Off, LDMigrationStage.Off, { @@ -413,7 +419,7 @@ it.each([ (invoke_old, invoke_new, measure_old, measure_new) => { const tracker = new MigrationOpTracker( 'flag', - { user: 'bob' }, + { key: 'user-key' }, LDMigrationStage.Off, LDMigrationStage.Off, { @@ -450,7 +456,7 @@ it.each([ (invoke_old, invoke_new, consistent) => { const tracker = new MigrationOpTracker( 'flag', - { user: 'bob' }, + { key: 'user-key' }, LDMigrationStage.Off, LDMigrationStage.Off, { diff --git a/packages/shared/sdk-server/__tests__/data_sources/DataSourceUpdates.test.ts b/packages/shared/sdk-server/__tests__/data_sources/DataSourceUpdates.test.ts index ff474c5f77..2be828a812 100644 --- a/packages/shared/sdk-server/__tests__/data_sources/DataSourceUpdates.test.ts +++ b/packages/shared/sdk-server/__tests__/data_sources/DataSourceUpdates.test.ts @@ -1,11 +1,36 @@ import { AsyncQueue } from 'launchdarkly-js-test-helpers'; +import { internal } from '@launchdarkly/js-sdk-common'; + import { LDFeatureStore } from '../../src/api/subsystems'; import promisify from '../../src/async/promisify'; import DataSourceUpdates from '../../src/data_sources/DataSourceUpdates'; import InMemoryFeatureStore from '../../src/store/InMemoryFeatureStore'; import VersionedDataKinds from '../../src/store/VersionedDataKinds'; +type InitMetadata = internal.InitMetadata; + +it('passes initialization metadata to underlying feature store', () => { + const metadata: InitMetadata = { environmentId: '12345' }; + const store = new InMemoryFeatureStore(); + store.applyChanges = jest.fn(); + const updates = new DataSourceUpdates( + store, + () => false, + () => {}, + ); + updates.init({}, () => {}, metadata); + expect(store.applyChanges).toHaveBeenCalledTimes(1); + expect(store.applyChanges).toHaveBeenNthCalledWith( + 1, + true, + expect.any(Object), + expect.any(Function), + metadata, + undefined, + ); +}); + describe.each([true, false])( 'given a DataSourceUpdates with in memory store and change listeners: %s', (listen) => { diff --git a/packages/shared/sdk-server/__tests__/data_sources/PollingProcessor.test.ts b/packages/shared/sdk-server/__tests__/data_sources/PollingProcessor.test.ts index 9a38a55d4c..9d25a142b5 100644 --- a/packages/shared/sdk-server/__tests__/data_sources/PollingProcessor.test.ts +++ b/packages/shared/sdk-server/__tests__/data_sources/PollingProcessor.test.ts @@ -67,6 +67,18 @@ describe('given an event processor', () => { expect(flags).toEqual(allData.flags); expect(segments).toEqual(allData.segments); }); + + it('initializes the feature store with metadata', () => { + const initHeaders = { + 'x-ld-envid': '12345', + }; + requestor.requestAllData = jest.fn((cb) => cb(undefined, jsonData, initHeaders)); + + processor.start(); + const metadata = storeFacade.getInitMetadata?.(); + + expect(metadata).toEqual({ environmentId: '12345' }); + }); }); describe('given a polling processor with a short poll duration', () => { diff --git a/packages/shared/sdk-server/__tests__/data_sources/Requestor.test.ts b/packages/shared/sdk-server/__tests__/data_sources/Requestor.test.ts index 3f3d8537a2..54b68f6312 100644 --- a/packages/shared/sdk-server/__tests__/data_sources/Requestor.test.ts +++ b/packages/shared/sdk-server/__tests__/data_sources/Requestor.test.ts @@ -49,7 +49,7 @@ describe('given a requestor', () => { throw new Error('Function not implemented.'); }, entries(): Iterable<[string, string]> { - throw new Error('Function not implemented.'); + return testHeaders ? Object.entries(testHeaders) : []; }, has(_name: string): boolean { throw new Error('Function not implemented.'); @@ -115,7 +115,9 @@ describe('given a requestor', () => { }); it('stores and sends etags', async () => { - testHeaders.etag = 'abc123'; + testHeaders = { + etag: 'abc123', + }; testResponse = 'a response'; const res1 = await promisify<{ err: any; body: any }>((cb) => { requestor.requestAllData((err, body) => cb({ err, body })); @@ -134,4 +136,17 @@ describe('given a requestor', () => { expect(req1.options.headers?.['if-none-match']).toBe(undefined); expect(req2.options.headers?.['if-none-match']).toBe((testHeaders.etag = 'abc123')); }); + + it('passes response headers to callback', async () => { + testHeaders = { + header1: 'value1', + header2: 'value2', + header3: 'value3', + }; + const res = await promisify<{ err: any; body: any; headers: any }>((cb) => { + requestor.requestAllData((err, body, headers) => cb({ err, body, headers })); + }); + + expect(res.headers).toEqual(testHeaders); + }); }); diff --git a/packages/shared/sdk-server/__tests__/data_sources/StreamingProcessor.test.ts b/packages/shared/sdk-server/__tests__/data_sources/StreamingProcessor.test.ts index f2b7b21aad..a91a90ffd2 100644 --- a/packages/shared/sdk-server/__tests__/data_sources/StreamingProcessor.test.ts +++ b/packages/shared/sdk-server/__tests__/data_sources/StreamingProcessor.test.ts @@ -138,7 +138,7 @@ describe('given a stream processor with mock event source', () => { }); it('uses expected uri and eventSource init args', () => { - expect(basicPlatform.requests.createEventSource).toBeCalledWith( + expect(basicPlatform.requests.createEventSource).toHaveBeenCalledWith( `${serviceEndpoints.streaming}/all`, { errorFilter: expect.any(Function), @@ -200,32 +200,44 @@ describe('given a stream processor with mock event source', () => { const patchHandler = mockEventSource.addEventListener.mock.calls[1][1]; patchHandler(event); - expect(mockListener.deserializeData).toBeCalledTimes(2); - expect(mockListener.processJson).toBeCalledTimes(2); + expect(mockListener.deserializeData).toHaveBeenCalledTimes(2); + expect(mockListener.processJson).toHaveBeenCalledTimes(2); + }); + + it('passes initialization headers to listener', () => { + const headers = { + header1: 'value1', + header2: 'value2', + header3: 'value3', + }; + mockEventSource.onopen({ type: 'open', headers }); + simulatePutEvent(); + expect(mockListener.processJson).toHaveBeenCalledTimes(1); + expect(mockListener.processJson).toHaveBeenNthCalledWith(1, expect.any(Object), headers); }); it('passes error to callback if json data is malformed', async () => { (mockListener.deserializeData as jest.Mock).mockReturnValue(false); simulatePutEvent(); - expect(logger.error).toBeCalledWith(expect.stringMatching(/invalid data in "put"/)); - expect(logger.debug).toBeCalledWith(expect.stringMatching(/invalid json/i)); + expect(logger.error).toHaveBeenCalledWith(expect.stringMatching(/invalid data in "put"/)); + expect(logger.debug).toHaveBeenCalledWith(expect.stringMatching(/invalid json/i)); expect(mockErrorHandler.mock.lastCall[0].message).toMatch(/malformed json/i); }); it('calls error handler if event.data prop is missing', async () => { simulatePutEvent({ flags: {} }); - expect(mockListener.deserializeData).not.toBeCalled(); - expect(mockListener.processJson).not.toBeCalled(); + expect(mockListener.deserializeData).not.toHaveBeenCalled(); + expect(mockListener.processJson).not.toHaveBeenCalled(); expect(mockErrorHandler.mock.lastCall[0].message).toMatch(/unexpected payload/i); }); it('closes and stops', async () => { streamingProcessor.close(); - expect(streamingProcessor.stop).toBeCalled(); - expect(mockEventSource.close).toBeCalled(); + expect(streamingProcessor.stop).toHaveBeenCalled(); + expect(mockEventSource.close).toHaveBeenCalled(); // @ts-ignore expect(streamingProcessor.eventSource).toBeUndefined(); }); @@ -249,8 +261,8 @@ describe('given a stream processor with mock event source', () => { const willRetry = simulateError(testError); expect(willRetry).toBeTruthy(); - expect(mockErrorHandler).not.toBeCalled(); - expect(logger.warn).toBeCalledWith( + expect(mockErrorHandler).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( expect.stringMatching(new RegExp(`${status}.*will retry`)), ); @@ -270,10 +282,10 @@ describe('given a stream processor with mock event source', () => { const willRetry = simulateError(testError); expect(willRetry).toBeFalsy(); - expect(mockErrorHandler).toBeCalledWith( + expect(mockErrorHandler).toHaveBeenCalledWith( new LDStreamingError(DataSourceErrorKind.Unknown, testError.message, testError.status), ); - expect(logger.error).toBeCalledWith( + expect(logger.error).toHaveBeenCalledWith( expect.stringMatching(new RegExp(`${status}.*permanently`)), ); diff --git a/packages/shared/sdk-server/__tests__/data_sources/createStreamListeners.test.ts b/packages/shared/sdk-server/__tests__/data_sources/createStreamListeners.test.ts index b3aeee0ff6..8b8286c51f 100644 --- a/packages/shared/sdk-server/__tests__/data_sources/createStreamListeners.test.ts +++ b/packages/shared/sdk-server/__tests__/data_sources/createStreamListeners.test.ts @@ -95,13 +95,36 @@ describe('createStreamListeners', () => { processJson(allData); - expect(logger.debug).toBeCalledWith(expect.stringMatching(/initializing/i)); - expect(dataSourceUpdates.init).toBeCalledWith( + expect(logger.debug).toHaveBeenCalledWith(expect.stringMatching(/initializing/i)); + expect(dataSourceUpdates.init).toHaveBeenCalledWith( { features: flags, segments, }, onPutCompleteHandler, + undefined, + ); + }); + + test('data source init is called with initialization metadata', async () => { + const listeners = createStreamListeners(dataSourceUpdates, logger, onCompleteHandlers); + const { processJson } = listeners.get('put')!; + const { + data: { flags, segments }, + } = allData; + const initHeaders = { + 'x-ld-envid': '12345', + }; + processJson(allData, initHeaders); + + expect(logger.debug).toHaveBeenCalledWith(expect.stringMatching(/initializing/i)); + expect(dataSourceUpdates.init).toHaveBeenCalledWith( + { + features: flags, + segments, + }, + onPutCompleteHandler, + { environmentId: '12345' }, ); }); }); @@ -122,8 +145,8 @@ describe('createStreamListeners', () => { processJson(patchData); - expect(logger.debug).toBeCalledWith(expect.stringMatching(/updating/i)); - expect(dataSourceUpdates.upsert).toBeCalledWith(kind, data, onPatchCompleteHandler); + expect(logger.debug).toHaveBeenCalledWith(expect.stringMatching(/updating/i)); + expect(dataSourceUpdates.upsert).toHaveBeenCalledWith(kind, data, onPatchCompleteHandler); }); test('data source upsert not called missing kind', async () => { @@ -133,7 +156,7 @@ describe('createStreamListeners', () => { processJson(missingKind); - expect(dataSourceUpdates.upsert).not.toBeCalled(); + expect(dataSourceUpdates.upsert).not.toHaveBeenCalled(); }); test('data source upsert not called wrong namespace path', async () => { @@ -143,7 +166,7 @@ describe('createStreamListeners', () => { processJson(wrongKey); - expect(dataSourceUpdates.upsert).not.toBeCalled(); + expect(dataSourceUpdates.upsert).not.toHaveBeenCalled(); }); }); @@ -163,8 +186,8 @@ describe('createStreamListeners', () => { processJson(deleteData); - expect(logger.debug).toBeCalledWith(expect.stringMatching(/deleting/i)); - expect(dataSourceUpdates.upsert).toBeCalledWith( + expect(logger.debug).toHaveBeenCalledWith(expect.stringMatching(/deleting/i)); + expect(dataSourceUpdates.upsert).toHaveBeenCalledWith( kind, { key: 'flagkey', version, deleted: true }, onDeleteCompleteHandler, @@ -178,7 +201,7 @@ describe('createStreamListeners', () => { processJson(missingKind); - expect(dataSourceUpdates.upsert).not.toBeCalled(); + expect(dataSourceUpdates.upsert).not.toHaveBeenCalled(); }); test('data source upsert not called wrong namespace path', async () => { @@ -188,7 +211,7 @@ describe('createStreamListeners', () => { processJson(wrongKey); - expect(dataSourceUpdates.upsert).not.toBeCalled(); + expect(dataSourceUpdates.upsert).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/shared/sdk-server/__tests__/hooks/HookRunner.test.ts b/packages/shared/sdk-server/__tests__/hooks/HookRunner.test.ts index b72a184f87..cd97d3fe38 100644 --- a/packages/shared/sdk-server/__tests__/hooks/HookRunner.test.ts +++ b/packages/shared/sdk-server/__tests__/hooks/HookRunner.test.ts @@ -36,6 +36,7 @@ describe('given a HookRunner', () => { reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' }, variationIndex: null, }), + '12345', ); testHook.verifyAfter( @@ -44,6 +45,7 @@ describe('given a HookRunner', () => { context: { ...defaultUser }, defaultValue: false, method: 'LDClient.variation', + environmentId: '12345', }, { added: 'added data' }, { @@ -187,6 +189,7 @@ it('can add a hook after initialization', async () => { reason: { kind: 'FALLTHROUGH' }, variationIndex: 0, }), + '12345', ); testHook.verifyBefore( { @@ -194,6 +197,7 @@ it('can add a hook after initialization', async () => { context: { ...defaultUser }, defaultValue: false, method: 'LDClient.variation', + environmentId: '12345', }, {}, ); @@ -203,6 +207,7 @@ it('can add a hook after initialization', async () => { context: { ...defaultUser }, defaultValue: false, method: 'LDClient.variation', + environmentId: '12345', }, {}, { diff --git a/packages/shared/sdk-server/__tests__/store/InMemoryFeatureStore.test.ts b/packages/shared/sdk-server/__tests__/store/InMemoryFeatureStore.test.ts index 0f77700a2f..114db7e849 100644 --- a/packages/shared/sdk-server/__tests__/store/InMemoryFeatureStore.test.ts +++ b/packages/shared/sdk-server/__tests__/store/InMemoryFeatureStore.test.ts @@ -147,4 +147,21 @@ describe('given an initialized feature store', () => { const feature = await featureStore.get({ namespace: 'potato' }, newPotato.key); expect(feature).toEqual(newPotato); }); + + it('returns undefined initMetadata', () => { + expect(featureStore.getInitMetadata?.()).toBeUndefined(); + }); +}); + +describe('given an initialized feature store with metadata', () => { + let featureStore: AsyncStoreFacade; + + beforeEach(async () => { + featureStore = new AsyncStoreFacade(new InMemoryFeatureStore()); + await featureStore.init({}, { environmentId: '12345' }); + }); + + it('returns correct metadata', () => { + expect(featureStore.getInitMetadata?.()).toEqual({ environmentId: '12345' }); + }); }); diff --git a/packages/shared/sdk-server/__tests__/store/PersistentStoreWrapper.test.ts b/packages/shared/sdk-server/__tests__/store/PersistentStoreWrapper.test.ts index eb9f99ecaa..33e2c13cd1 100644 --- a/packages/shared/sdk-server/__tests__/store/PersistentStoreWrapper.test.ts +++ b/packages/shared/sdk-server/__tests__/store/PersistentStoreWrapper.test.ts @@ -510,6 +510,7 @@ describe.each(['caching', 'non-caching'])( }, }, }, + undefined, 'selector1', ); @@ -531,6 +532,7 @@ describe.each(['caching', 'non-caching'])( }, }, }, + undefined, 'selector1', ); @@ -549,6 +551,7 @@ describe.each(['caching', 'non-caching'])( }, }, }, + undefined, 'selector1', ); @@ -587,6 +590,7 @@ describe.each(['caching', 'non-caching'])( }, }, }, + undefined, 'selector', ); expect(callbackCount).toEqual(3); @@ -605,6 +609,7 @@ describe.each(['caching', 'non-caching'])( }, }, }, + undefined, 'selector', ); @@ -620,6 +625,7 @@ describe.each(['caching', 'non-caching'])( }, }, }, + undefined, 'selector', ); diff --git a/packages/shared/sdk-server/__tests__/store/TransactionalPersistentStore.test.ts b/packages/shared/sdk-server/__tests__/store/TransactionalPersistentStore.test.ts index de4a468648..1a132b088c 100644 --- a/packages/shared/sdk-server/__tests__/store/TransactionalPersistentStore.test.ts +++ b/packages/shared/sdk-server/__tests__/store/TransactionalPersistentStore.test.ts @@ -35,6 +35,7 @@ describe('given a non transactional store', () => { }, }, }, + undefined, 'selector1', ); expect(await nonTransactionalFacade.all(VersionedDataKinds.Features)).toEqual({ @@ -85,6 +86,7 @@ describe('given a non transactional store', () => { }, }, }, + undefined, 'selector1', ); diff --git a/packages/shared/sdk-server/package.json b/packages/shared/sdk-server/package.json index 43e5ef1093..cbc3de57b0 100644 --- a/packages/shared/sdk-server/package.json +++ b/packages/shared/sdk-server/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/js-server-sdk-common", - "version": "2.10.0", + "version": "2.15.0", "type": "commonjs", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -27,7 +27,7 @@ }, "license": "Apache-2.0", "dependencies": { - "@launchdarkly/js-sdk-common": "2.12.0", + "@launchdarkly/js-sdk-common": "2.16.0", "semver": "7.5.4" }, "devDependencies": { diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index 25eb64c0f2..1f872b5e7a 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -397,6 +397,7 @@ export default class LDClientImpl implements LDClient { }, ); }), + this._featureStore.getInitMetaData?.()?.environmentId, ) .then((detail) => { callback?.(null, detail.value); @@ -428,6 +429,7 @@ export default class LDClientImpl implements LDClient { }, ); }), + this._featureStore.getInitMetaData?.()?.environmentId, ); } @@ -462,6 +464,7 @@ export default class LDClientImpl implements LDClient { typeChecker, ); }), + this._featureStore.getInitMetaData?.()?.environmentId, ); } @@ -523,6 +526,7 @@ export default class LDClientImpl implements LDClient { }, ); }), + this._featureStore.getInitMetaData?.()?.environmentId, ) .then((detail) => detail.value); } @@ -594,6 +598,7 @@ export default class LDClientImpl implements LDClient { }, ); }), + this._featureStore.getInitMetaData?.()?.environmentId, ); } @@ -602,7 +607,6 @@ export default class LDClientImpl implements LDClient { context: LDContext, defaultValue: LDMigrationStage, ): Promise<{ detail: LDEvaluationDetail; migration: LDMigrationVariation }> { - const convertedContext = Context.fromLDContext(context); const res = await new Promise<{ detail: LDEvaluationDetail; flag?: Flag }>((resolve) => { this._evaluateIfPossible( key, @@ -634,7 +638,6 @@ export default class LDClientImpl implements LDClient { }); const { detail, flag } = res; - const contextKeys = convertedContext.valid ? convertedContext.kindsAndKeys : {}; const checkRatio = flag?.migration?.checkRatio; const samplingRatio = flag?.samplingRatio; @@ -644,7 +647,7 @@ export default class LDClientImpl implements LDClient { value: detail.value as LDMigrationStage, tracker: new MigrationOpTracker( key, - contextKeys, + context, defaultValue, detail.value, detail.reason, @@ -670,6 +673,7 @@ export default class LDClientImpl implements LDClient { defaultValue, MIGRATION_VARIATION_METHOD_NAME, () => this._migrationVariationInternal(key, context, defaultValue), + this._featureStore.getInitMetaData?.()?.environmentId, ); return res.migration; @@ -827,9 +831,9 @@ export default class LDClientImpl implements LDClient { try { await this._eventProcessor.flush(); } catch (err) { - callback?.(err as Error, false); + return callback?.(err as Error, false); } - callback?.(null, true); + return callback?.(null, true); } addHook(hook: Hook): void { @@ -994,14 +998,16 @@ export default class LDClientImpl implements LDClient { if (timeout) { const cancelableTimeout = cancelableTimedPromise(timeout, 'waitForInitialization'); return Promise.race([ - basePromise.then(() => cancelableTimeout.cancel()).then(() => this), + basePromise.then(() => this), cancelableTimeout.promise.then(() => this), - ]).catch((reason) => { - if (reason instanceof LDTimeoutError) { - logger?.error(reason.message); - } - throw reason; - }); + ]) + .catch((reason) => { + if (reason instanceof LDTimeoutError) { + logger?.error(reason.message); + } + throw reason; + }) + .finally(() => cancelableTimeout.cancel()); } return basePromise; } diff --git a/packages/shared/sdk-server/src/MigrationOpEventConversion.ts b/packages/shared/sdk-server/src/MigrationOpEventConversion.ts index 24be092aaf..2af0d143bc 100644 --- a/packages/shared/sdk-server/src/MigrationOpEventConversion.ts +++ b/packages/shared/sdk-server/src/MigrationOpEventConversion.ts @@ -1,4 +1,4 @@ -import { internal, TypeValidators } from '@launchdarkly/js-sdk-common'; +import { Context, internal, TypeValidators } from '@launchdarkly/js-sdk-common'; import { LDMigrationConsistencyMeasurement, @@ -223,15 +223,29 @@ export default function MigrationOpEventToInputEvent( return undefined; } - if (!TypeValidators.Object.is(inEvent.contextKeys)) { + if (!TypeValidators.Number.is(inEvent.creationDate)) { return undefined; } - if (!TypeValidators.Number.is(inEvent.creationDate)) { - return undefined; + const contextKeysOrContext: Pick = {}; + + if (TypeValidators.Object.is(inEvent.context)) { + const context = Context.fromLDContext(inEvent.context); + if (context.valid) { + contextKeysOrContext.context = context; + } + } else if (TypeValidators.Object.is(inEvent.contextKeys)) { + if ( + Object.keys(inEvent.contextKeys).every((key) => TypeValidators.Kind.is(key)) && + Object.values(inEvent.contextKeys).every( + (value) => TypeValidators.String.is(value) && value !== '', + ) + ) { + contextKeysOrContext.contextKeys = { ...inEvent.contextKeys }; + } } - if (!Object.keys(inEvent.contextKeys).every((key) => TypeValidators.Kind.is(key))) { + if (!contextKeysOrContext.context && !contextKeysOrContext.contextKeys) { return undefined; } @@ -241,14 +255,6 @@ export default function MigrationOpEventToInputEvent( return undefined; } - if ( - !Object.values(inEvent.contextKeys).every( - (value) => TypeValidators.String.is(value) && value !== '', - ) - ) { - return undefined; - } - const evaluation = validateEvaluation(inEvent.evaluation); if (!evaluation) { @@ -259,7 +265,7 @@ export default function MigrationOpEventToInputEvent( kind: inEvent.kind, operation: inEvent.operation, creationDate: inEvent.creationDate, - contextKeys: { ...inEvent.contextKeys }, + ...contextKeysOrContext, measurements: validateMeasurements(inEvent.measurements), evaluation, samplingRatio, diff --git a/packages/shared/sdk-server/src/MigrationOpTracker.ts b/packages/shared/sdk-server/src/MigrationOpTracker.ts index a1fa23c841..c2fe22649c 100644 --- a/packages/shared/sdk-server/src/MigrationOpTracker.ts +++ b/packages/shared/sdk-server/src/MigrationOpTracker.ts @@ -1,5 +1,7 @@ import { + Context, internal, + LDContext, LDEvaluationReason, LDLogger, TypeValidators, @@ -42,7 +44,7 @@ export default class MigrationOpTracker implements LDMigrationTracker { constructor( private readonly _flagKey: string, - private readonly _contextKeys: Record, + private readonly _context: LDContext, private readonly _defaultStage: LDMigrationStage, private readonly _stage: LDMigrationStage, private readonly _reason: LDEvaluationReason, @@ -97,7 +99,7 @@ export default class MigrationOpTracker implements LDMigrationTracker { return undefined; } - if (Object.keys(this._contextKeys).length === 0) { + if (!Context.fromLDContext(this._context).valid) { this._logger?.error( 'The migration was not done against a valid context and cannot generate an event.', ); @@ -127,7 +129,7 @@ export default class MigrationOpTracker implements LDMigrationTracker { kind: 'migration_op', operation: this._operation, creationDate: Date.now(), - contextKeys: this._contextKeys, + context: this._context, evaluation: { key: this._flagKey, value: this._stage, diff --git a/packages/shared/sdk-server/src/api/data/LDMigrationOpEvent.ts b/packages/shared/sdk-server/src/api/data/LDMigrationOpEvent.ts index 657b53d1f5..0ce0694dcd 100644 --- a/packages/shared/sdk-server/src/api/data/LDMigrationOpEvent.ts +++ b/packages/shared/sdk-server/src/api/data/LDMigrationOpEvent.ts @@ -1,4 +1,4 @@ -import { LDEvaluationReason } from '@launchdarkly/js-sdk-common'; +import { LDContext, LDEvaluationReason } from '@launchdarkly/js-sdk-common'; import { LDMigrationStage } from './LDMigrationStage'; @@ -66,7 +66,11 @@ export interface LDMigrationOpEvent { kind: 'migration_op'; operation: LDMigrationOp; creationDate: number; - contextKeys: Record; + /** + * @deprecated Use 'context' instead. + */ + contextKeys?: Record; + context?: LDContext; evaluation: LDMigrationEvaluation; measurements: LDMigrationMeasurement[]; samplingRatio: number; diff --git a/packages/shared/sdk-server/src/api/integrations/Hook.ts b/packages/shared/sdk-server/src/api/integrations/Hook.ts index 52e7639866..71d023a5ad 100644 --- a/packages/shared/sdk-server/src/api/integrations/Hook.ts +++ b/packages/shared/sdk-server/src/api/integrations/Hook.ts @@ -8,6 +8,7 @@ export interface EvaluationSeriesContext { readonly context: LDContext; readonly defaultValue: unknown; readonly method: string; + readonly environmentId?: string; } /** diff --git a/packages/shared/sdk-server/src/api/options/LDOptions.ts b/packages/shared/sdk-server/src/api/options/LDOptions.ts index b6d48f7be9..8177940244 100644 --- a/packages/shared/sdk-server/src/api/options/LDOptions.ts +++ b/packages/shared/sdk-server/src/api/options/LDOptions.ts @@ -346,4 +346,14 @@ export interface LDOptions { * ``` */ hooks?: Hook[]; + + /** + * Set to true to opt in to compressing event payloads if the SDK supports it, since the + * compression library may not be supported in the underlying SDK framework. If the compression + * library is not supported then event payloads will not be compressed even if this option + * is enabled. + * + * Defaults to false. + */ + enableEventCompression?: boolean; } diff --git a/packages/shared/sdk-server/src/api/subsystems/LDDataSourceUpdates.ts b/packages/shared/sdk-server/src/api/subsystems/LDDataSourceUpdates.ts index 1d13b1c103..d7ac7f2793 100644 --- a/packages/shared/sdk-server/src/api/subsystems/LDDataSourceUpdates.ts +++ b/packages/shared/sdk-server/src/api/subsystems/LDDataSourceUpdates.ts @@ -1,6 +1,10 @@ +import { internal } from '@launchdarkly/js-sdk-common'; + import { DataKind } from '../interfaces'; import { LDFeatureStoreDataStorage, LDKeyedFeatureStoreItem } from './LDFeatureStore'; +type InitMetadata = internal.InitMetadata; + /** * Interface that a data source implementation will use to push data into the SDK. * @@ -19,8 +23,11 @@ export interface LDDataSourceUpdates { * * @param callback * Will be called when the store has been initialized. + * + * @param initMetadata + * Optional metadata to initialize the data source with. */ - init(allData: LDFeatureStoreDataStorage, callback: () => void): void; + init(allData: LDFeatureStoreDataStorage, callback: () => void, initMetadata?: InitMetadata): void; /** * Updates or inserts an item in the specified collection. For updates, the object will only be @@ -54,7 +61,8 @@ export interface LDDataSourceUpdates { applyChanges( basis: boolean, data: LDFeatureStoreDataStorage, - selector: String, callback: () => void, + initMetadata?: InitMetadata, + selector?: String, ): void; } diff --git a/packages/shared/sdk-server/src/api/subsystems/LDFeatureRequestor.ts b/packages/shared/sdk-server/src/api/subsystems/LDFeatureRequestor.ts index c287d6617a..43b3c261c2 100644 --- a/packages/shared/sdk-server/src/api/subsystems/LDFeatureRequestor.ts +++ b/packages/shared/sdk-server/src/api/subsystems/LDFeatureRequestor.ts @@ -6,5 +6,5 @@ * @ignore */ export interface LDFeatureRequestor { - requestAllData: (cb: (err: any, body: any) => void) => void; + requestAllData: (cb: (err: any, body: any, headers: any) => void) => void; } diff --git a/packages/shared/sdk-server/src/api/subsystems/LDFeatureStore.ts b/packages/shared/sdk-server/src/api/subsystems/LDFeatureStore.ts index 5c047a8651..ac8e793833 100644 --- a/packages/shared/sdk-server/src/api/subsystems/LDFeatureStore.ts +++ b/packages/shared/sdk-server/src/api/subsystems/LDFeatureStore.ts @@ -1,5 +1,9 @@ +import { internal } from '@launchdarkly/js-sdk-common'; + import { DataKind } from '../interfaces'; +type InitMetadata = internal.InitMetadata; + /** * Represents an item which can be stored in the feature store. */ @@ -92,8 +96,11 @@ export interface LDFeatureStore { * * @param callback * Will be called when the store has been initialized. + * + * @param initMetadata + * Optional metadata to initialize the feature store with. */ - init(allData: LDFeatureStoreDataStorage, callback: () => void): void; + init(allData: LDFeatureStoreDataStorage, callback: () => void, initMetadata?: InitMetadata): void; /** * Delete an entity from the store. @@ -154,8 +161,9 @@ export interface LDFeatureStore { applyChanges( basis: boolean, data: LDFeatureStoreDataStorage, - selector: String | undefined, callback: () => void, + initMetadata?: InitMetadata, + selector?: String, ): void; /** @@ -179,4 +187,9 @@ export interface LDFeatureStore { * Get a description of the store. */ getDescription?(): string; + + /** + * Get the initialization metadata of the store. + */ + getInitMetaData?(): InitMetadata | undefined; } diff --git a/packages/shared/sdk-server/src/cache/TtlCache.ts b/packages/shared/sdk-server/src/cache/TtlCache.ts index 8ec208efa1..91cfc31075 100644 --- a/packages/shared/sdk-server/src/cache/TtlCache.ts +++ b/packages/shared/sdk-server/src/cache/TtlCache.ts @@ -4,8 +4,6 @@ function isStale(record: CacheRecord): boolean { /** * Options for the TTL cache. - * - * @internal */ export interface TtlCacheOptions { /** @@ -26,8 +24,6 @@ interface CacheRecord { /** * A basic TTL cache with configurable TTL and check interval. - * - * @internal */ export default class TtlCache { private _storage: Map = new Map(); diff --git a/packages/shared/sdk-server/src/data_sources/DataSourceUpdates.ts b/packages/shared/sdk-server/src/data_sources/DataSourceUpdates.ts index 9de85c737b..8a58155690 100644 --- a/packages/shared/sdk-server/src/data_sources/DataSourceUpdates.ts +++ b/packages/shared/sdk-server/src/data_sources/DataSourceUpdates.ts @@ -1,3 +1,5 @@ +import { internal } from '@launchdarkly/js-sdk-common'; + import { DataKind } from '../api/interfaces'; import { LDDataSourceUpdates, @@ -13,6 +15,8 @@ import VersionedDataKinds from '../store/VersionedDataKinds'; import DependencyTracker from './DependencyTracker'; import NamespacedDataSet from './NamespacedDataSet'; +type InitMetadata = internal.InitMetadata; + /** * This type allows computing the clause dependencies of either a flag or a segment. */ @@ -66,8 +70,12 @@ export default class DataSourceUpdates implements LDDataSourceUpdates { private readonly _onChange: (key: string) => void, ) {} - init(allData: LDFeatureStoreDataStorage, callback: () => void): void { - this.applyChanges(true, allData, undefined, callback); // basis is true for init. selector is undefined for FDv1 init + init( + allData: LDFeatureStoreDataStorage, + callback: () => void, + initMetadata?: InitMetadata, + ): void { + this.applyChanges(true, allData, callback, initMetadata); // basis is true for init. selector is undefined for FDv1 init } upsert(kind: DataKind, data: LDKeyedFeatureStoreItem, callback: () => void): void { @@ -78,7 +86,6 @@ export default class DataSourceUpdates implements LDDataSourceUpdates { [data.key]: data, }, }, - undefined, // selector is undefined for FDv1 upsert callback, ); } @@ -86,58 +93,65 @@ export default class DataSourceUpdates implements LDDataSourceUpdates { applyChanges( basis: boolean, data: LDFeatureStoreDataStorage, - selector: String | undefined, callback: () => void, + initMetadata?: InitMetadata, + selector?: String, ): void { const checkForChanges = this._hasEventListeners(); const doApplyChanges = (oldData: LDFeatureStoreDataStorage) => { - this._featureStore.applyChanges(basis, data, selector, () => { - // Defer change events so they execute after the callback. - Promise.resolve().then(() => { - if (basis) { - this._dependencyTracker.reset(); - } - - Object.entries(data).forEach(([namespace, items]) => { - Object.keys(items || {}).forEach((key) => { - const item = items[key]; - this._dependencyTracker.updateDependenciesFrom( - namespace, - key, - computeDependencies(namespace, item), - ); - }); - }); - - if (checkForChanges) { - const updatedItems = new NamespacedDataSet(); - Object.keys(data).forEach((namespace) => { - const oldDataForKind = oldData[namespace]; - const newDataForKind = data[namespace]; - let iterateData; - if (basis) { - // for basis, need to iterate on all keys - iterateData = { ...oldDataForKind, ...newDataForKind }; - } else { - // for non basis, only need to iterate on keys in incoming data - iterateData = { ...newDataForKind }; - } - Object.keys(iterateData).forEach((key) => { - this.addIfModified( + this._featureStore.applyChanges( + basis, + data, + () => { + // Defer change events so they execute after the callback. + Promise.resolve().then(() => { + if (basis) { + this._dependencyTracker.reset(); + } + + Object.entries(data).forEach(([namespace, items]) => { + Object.keys(items || {}).forEach((key) => { + const item = items[key]; + this._dependencyTracker.updateDependenciesFrom( namespace, key, - oldDataForKind && oldDataForKind[key], - newDataForKind && newDataForKind[key], - updatedItems, + computeDependencies(namespace, item), ); }); }); - this.sendChangeEvents(updatedItems); - } - }); - callback?.(); - }); + if (checkForChanges) { + const updatedItems = new NamespacedDataSet(); + Object.keys(data).forEach((namespace) => { + const oldDataForKind = oldData[namespace]; + const newDataForKind = data[namespace]; + let iterateData; + if (basis) { + // for basis, need to iterate on all keys + iterateData = { ...oldDataForKind, ...newDataForKind }; + } else { + // for non basis, only need to iterate on keys in incoming data + iterateData = { ...newDataForKind }; + } + Object.keys(iterateData).forEach((key) => { + this.addIfModified( + namespace, + key, + oldDataForKind && oldDataForKind[key], + newDataForKind && newDataForKind[key], + updatedItems, + ); + }); + }); + + this.sendChangeEvents(updatedItems); + } + }); + callback?.(); + }, + initMetadata, + selector, + ); }; let oldData = {}; diff --git a/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts b/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts index fc5fda2d33..2d0ff5230b 100644 --- a/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts +++ b/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts @@ -1,6 +1,7 @@ import { DataSourceErrorKind, httpErrorMessage, + internal, isHttpRecoverable, LDLogger, LDPollingError, @@ -15,6 +16,8 @@ import Requestor from './Requestor'; export type PollingErrorHandler = (err: LDPollingError) => void; +const { initMetadataFromHeaders } = internal; + /** * @internal */ @@ -50,7 +53,7 @@ export default class PollingProcessor implements subsystem.LDStreamProcessor { const startTime = Date.now(); this._logger?.debug('Polling LaunchDarkly for feature flag updates'); - this._requestor.requestAllData((err, body) => { + this._requestor.requestAllData((err, body, headers) => { const elapsed = Date.now() - startTime; const sleepFor = Math.max(this._pollInterval * 1000 - elapsed, 0); @@ -79,13 +82,17 @@ export default class PollingProcessor implements subsystem.LDStreamProcessor { [VersionedDataKinds.Features.namespace]: parsed.flags, [VersionedDataKinds.Segments.namespace]: parsed.segments, }; - this._featureStore.init(initData, () => { - this._initSuccessHandler(); - // Triggering the next poll after the init has completed. - this._timeoutHandle = setTimeout(() => { - this._poll(); - }, sleepFor); - }); + this._featureStore.init( + initData, + () => { + this._initSuccessHandler(); + // Triggering the next poll after the init has completed. + this._timeoutHandle = setTimeout(() => { + this._poll(); + }, sleepFor); + }, + initMetadataFromHeaders(headers), + ); // The poll will be triggered by the feature store initialization // completing. return; diff --git a/packages/shared/sdk-server/src/data_sources/Requestor.ts b/packages/shared/sdk-server/src/data_sources/Requestor.ts index 0d3567eae8..4f59bda9b7 100644 --- a/packages/shared/sdk-server/src/data_sources/Requestor.ts +++ b/packages/shared/sdk-server/src/data_sources/Requestor.ts @@ -70,7 +70,7 @@ export default class Requestor implements LDFeatureRequestor { return { res, body }; } - async requestAllData(cb: (err: any, body: any) => void) { + async requestAllData(cb: (err: any, body: any, headers: any) => void) { const options: Options = { method: 'GET', headers: this._headers, @@ -83,11 +83,15 @@ export default class Requestor implements LDFeatureRequestor { `Unexpected status code: ${res.status}`, res.status, ); - return cb(err, undefined); + return cb(err, undefined, undefined); } - return cb(undefined, res.status === 304 ? null : body); + return cb( + undefined, + res.status === 304 ? null : body, + Object.fromEntries(res.headers.entries()), + ); } catch (err) { - return cb(err, undefined); + return cb(err, undefined, undefined); } } } diff --git a/packages/shared/sdk-server/src/data_sources/StreamingProcessor.ts b/packages/shared/sdk-server/src/data_sources/StreamingProcessor.ts index e752f4863c..41cd409174 100644 --- a/packages/shared/sdk-server/src/data_sources/StreamingProcessor.ts +++ b/packages/shared/sdk-server/src/data_sources/StreamingProcessor.ts @@ -38,6 +38,7 @@ export default class StreamingProcessor implements subsystem.LDStreamProcessor { private _eventSource?: EventSource; private _requests: Requests; private _connectionAttemptStartTime?: number; + private _initHeaders?: { [key: string]: string }; constructor( clientContext: ClientContext, @@ -125,7 +126,8 @@ export default class StreamingProcessor implements subsystem.LDStreamProcessor { // The work is done by `errorFilter`. }; - eventSource.onopen = () => { + eventSource.onopen = (e) => { + this._initHeaders = e.headers; this._logger?.info('Opened LaunchDarkly stream connection'); }; @@ -146,7 +148,7 @@ export default class StreamingProcessor implements subsystem.LDStreamProcessor { reportJsonError(eventName, data, this._logger, this._errorHandler); return; } - processJson(dataJson); + processJson(dataJson, this._initHeaders); } else { this._errorHandler?.( new LDStreamingError( diff --git a/packages/shared/sdk-server/src/data_sources/createStreamListeners.ts b/packages/shared/sdk-server/src/data_sources/createStreamListeners.ts index 391e141914..6e453196cb 100644 --- a/packages/shared/sdk-server/src/data_sources/createStreamListeners.ts +++ b/packages/shared/sdk-server/src/data_sources/createStreamListeners.ts @@ -1,5 +1,6 @@ import { EventName, + internal, LDLogger, ProcessStreamResponse, VoidFunction, @@ -16,20 +17,24 @@ import { } from '../store/serialization'; import VersionedDataKinds from '../store/VersionedDataKinds'; +const { initMetadataFromHeaders } = internal; + export const createPutListener = ( dataSourceUpdates: LDDataSourceUpdates, logger?: LDLogger, onPutCompleteHandler: VoidFunction = () => {}, ) => ({ deserializeData: deserializeAll, - processJson: async ({ data: { flags, segments } }: AllData) => { + processJson: async ( + { data: { flags, segments } }: AllData, + initHeaders?: { [key: string]: string }, + ) => { const initData = { [VersionedDataKinds.Features.namespace]: flags, [VersionedDataKinds.Segments.namespace]: segments, }; - logger?.debug('Initializing all data'); - dataSourceUpdates.init(initData, onPutCompleteHandler); + dataSourceUpdates.init(initData, onPutCompleteHandler, initMetadataFromHeaders(initHeaders)); }, }); diff --git a/packages/shared/sdk-server/src/hooks/HookRunner.ts b/packages/shared/sdk-server/src/hooks/HookRunner.ts index c7c1e61f4f..315970f1b4 100644 --- a/packages/shared/sdk-server/src/hooks/HookRunner.ts +++ b/packages/shared/sdk-server/src/hooks/HookRunner.ts @@ -22,6 +22,7 @@ export default class HookRunner { defaultValue: unknown, methodName: string, method: () => Promise, + environmentId?: string, ): Promise { // This early return is here to avoid the extra async/await associated with // using withHooksDataWithDetail. @@ -38,6 +39,7 @@ export default class HookRunner { const detail = await method(); return { detail }; }, + environmentId, ).then(({ detail }) => detail); } @@ -51,12 +53,13 @@ export default class HookRunner { defaultValue: unknown, methodName: string, method: () => Promise<{ detail: LDEvaluationDetail; [index: string]: any }>, + environmentId?: string, ): Promise<{ detail: LDEvaluationDetail; [index: string]: any }> { if (this._hooks.length === 0) { return method(); } const { hooks, hookContext }: { hooks: Hook[]; hookContext: EvaluationSeriesContext } = - this._prepareHooks(key, context, defaultValue, methodName); + this._prepareHooks(key, context, defaultValue, methodName, environmentId); const hookData = this._executeBeforeEvaluation(hooks, hookContext); const result = await method(); this._executeAfterEvaluation(hooks, hookContext, hookData, result.detail); @@ -124,6 +127,7 @@ export default class HookRunner { context: LDContext, defaultValue: unknown, methodName: string, + environmentId?: string, ): { hooks: Hook[]; hookContext: EvaluationSeriesContext; @@ -137,6 +141,7 @@ export default class HookRunner { context, defaultValue, method: methodName, + environmentId, }; return { hooks, hookContext }; } diff --git a/packages/shared/sdk-server/src/index.ts b/packages/shared/sdk-server/src/index.ts index 1c99f0097d..63e0a22323 100644 --- a/packages/shared/sdk-server/src/index.ts +++ b/packages/shared/sdk-server/src/index.ts @@ -9,6 +9,7 @@ export * from './store'; export * from './events'; export * from '@launchdarkly/js-sdk-common'; +export * as internalServer from './internal'; export { LDClientImpl, diff --git a/packages/shared/sdk-server/src/internal/index.ts b/packages/shared/sdk-server/src/internal/index.ts new file mode 100644 index 0000000000..16b3b5d40a --- /dev/null +++ b/packages/shared/sdk-server/src/internal/index.ts @@ -0,0 +1,5 @@ +import TtlCache from '../cache/TtlCache'; +import type { TtlCacheOptions } from '../cache/TtlCache'; + +export { TtlCache }; +export type { TtlCacheOptions }; diff --git a/packages/shared/sdk-server/src/options/Configuration.ts b/packages/shared/sdk-server/src/options/Configuration.ts index bfc3d79cd0..8725e52520 100644 --- a/packages/shared/sdk-server/src/options/Configuration.ts +++ b/packages/shared/sdk-server/src/options/Configuration.ts @@ -68,6 +68,7 @@ const validations: Record = { application: TypeValidators.Object, payloadFilterKey: TypeValidators.stringMatchingRegex(/^[a-zA-Z0-9](\w|\.|-)*$/), hooks: TypeValidators.createTypeArray('Hook[]', {}), + enableEventCompression: TypeValidators.Boolean, type: TypeValidators.String, }; @@ -117,6 +118,7 @@ export const defaultValues: ValidatedOptions = { diagnosticOptOut: false, diagnosticRecordingInterval: 900, featureStore: () => new InMemoryFeatureStore(), + enableEventCompression: false, dataSystem: defaultDataSystemOptions, }; @@ -329,6 +331,8 @@ export default class Configuration { public readonly hooks?: Hook[]; + public readonly enableEventCompression: boolean; + constructor(options: LDOptions = {}, internalOptions: internal.LDInternalOptions = {}) { // The default will handle undefined, but not null. // Because we can be called from JS we need to be extra defensive. @@ -443,7 +447,7 @@ Type '((LDFeatureStore | ((options: LDOptions) => LDFeatureStore)) & ((...args: this.tags = new ApplicationTags(validatedOptions); this.diagnosticRecordingInterval = validatedOptions.diagnosticRecordingInterval; this.hooks = validatedOptions.hooks; - + this.enableEventCompression = validatedOptions.enableEventCompression; this.offline = validatedOptions.offline; } } diff --git a/packages/shared/sdk-server/src/options/ValidatedOptions.ts b/packages/shared/sdk-server/src/options/ValidatedOptions.ts index 1e707954c8..cf2a63ea5c 100644 --- a/packages/shared/sdk-server/src/options/ValidatedOptions.ts +++ b/packages/shared/sdk-server/src/options/ValidatedOptions.ts @@ -42,4 +42,5 @@ export interface ValidatedOptions { [index: string]: any; bigSegments?: LDBigSegmentsOptions; hooks?: Hook[]; + enableEventCompression: boolean; } diff --git a/packages/shared/sdk-server/src/store/AsyncStoreFacade.ts b/packages/shared/sdk-server/src/store/AsyncStoreFacade.ts index 792668ff25..9e9a8aa273 100644 --- a/packages/shared/sdk-server/src/store/AsyncStoreFacade.ts +++ b/packages/shared/sdk-server/src/store/AsyncStoreFacade.ts @@ -1,3 +1,5 @@ +import { internal } from '@launchdarkly/js-sdk-common'; + import { DataKind } from '../api/interfaces'; import { LDFeatureStore, @@ -8,6 +10,8 @@ import { } from '../api/subsystems'; import promisify from '../async/promisify'; +type InitMetadata = internal.InitMetadata; + /** * Provides an async interface to a feature store. * @@ -33,9 +37,9 @@ export default class AsyncStoreFacade { }); } - async init(allData: LDFeatureStoreDataStorage): Promise { + async init(allData: LDFeatureStoreDataStorage, initMetadata?: InitMetadata): Promise { return promisify((cb) => { - this._store.init(allData, cb); + this._store.init(allData, cb, initMetadata); }); } @@ -60,14 +64,19 @@ export default class AsyncStoreFacade { async applyChanges( basis: boolean, data: LDFeatureStoreDataStorage, - selector: String | undefined, + initMetadata?: internal.InitMetadata, + selector?: String, // TODO: SDK-1044 - Utilize selector ): Promise { return promisify((cb) => { - this._store.applyChanges(basis, data, selector, cb); + this._store.applyChanges(basis, data, cb, initMetadata, selector); }); } close(): void { this._store.close(); } + + getInitMetadata?(): InitMetadata | undefined { + return this._store.getInitMetaData?.(); + } } diff --git a/packages/shared/sdk-server/src/store/InMemoryFeatureStore.ts b/packages/shared/sdk-server/src/store/InMemoryFeatureStore.ts index 6357a81991..9992c24e1d 100644 --- a/packages/shared/sdk-server/src/store/InMemoryFeatureStore.ts +++ b/packages/shared/sdk-server/src/store/InMemoryFeatureStore.ts @@ -1,3 +1,5 @@ +import { internal } from '@launchdarkly/js-sdk-common'; + import { DataKind } from '../api/interfaces'; import { LDFeatureStore, @@ -7,11 +9,15 @@ import { LDKeyedFeatureStoreItem, } from '../api/subsystems'; +type InitMetadata = internal.InitMetadata; + export default class InMemoryFeatureStore implements LDFeatureStore { private _allData: LDFeatureStoreDataStorage = {}; private _initCalled = false; + private _initMetadata?: InitMetadata; + get(kind: DataKind, key: string, callback: (res: LDFeatureStoreItem | null) => void): void { const items = this._allData[kind.namespace]; if (items) { @@ -36,8 +42,8 @@ export default class InMemoryFeatureStore implements LDFeatureStore { callback?.(result); } - init(allData: LDFeatureStoreDataStorage, callback: () => void): void { - this.applyChanges(true, allData, undefined, callback); + init(allData: LDFeatureStoreDataStorage, callback: () => void, initMetadata?: InitMetadata): void { + this.applyChanges(true, allData, callback, initMetadata); } delete(kind: DataKind, key: string, version: number, callback: () => void): void { @@ -49,7 +55,6 @@ export default class InMemoryFeatureStore implements LDFeatureStore { [key]: item, }, }, - undefined, callback, ); } @@ -62,7 +67,6 @@ export default class InMemoryFeatureStore implements LDFeatureStore { [data.key]: data, }, }, - undefined, callback, ); } @@ -70,9 +74,11 @@ export default class InMemoryFeatureStore implements LDFeatureStore { applyChanges( basis: boolean, data: LDFeatureStoreDataStorage, - selector: String | undefined, // TODO: SDK-1044 - Utilize selector callback: () => void, + initMetadata?: InitMetadata, + selector?: String, // TODO: SDK-1044 - Utilize selector ): void { + this._initMetadata = initMetadata; if (basis) { this._initCalled = true; this._allData = data; @@ -121,4 +127,8 @@ export default class InMemoryFeatureStore implements LDFeatureStore { getDescription(): string { return 'memory'; } + + getInitMetaData(): InitMetadata | undefined { + return this._initMetadata; + } } diff --git a/packages/shared/sdk-server/src/store/PersistentDataStoreWrapper.ts b/packages/shared/sdk-server/src/store/PersistentDataStoreWrapper.ts index 97ec961bbb..c37c1ac66b 100644 --- a/packages/shared/sdk-server/src/store/PersistentDataStoreWrapper.ts +++ b/packages/shared/sdk-server/src/store/PersistentDataStoreWrapper.ts @@ -1,3 +1,5 @@ +import { internal } from '@launchdarkly/js-sdk-common'; + import { DataKind, PersistentDataStore, @@ -17,6 +19,7 @@ import { persistentStoreKinds } from './persistentStoreKinds'; import sortDataSet from './sortDataSet'; import UpdateQueue from './UpdateQueue'; + function cacheKey(kind: DataKind, key: string) { return `${kind.namespace}:${key}`; } @@ -266,8 +269,9 @@ export default class PersistentDataStoreWrapper implements LDFeatureStore { applyChanges( basis: boolean, data: LDFeatureStoreDataStorage, - selector: String | undefined, // TODO: SDK-1044 - Utilize selector callback: () => void, + _?: internal.InitMetadata, // init metadata is not utilized in the persistence layer + _selector?: String, // TODO: SDK-1044 - Utilize selector ): void { if (basis) { this._queue.enqueue((cb) => { diff --git a/packages/shared/sdk-server/src/store/TransactionalPersistentStore.ts b/packages/shared/sdk-server/src/store/TransactionalPersistentStore.ts index d4d95fd639..73895a86bf 100644 --- a/packages/shared/sdk-server/src/store/TransactionalPersistentStore.ts +++ b/packages/shared/sdk-server/src/store/TransactionalPersistentStore.ts @@ -1,3 +1,5 @@ +import { internal } from '@launchdarkly/js-sdk-common'; + import { DataKind } from '../api/interfaces'; import { LDFeatureStore, @@ -33,7 +35,7 @@ export default class TransactionalPersistentStore implements LDFeatureStore { init(allData: LDFeatureStoreDataStorage, callback: () => void): void { // adapt to applyChanges for common handling - this.applyChanges(true, allData, undefined, callback); + this.applyChanges(true, allData, callback); } delete(kind: DataKind, key: string, version: number, callback: () => void): void { @@ -46,7 +48,6 @@ export default class TransactionalPersistentStore implements LDFeatureStore { [key]: item, }, }, - undefined, callback, ); } @@ -60,7 +61,6 @@ export default class TransactionalPersistentStore implements LDFeatureStore { [data.key]: data, }, }, - undefined, callback, ); } @@ -68,13 +68,26 @@ export default class TransactionalPersistentStore implements LDFeatureStore { applyChanges( basis: boolean, data: LDFeatureStoreDataStorage, - selector: String | undefined, // TODO: SDK-1044 - Utilize selector callback: () => void, + _initMetadata?: internal.InitMetadata, // init metadata is not utilized in the persistence layer + _selector?: String, // TODO: SDK-1044 - Utilize selector ): void { - this._memoryStore.applyChanges(basis, data, selector, () => { - // TODO: SDK-1047 conditional propgation to persistence based on parameter - this._nonTransPersistenceStore.applyChanges(basis, data, selector, callback); - }); + this._memoryStore.applyChanges( + basis, + data, + () => { + // TODO: SDK-1047 conditional propagation to persistence based on parameter + this._nonTransPersistenceStore.applyChanges( + basis, + data, + callback, + _initMetadata, + _selector, + ); + }, + _initMetadata, + _selector, + ); if (basis) { // basis causes memory store to become the active store diff --git a/packages/store/node-server-sdk-dynamodb/CHANGELOG.md b/packages/store/node-server-sdk-dynamodb/CHANGELOG.md index e47412f8ef..aec808276c 100644 --- a/packages/store/node-server-sdk-dynamodb/CHANGELOG.md +++ b/packages/store/node-server-sdk-dynamodb/CHANGELOG.md @@ -90,6 +90,83 @@ * devDependencies * @launchdarkly/node-server-sdk bumped from 9.2.1 to 9.2.2 +## [6.2.9](https://github.com/launchdarkly/js-core/compare/node-server-sdk-dynamodb-v6.2.8...node-server-sdk-dynamodb-v6.2.9) (2025-04-16) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/node-server-sdk bumped from 9.8.0 to 9.9.0 + * peerDependencies + * @launchdarkly/node-server-sdk bumped from >=9.4.3 to >=9.9.0 + +## [6.2.8](https://github.com/launchdarkly/js-core/compare/node-server-sdk-dynamodb-v6.2.7...node-server-sdk-dynamodb-v6.2.8) (2025-04-08) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/node-server-sdk bumped from 9.7.7 to 9.8.0 + * peerDependencies + * @launchdarkly/node-server-sdk bumped from >=9.4.3 to >=9.8.0 + +## [6.2.7](https://github.com/launchdarkly/js-core/compare/node-server-sdk-dynamodb-v6.2.6...node-server-sdk-dynamodb-v6.2.7) (2025-03-26) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/node-server-sdk bumped from 9.7.6 to 9.7.7 + * peerDependencies + * @launchdarkly/node-server-sdk bumped from >=9.4.3 to >=9.7.7 + +## [6.2.6](https://github.com/launchdarkly/js-core/compare/node-server-sdk-dynamodb-v6.2.5...node-server-sdk-dynamodb-v6.2.6) (2025-03-21) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/node-server-sdk bumped from 9.7.5 to 9.7.6 + * peerDependencies + * @launchdarkly/node-server-sdk bumped from >=9.4.3 to >=9.7.6 + +## [6.2.5](https://github.com/launchdarkly/js-core/compare/node-server-sdk-dynamodb-v6.2.4...node-server-sdk-dynamodb-v6.2.5) (2025-03-17) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/node-server-sdk bumped from 9.7.4 to 9.7.5 + * peerDependencies + * @launchdarkly/node-server-sdk bumped from >=9.4.3 to >=9.7.5 + +## [6.2.4](https://github.com/launchdarkly/js-core/compare/node-server-sdk-dynamodb-v6.2.3...node-server-sdk-dynamodb-v6.2.4) (2025-02-18) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/node-server-sdk bumped from 9.7.3 to 9.7.4 + * peerDependencies + * @launchdarkly/node-server-sdk bumped from >=9.4.3 to >=9.7.4 + +## [6.2.3](https://github.com/launchdarkly/js-core/compare/node-server-sdk-dynamodb-v6.2.2...node-server-sdk-dynamodb-v6.2.3) (2025-01-22) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/node-server-sdk bumped from 9.7.2 to 9.7.3 + * peerDependencies + * @launchdarkly/node-server-sdk bumped from >=9.4.3 to >=9.7.3 + ## [6.2.2](https://github.com/launchdarkly/js-core/compare/node-server-sdk-dynamodb-v6.2.1...node-server-sdk-dynamodb-v6.2.2) (2024-11-14) diff --git a/packages/store/node-server-sdk-dynamodb/package.json b/packages/store/node-server-sdk-dynamodb/package.json index 7206ffec54..7bcb339980 100644 --- a/packages/store/node-server-sdk-dynamodb/package.json +++ b/packages/store/node-server-sdk-dynamodb/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/node-server-sdk-dynamodb", - "version": "6.2.2", + "version": "6.2.9", "description": "DynamoDB-backed feature store for the LaunchDarkly Server-Side SDK for Node.js", "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/store/node-server-sdk-dynamodb", "repository": { @@ -35,7 +35,7 @@ }, "devDependencies": { "@aws-sdk/client-dynamodb": "3.348.0", - "@launchdarkly/node-server-sdk": "9.7.2", + "@launchdarkly/node-server-sdk": "9.9.0", "@trivago/prettier-plugin-sort-imports": "^4.1.1", "@types/jest": "^29.4.0", "@typescript-eslint/eslint-plugin": "^6.20.0", diff --git a/packages/store/node-server-sdk-redis/CHANGELOG.md b/packages/store/node-server-sdk-redis/CHANGELOG.md index 988afd2abf..5a6e6a58ab 100644 --- a/packages/store/node-server-sdk-redis/CHANGELOG.md +++ b/packages/store/node-server-sdk-redis/CHANGELOG.md @@ -90,6 +90,83 @@ * devDependencies * @launchdarkly/node-server-sdk bumped from 9.2.1 to 9.2.2 +## [4.2.9](https://github.com/launchdarkly/js-core/compare/node-server-sdk-redis-v4.2.8...node-server-sdk-redis-v4.2.9) (2025-04-16) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/node-server-sdk bumped from 9.8.0 to 9.9.0 + * peerDependencies + * @launchdarkly/node-server-sdk bumped from >=9.4.3 to >=9.9.0 + +## [4.2.8](https://github.com/launchdarkly/js-core/compare/node-server-sdk-redis-v4.2.7...node-server-sdk-redis-v4.2.8) (2025-04-08) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/node-server-sdk bumped from 9.7.7 to 9.8.0 + * peerDependencies + * @launchdarkly/node-server-sdk bumped from >=9.4.3 to >=9.8.0 + +## [4.2.7](https://github.com/launchdarkly/js-core/compare/node-server-sdk-redis-v4.2.6...node-server-sdk-redis-v4.2.7) (2025-03-26) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/node-server-sdk bumped from 9.7.6 to 9.7.7 + * peerDependencies + * @launchdarkly/node-server-sdk bumped from >=9.4.3 to >=9.7.7 + +## [4.2.6](https://github.com/launchdarkly/js-core/compare/node-server-sdk-redis-v4.2.5...node-server-sdk-redis-v4.2.6) (2025-03-21) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/node-server-sdk bumped from 9.7.5 to 9.7.6 + * peerDependencies + * @launchdarkly/node-server-sdk bumped from >=9.4.3 to >=9.7.6 + +## [4.2.5](https://github.com/launchdarkly/js-core/compare/node-server-sdk-redis-v4.2.4...node-server-sdk-redis-v4.2.5) (2025-03-17) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/node-server-sdk bumped from 9.7.4 to 9.7.5 + * peerDependencies + * @launchdarkly/node-server-sdk bumped from >=9.4.3 to >=9.7.5 + +## [4.2.4](https://github.com/launchdarkly/js-core/compare/node-server-sdk-redis-v4.2.3...node-server-sdk-redis-v4.2.4) (2025-02-18) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/node-server-sdk bumped from 9.7.3 to 9.7.4 + * peerDependencies + * @launchdarkly/node-server-sdk bumped from >=9.4.3 to >=9.7.4 + +## [4.2.3](https://github.com/launchdarkly/js-core/compare/node-server-sdk-redis-v4.2.2...node-server-sdk-redis-v4.2.3) (2025-01-22) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/node-server-sdk bumped from 9.7.2 to 9.7.3 + * peerDependencies + * @launchdarkly/node-server-sdk bumped from >=9.4.3 to >=9.7.3 + ## [4.2.2](https://github.com/launchdarkly/js-core/compare/node-server-sdk-redis-v4.2.1...node-server-sdk-redis-v4.2.2) (2024-11-14) diff --git a/packages/store/node-server-sdk-redis/package.json b/packages/store/node-server-sdk-redis/package.json index 9d252beb74..51ac0147d8 100644 --- a/packages/store/node-server-sdk-redis/package.json +++ b/packages/store/node-server-sdk-redis/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/node-server-sdk-redis", - "version": "4.2.2", + "version": "4.2.9", "description": "Redis-backed feature store for the LaunchDarkly Server-Side SDK for Node.js", "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/store/node-server-sdk-redis", "repository": { @@ -33,7 +33,7 @@ "@launchdarkly/node-server-sdk": ">=9.4.3" }, "devDependencies": { - "@launchdarkly/node-server-sdk": "9.7.2", + "@launchdarkly/node-server-sdk": "9.9.0", "@trivago/prettier-plugin-sort-imports": "^4.1.1", "@types/jest": "^29.4.0", "@typescript-eslint/eslint-plugin": "^6.20.0", diff --git a/packages/telemetry/browser-telemetry/CHANGELOG.md b/packages/telemetry/browser-telemetry/CHANGELOG.md new file mode 100644 index 0000000000..6ff46da04e --- /dev/null +++ b/packages/telemetry/browser-telemetry/CHANGELOG.md @@ -0,0 +1,128 @@ +# Changelog + +## [1.0.6](https://github.com/launchdarkly/js-core/compare/browser-telemetry-v1.0.5...browser-telemetry-v1.0.6) (2025-04-16) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/js-client-sdk bumped from 0.5.2 to 0.5.3 + +## [1.0.5](https://github.com/launchdarkly/js-core/compare/browser-telemetry-v1.0.4...browser-telemetry-v1.0.5) (2025-04-15) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/js-client-sdk bumped from 0.5.1 to 0.5.2 + +## [1.0.4](https://github.com/launchdarkly/js-core/compare/browser-telemetry-v1.0.3...browser-telemetry-v1.0.4) (2025-04-08) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/js-client-sdk bumped from 0.5.0 to 0.5.1 + +## [1.0.3](https://github.com/launchdarkly/js-core/compare/browser-telemetry-v1.0.2...browser-telemetry-v1.0.3) (2025-03-26) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/js-client-sdk bumped from 0.4.1 to 0.5.0 + +## [1.0.2](https://github.com/launchdarkly/js-core/compare/browser-telemetry-v1.0.1...browser-telemetry-v1.0.2) (2025-02-28) + + +### Bug Fixes + +* Fix a bug where the incorrect src lines may have been captured. ([#792](https://github.com/launchdarkly/js-core/issues/792)) ([1f44dd5](https://github.com/launchdarkly/js-core/commit/1f44dd5bad3cc108beda5fb23d9b2b540812e7e6)) + +## [1.0.1](https://github.com/launchdarkly/js-core/compare/browser-telemetry-v1.0.0...browser-telemetry-v1.0.1) (2025-02-18) + + +### Bug Fixes + +* Fix issue processing URLs for fetch and XHR requests. ([#783](https://github.com/launchdarkly/js-core/issues/783)) ([32cec6a](https://github.com/launchdarkly/js-core/commit/32cec6af00384e7496832ba87a3005b26558c528)) + +## [1.0.0](https://github.com/launchdarkly/js-core/compare/browser-telemetry-v0.3.1...browser-telemetry-v1.0.0) (2025-02-18) + + +### ⚠ BREAKING CHANGES + +* 1.0 Release for browser-telemetry. + +### Features + +* 1.0 Release for browser-telemetry. ([681e423](https://github.com/launchdarkly/js-core/commit/681e4230efb99abb1acb51de3a7d0265fddcd6e0)) + +## [0.3.1](https://github.com/launchdarkly/js-core/compare/browser-telemetry-v0.3.0...browser-telemetry-v0.3.1) (2025-02-06) + + +### Dependencies + +* The following workspace dependencies were updated + * devDependencies + * @launchdarkly/js-client-sdk bumped from 0.3.2 to 0.4.1 + +## [0.3.0](https://github.com/launchdarkly/js-core/compare/browser-telemetry-v0.2.0...browser-telemetry-v0.3.0) (2025-01-31) + + +### Features + +* Option to disable all breadcrumbs and stack. ([#770](https://github.com/launchdarkly/js-core/issues/770)) ([2c51838](https://github.com/launchdarkly/js-core/commit/2c51838f84a6c21ab38b12d960117d8ed801a114)) + +## [0.2.0](https://github.com/launchdarkly/js-core/compare/browser-telemetry-v0.1.1...browser-telemetry-v0.2.0) (2025-01-23) + + +### Features + +* Add support for filtering username/password URL authority. ([#751](https://github.com/launchdarkly/js-core/issues/751)) ([62ab9fb](https://github.com/launchdarkly/js-core/commit/62ab9fb774847b5d953041f29b5f997629f86fa7)) + +## [0.1.1](https://github.com/launchdarkly/js-core/compare/browser-telemetry-v0.1.0...browser-telemetry-v0.1.1) (2025-01-23) + + +### Bug Fixes + +* Fix race condition with client registration. ([#750](https://github.com/launchdarkly/js-core/issues/750)) ([d2ac2e2](https://github.com/launchdarkly/js-core/commit/d2ac2e230118b573b4e90b5781350067c7920fcf)) + +## [0.1.0](https://github.com/launchdarkly/js-core/compare/browser-telemetry-v0.0.9...browser-telemetry-v0.1.0) (2025-01-22) + + +### ⚠ BREAKING CHANGES + +* Updated AI Config interface. ([#697](https://github.com/launchdarkly/js-core/issues/697)) + +### Features + +* Add basic logging support for browser-telemetry. ([#736](https://github.com/launchdarkly/js-core/issues/736)) ([2ef1486](https://github.com/launchdarkly/js-core/commit/2ef14868ce581afbc5257448da13414a5ba1c100)) +* Add browser telemetry options. ([#675](https://github.com/launchdarkly/js-core/issues/675)) ([c8352b2](https://github.com/launchdarkly/js-core/commit/c8352b21b678bb8f1063bb0c9df2e795c6cec8d5)) +* Add browser-telemetry API types. ([#669](https://github.com/launchdarkly/js-core/issues/669)) ([89967ee](https://github.com/launchdarkly/js-core/commit/89967eec67da13951837f19b7671647fb96b2c8c)) +* Add DOM collectors. ([#672](https://github.com/launchdarkly/js-core/issues/672)) ([4473a06](https://github.com/launchdarkly/js-core/commit/4473a06145b09205f1b03d31a2215b9c3b6d75c2)) +* Add http collectors. ([#673](https://github.com/launchdarkly/js-core/issues/673)) ([6e60ddc](https://github.com/launchdarkly/js-core/commit/6e60ddc6932341ace2d16ace688d7774bc6340d4)) +* Add singleton support for browser-telemetry. ([#739](https://github.com/launchdarkly/js-core/issues/739)) ([68a3b87](https://github.com/launchdarkly/js-core/commit/68a3b87fcc9600a7f64e7e2e1a15c12b9c370f25)) +* Add stack trace parsing. ([#676](https://github.com/launchdarkly/js-core/issues/676)) ([ca1dd49](https://github.com/launchdarkly/js-core/commit/ca1dd49e596c73e807388cefcae36e956b3477a0)) +* Add support for breadcrumb filtering. ([#733](https://github.com/launchdarkly/js-core/issues/733)) ([5c327a1](https://github.com/launchdarkly/js-core/commit/5c327a1c42625ec606a8599f59d58a1686f050e1)) +* Add support for the session init event. ([320c07d](https://github.com/launchdarkly/js-core/commit/320c07d852a8902523c290a5249f92efffd89dde)) +* Add the ability to filter errors. ([#743](https://github.com/launchdarkly/js-core/issues/743)) ([5cffb2b](https://github.com/launchdarkly/js-core/commit/5cffb2b5216f94941498ebb6bb783d0a8841d566)) +* Export browser-telemetry initialization method. ([d1b364e](https://github.com/launchdarkly/js-core/commit/d1b364eaf08502b8b7d65c124833b617577fd081)) +* Implement browser telemetry client. ([#691](https://github.com/launchdarkly/js-core/issues/691)) ([db74a99](https://github.com/launchdarkly/js-core/commit/db74a99c736c00521f317c1fcddb2d1038c01c1c)) +* Make browser-telemetry specific inspector type. ([#741](https://github.com/launchdarkly/js-core/issues/741)) ([14ecdb3](https://github.com/launchdarkly/js-core/commit/14ecdb3570b04ee26c38f361bfa2db948c843fef)) +* Random uuid for telemetry package. ([#689](https://github.com/launchdarkly/js-core/issues/689)) ([4cf34f9](https://github.com/launchdarkly/js-core/commit/4cf34f94f9d1a1949462187d09e7d84b096edb15)) +* Rename initializeTelemetryInstance to initTelemetryInstance for consistency with initTelemetry. ([257734f](https://github.com/launchdarkly/js-core/commit/257734f74d5c36d9e68441d6ca7dd7d1a6a2ba9b)) +* Source maps with inline sources for browser-telemetry. ([#735](https://github.com/launchdarkly/js-core/issues/735)) ([1656a85](https://github.com/launchdarkly/js-core/commit/1656a856e412a661af26ed08620aebedf2064ae1)) +* Updated AI Config interface. ([#697](https://github.com/launchdarkly/js-core/issues/697)) ([cd72ea8](https://github.com/launchdarkly/js-core/commit/cd72ea8193888b0635b5beffa0a877b18294777e)) +* Vendor TraceKit ([d1b364e](https://github.com/launchdarkly/js-core/commit/d1b364eaf08502b8b7d65c124833b617577fd081)) + + +### Bug Fixes + +* Clear pending events buffer when registered. ([#727](https://github.com/launchdarkly/js-core/issues/727)) ([b6ad7df](https://github.com/launchdarkly/js-core/commit/b6ad7dfe1e16122ca16b6304e1a7b1c362cf2156)) +* Export BrowserTelemetry, BrowserTelemetryInspector, and ImplementsCrumb. ([257734f](https://github.com/launchdarkly/js-core/commit/257734f74d5c36d9e68441d6ca7dd7d1a6a2ba9b)) +* Fix breadcrumb filter option parsing. ([#742](https://github.com/launchdarkly/js-core/issues/742)) ([833f4ce](https://github.com/launchdarkly/js-core/commit/833f4ce18b53c31a042316768cfeb4118746857e)) +* Remove BrowserTelemetry until more types are available. ([#671](https://github.com/launchdarkly/js-core/issues/671)) ([796b8a3](https://github.com/launchdarkly/js-core/commit/796b8a379e23b3345b1b5db3e324372570993603)) diff --git a/packages/telemetry/browser-telemetry/README.md b/packages/telemetry/browser-telemetry/README.md index 816ee4d6d2..159bdb00b7 100644 --- a/packages/telemetry/browser-telemetry/README.md +++ b/packages/telemetry/browser-telemetry/README.md @@ -1,11 +1,12 @@ # Telemetry integration for LaunchDarkly browser SDKs. -# ⛔️⛔️⛔️⛔️ +[![NPM][browser-telemetry-npm-badge]][browser-telemetry-npm-link] +[![Actions Status][browser-telemetry-ci-badge]][browser-telemetry-ci] +[![Documentation][browser-telemetry-ghp-badge]][browser-telemetry-ghp-link] +[![NPM][browser-telemetry-dm-badge]][browser-telemetry-npm-link] +[![NPM][browser-telemetry-dt-badge]][browser-telemetry-npm-link] -> [!WARNING] -> This is an alpha version. The API is not stabilized and will introduce breaking changes. - -TODO Add badges +Telemetry package for use with the LaunchDarkly browser SDKs. ## LaunchDarkly overview @@ -15,11 +16,47 @@ TODO Add badges ## Compatibility -TODO +This package is compatible with the `launchdarkly-js-client-sdk` version 3.4.0 and later. ## Setup -TODO +### For error metric collection only + +``` +import { initialize } from "launchdarkly-js-client-sdk"; +import { initTelemetry, register } from "@launchdarkly/browser-telemetry"; + +// Initialize the telemetry as early as possible in your application. +// Errors will be missed if they occur before the telemetry is initialized. +// For metrics only, breadcrumbs and stack traces are not required. +initTelemetry({breadcrumbs: false, stack: false}); + +// Initialize the LaunchDarkly client. +const client = initialize('sdk-key', context); + +// Register the client with the telemetry instance. +register(client); +``` + +### For error monitoring + metric collection + +``` +import { initialize } from "launchdarkly-js-client-sdk"; +import { initTelemetry, register, inspectors } from "@launchdarkly/browser-telemetry"; + +// Initialize the telemetry as early as possible in your application. +// Errors will be missed if they occur before the telemetry is initialized. +initTelemetry(); + +// Initialize the LaunchDarkly client. +const client = initialize('sdk-key', context, { + // Inspectors allows the telemetry SDK to capture feature flag information. + inspectors: inspectors(), +}); + +// Register the client with the telemetry instance. +register(client); +``` ## Contributing @@ -39,3 +76,12 @@ We encourage pull requests and other contributions from the community. Check out - [docs.launchdarkly.com](https://docs.launchdarkly.com/ 'LaunchDarkly Documentation') for our documentation and SDK reference guides - [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ 'LaunchDarkly API Documentation') for our API documentation - [blog.launchdarkly.com](https://blog.launchdarkly.com/ 'LaunchDarkly Blog Documentation') for the latest product updates + +[browser-telemetry-ci-badge]: https://github.com/launchdarkly/js-core/actions/workflows/browser-telemetry.yml/badge.svg +[browser-telemetry-ci]: https://github.com/launchdarkly/js-core/actions/workflows/browser-telemetry.yml +[browser-telemetry-npm-badge]: https://img.shields.io/npm/v/@launchdarkly/browser-telemetry.svg?style=flat-square +[browser-telemetry-npm-link]: https://www.npmjs.com/package/@launchdarkly/browser-telemetry +[browser-telemetry-ghp-badge]: https://img.shields.io/static/v1?label=GitHub+Pages&message=API+reference&color=00add8 +[browser-telemetry-ghp-link]: https://launchdarkly.github.io/js-core/packages/telemetry/browser-telemetry/docs/ +[browser-telemetry-dm-badge]: https://img.shields.io/npm/dm/@launchdarkly/browser-telemetry.svg?style=flat-square +[browser-telemetry-dt-badge]: https://img.shields.io/npm/dt/@launchdarkly/browser-telemetry.svg?style=flat-square diff --git a/packages/telemetry/browser-telemetry/__tests__/BrowserTelemetryImpl.test.ts b/packages/telemetry/browser-telemetry/__tests__/BrowserTelemetryImpl.test.ts index 8233f46fb4..fbdf135c2e 100644 --- a/packages/telemetry/browser-telemetry/__tests__/BrowserTelemetryImpl.test.ts +++ b/packages/telemetry/browser-telemetry/__tests__/BrowserTelemetryImpl.test.ts @@ -1,3 +1,4 @@ +import { LDClientLogging } from '../src/api'; import { LDClientTracking } from '../src/api/client/LDClientTracking'; import BrowserTelemetryImpl from '../src/BrowserTelemetryImpl'; import { ParsedOptions } from '../src/options'; @@ -22,8 +23,10 @@ const defaultOptions: ParsedOptions = { }, evaluations: true, flagChange: true, + filters: [], }, stack: { + enabled: true, source: { beforeLines: 5, afterLines: 5, @@ -31,6 +34,7 @@ const defaultOptions: ParsedOptions = { }, }, collectors: [], + errorFilters: [], }; it('sends buffered events when client is registered', () => { @@ -65,8 +69,8 @@ it('limits pending events to maxPendingEvents', () => { telemetry.register(mockClient); - // Should only see the last 2 errors tracked - expect(mockClient.track).toHaveBeenCalledTimes(2); + // Should only see the the session init event and last 2 errors tracked + expect(mockClient.track).toHaveBeenCalledTimes(3); expect(mockClient.track).toHaveBeenCalledWith( '$ld:telemetry:error', expect.objectContaining({ @@ -208,3 +212,525 @@ it('unregisters collectors on close', () => { expect(mockCollector.unregister).toHaveBeenCalled(); }); + +it('logs event dropped message when maxPendingEvents is reached', () => { + const mockLogger = { + warn: jest.fn(), + }; + const telemetry = new BrowserTelemetryImpl({ + ...defaultOptions, + maxPendingEvents: 2, + logger: mockLogger, + }); + telemetry.captureError(new Error('Test error')); + expect(mockLogger.warn).not.toHaveBeenCalled(); + telemetry.captureError(new Error('Test error 2')); + expect(mockLogger.warn).not.toHaveBeenCalled(); + + telemetry.captureError(new Error('Test error 3')); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'LaunchDarkly - Browser Telemetry: Maximum pending events reached. Old events will be dropped until the SDK' + + ' client is registered.', + ); + + telemetry.captureError(new Error('Test error 4')); + expect(mockLogger.warn).toHaveBeenCalledTimes(1); +}); + +it('filters breadcrumbs using provided filters', () => { + const options: ParsedOptions = { + ...defaultOptions, + breadcrumbs: { + ...defaultOptions.breadcrumbs, + click: false, + evaluations: false, + flagChange: false, + http: { instrumentFetch: false, instrumentXhr: false }, + keyboardInput: false, + filters: [ + // Filter to remove breadcrumbs with id:2 + (breadcrumb) => { + if (breadcrumb.type === 'custom' && breadcrumb.data?.id === 2) { + return undefined; + } + return breadcrumb; + }, + // Filter to transform breadcrumbs with id:3 + (breadcrumb) => { + if (breadcrumb.type === 'custom' && breadcrumb.data?.id === 3) { + return { + ...breadcrumb, + data: { id: 'filtered-3' }, + }; + } + return breadcrumb; + }, + ], + }, + }; + const telemetry = new BrowserTelemetryImpl(options); + + telemetry.addBreadcrumb({ + type: 'custom', + data: { id: 1 }, + timestamp: Date.now(), + class: 'custom', + level: 'info', + }); + + telemetry.addBreadcrumb({ + type: 'custom', + data: { id: 2 }, + timestamp: Date.now(), + class: 'custom', + level: 'info', + }); + + telemetry.addBreadcrumb({ + type: 'custom', + data: { id: 3 }, + timestamp: Date.now(), + class: 'custom', + level: 'info', + }); + + const error = new Error('Test error'); + telemetry.captureError(error); + telemetry.register(mockClient); + + expect(mockClient.track).toHaveBeenCalledWith( + '$ld:telemetry:error', + expect.objectContaining({ + breadcrumbs: expect.arrayContaining([ + expect.objectContaining({ data: { id: 1 } }), + expect.objectContaining({ data: { id: 'filtered-3' } }), + ]), + }), + ); + + // Verify breadcrumb with id:2 was filtered out + expect(mockClient.track).toHaveBeenCalledWith( + '$ld:telemetry:error', + expect.objectContaining({ + breadcrumbs: expect.not.arrayContaining([expect.objectContaining({ data: { id: 2 } })]), + }), + ); +}); + +it('omits breadcrumb when a filter throws an exception', () => { + const breadSpy = jest.fn((breadcrumb) => breadcrumb); + const options: ParsedOptions = { + ...defaultOptions, + breadcrumbs: { + ...defaultOptions.breadcrumbs, + filters: [ + () => { + throw new Error('Filter error'); + }, + // This filter should never run + breadSpy, + ], + }, + }; + const telemetry = new BrowserTelemetryImpl(options); + + telemetry.addBreadcrumb({ + type: 'custom', + data: { id: 1 }, + timestamp: Date.now(), + class: 'custom', + level: 'info', + }); + + const error = new Error('Test error'); + telemetry.captureError(error); + telemetry.register(mockClient); + + expect(mockClient.track).toHaveBeenCalledWith( + '$ld:telemetry:error', + expect.objectContaining({ + breadcrumbs: [], + }), + ); + + expect(breadSpy).not.toHaveBeenCalled(); +}); + +it('omits breadcrumbs when a filter is not a function', () => { + const options: ParsedOptions = { + ...defaultOptions, + breadcrumbs: { + ...defaultOptions.breadcrumbs, + // @ts-ignore + filters: ['potato'], + }, + }; + const telemetry = new BrowserTelemetryImpl(options); + + telemetry.addBreadcrumb({ + type: 'custom', + data: { id: 1 }, + timestamp: Date.now(), + class: 'custom', + level: 'info', + }); + + const error = new Error('Test error'); + telemetry.captureError(error); + telemetry.register(mockClient); + + expect(mockClient.track).toHaveBeenCalledWith( + '$ld:telemetry:error', + expect.objectContaining({ + breadcrumbs: [], + }), + ); +}); + +it('warns when a breadcrumb filter is not a function', () => { + const mockLogger = { + warn: jest.fn(), + }; + const options: ParsedOptions = { + ...defaultOptions, + // @ts-ignore + breadcrumbs: { ...defaultOptions.breadcrumbs, filters: ['potato'] }, + logger: mockLogger, + }; + + const telemetry = new BrowserTelemetryImpl(options); + telemetry.addBreadcrumb({ + type: 'custom', + data: { id: 1 }, + timestamp: Date.now(), + class: 'custom', + level: 'info', + }); + + expect(mockLogger.warn).toHaveBeenCalledWith( + 'LaunchDarkly - Browser Telemetry: Error applying breadcrumb filters: TypeError: filter is not a function', + ); +}); + +it('warns when a breadcrumb filter throws an exception', () => { + const mockLogger = { + warn: jest.fn(), + }; + const options: ParsedOptions = { + ...defaultOptions, + breadcrumbs: { + ...defaultOptions.breadcrumbs, + filters: [ + () => { + throw new Error('Filter error'); + }, + ], + }, + logger: mockLogger, + }; + + const telemetry = new BrowserTelemetryImpl(options); + telemetry.addBreadcrumb({ + type: 'custom', + data: { id: 1 }, + timestamp: Date.now(), + class: 'custom', + level: 'info', + }); + + expect(mockLogger.warn).toHaveBeenCalledWith( + 'LaunchDarkly - Browser Telemetry: Error applying breadcrumb filters: Error: Filter error', + ); +}); + +it('only logs breadcrumb filter error once', () => { + const mockLogger = { + warn: jest.fn(), + }; + const options: ParsedOptions = { + ...defaultOptions, + breadcrumbs: { + ...defaultOptions.breadcrumbs, + filters: [ + () => { + throw new Error('Filter error'); + }, + ], + }, + logger: mockLogger, + }; + + const telemetry = new BrowserTelemetryImpl(options); + + // Add multiple breadcrumbs that will trigger the filter error + telemetry.addBreadcrumb({ + type: 'custom', + data: { id: 1 }, + timestamp: Date.now(), + class: 'custom', + level: 'info', + }); + + telemetry.addBreadcrumb({ + type: 'custom', + data: { id: 2 }, + timestamp: Date.now(), + class: 'custom', + level: 'info', + }); + + // Verify warning was only logged once + expect(mockLogger.warn).toHaveBeenCalledTimes(1); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'LaunchDarkly - Browser Telemetry: Error applying breadcrumb filters: Error: Filter error', + ); +}); + +it('uses the client logger when no logger is provided', () => { + const options: ParsedOptions = { + ...defaultOptions, + breadcrumbs: { + ...defaultOptions.breadcrumbs, + filters: [ + () => { + throw new Error('Filter error'); + }, + ], + }, + }; + + const telemetry = new BrowserTelemetryImpl(options); + + const mockClientWithLogging: jest.Mocked = { + logger: { + warn: jest.fn(), + }, + track: jest.fn(), + }; + + telemetry.register(mockClientWithLogging); + + // Add multiple breadcrumbs that will trigger the filter error + telemetry.addBreadcrumb({ + type: 'custom', + data: { id: 1 }, + timestamp: Date.now(), + class: 'custom', + level: 'info', + }); + + expect(mockClientWithLogging.logger.warn).toHaveBeenCalledTimes(1); + expect(mockClientWithLogging.logger.warn).toHaveBeenCalledWith( + 'LaunchDarkly - Browser Telemetry: Error applying breadcrumb filters: Error: Filter error', + ); +}); + +it('sends session init event when client is registered', () => { + const telemetry = new BrowserTelemetryImpl(defaultOptions); + telemetry.register(mockClient); + + expect(mockClient.track).toHaveBeenCalledWith( + '$ld:telemetry:session:init', + expect.objectContaining({ + sessionId: expect.any(String), + }), + ); +}); + +it('applies error filters to captured errors', () => { + const options: ParsedOptions = { + ...defaultOptions, + errorFilters: [ + (error) => ({ + ...error, + message: error.message.replace('secret', 'redacted'), + }), + ], + }; + const telemetry = new BrowserTelemetryImpl(options); + + telemetry.captureError(new Error('Error with secret info')); + telemetry.register(mockClient); + + expect(mockClient.track).toHaveBeenCalledWith( + '$ld:telemetry:error', + expect.objectContaining({ + message: 'Error with redacted info', + }), + ); +}); + +it('filters out errors when filter returns undefined', () => { + const options: ParsedOptions = { + ...defaultOptions, + errorFilters: [() => undefined], + }; + const telemetry = new BrowserTelemetryImpl(options); + + telemetry.captureError(new Error('Test error')); + telemetry.register(mockClient); + + // Verify only session init event was tracked + expect(mockClient.track).toHaveBeenCalledTimes(1); + expect(mockClient.track).toHaveBeenCalledWith( + '$ld:telemetry:session:init', + expect.objectContaining({ + sessionId: expect.any(String), + }), + ); +}); + +it('applies multiple error filters in sequence', () => { + const options: ParsedOptions = { + ...defaultOptions, + errorFilters: [ + (error) => ({ + ...error, + message: error.message.replace('secret', 'redacted'), + }), + (error) => ({ + ...error, + message: error.message.replace('redacted', 'sneaky'), + }), + ], + }; + const telemetry = new BrowserTelemetryImpl(options); + + telemetry.captureError(new Error('Error with secret info')); + telemetry.register(mockClient); + + expect(mockClient.track).toHaveBeenCalledWith( + '$ld:telemetry:error', + expect.objectContaining({ + message: 'Error with sneaky info', + }), + ); +}); + +it('handles error filter throwing an exception', () => { + const mockLogger = { + warn: jest.fn(), + }; + const options: ParsedOptions = { + ...defaultOptions, + errorFilters: [ + () => { + throw new Error('Filter error'); + }, + ], + logger: mockLogger, + }; + const telemetry = new BrowserTelemetryImpl(options); + + telemetry.captureError(new Error('Test error')); + telemetry.register(mockClient); + + expect(mockLogger.warn).toHaveBeenCalledWith( + 'LaunchDarkly - Browser Telemetry: Error applying error filters: Error: Filter error', + ); + // Verify only session init event was tracked + expect(mockClient.track).toHaveBeenCalledTimes(1); + expect(mockClient.track).toHaveBeenCalledWith( + '$ld:telemetry:session:init', + expect.objectContaining({ + sessionId: expect.any(String), + }), + ); +}); + +it('only logs error filter error once', () => { + const mockLogger = { + warn: jest.fn(), + }; + const options: ParsedOptions = { + ...defaultOptions, + errorFilters: [ + () => { + throw new Error('Filter error'); + }, + ], + logger: mockLogger, + }; + const telemetry = new BrowserTelemetryImpl(options); + + telemetry.captureError(new Error('Error 1')); + telemetry.captureError(new Error('Error 2')); + + expect(mockLogger.warn).toHaveBeenCalledTimes(1); +}); + +it('waits for client initialization before sending events', async () => { + const telemetry = new BrowserTelemetryImpl(defaultOptions); + const error = new Error('Test error'); + + let resolver; + + const initPromise = new Promise((resolve) => { + resolver = resolve; + }); + + const mockInitClient = { + track: jest.fn(), + waitForInitialization: jest.fn().mockImplementation(() => initPromise), + }; + + telemetry.captureError(error); + telemetry.register(mockInitClient); + + expect(mockInitClient.track).not.toHaveBeenCalled(); + + resolver!(); + + await initPromise; + + expect(mockInitClient.track).toHaveBeenCalledWith( + '$ld:telemetry:session:init', + expect.objectContaining({ + sessionId: expect.any(String), + }), + ); + + expect(mockInitClient.track).toHaveBeenCalledWith( + '$ld:telemetry:error', + expect.objectContaining({ + type: 'Error', + message: 'Test error', + stack: { frames: expect.any(Array) }, + breadcrumbs: [], + sessionId: expect.any(String), + }), + ); +}); + +it('handles client initialization failure gracefully', async () => { + const telemetry = new BrowserTelemetryImpl(defaultOptions); + const error = new Error('Test error'); + const mockInitClient = { + track: jest.fn(), + waitForInitialization: jest.fn().mockRejectedValue(new Error('Init failed')), + }; + + telemetry.captureError(error); + telemetry.register(mockInitClient); + + await expect(mockInitClient.waitForInitialization()).rejects.toThrow('Init failed'); + + // Should still send events even if initialization fails + expect(mockInitClient.track).toHaveBeenCalledWith( + '$ld:telemetry:session:init', + expect.objectContaining({ + sessionId: expect.any(String), + }), + ); + + expect(mockInitClient.track).toHaveBeenCalledWith( + '$ld:telemetry:error', + expect.objectContaining({ + type: 'Error', + message: 'Test error', + stack: { frames: expect.any(Array) }, + breadcrumbs: [], + sessionId: expect.any(String), + }), + ); +}); diff --git a/packages/telemetry/browser-telemetry/__tests__/collectors/http/fetch.test.ts b/packages/telemetry/browser-telemetry/__tests__/collectors/http/fetch.test.ts index b11c9a506c..766fc9a8af 100644 --- a/packages/telemetry/browser-telemetry/__tests__/collectors/http/fetch.test.ts +++ b/packages/telemetry/browser-telemetry/__tests__/collectors/http/fetch.test.ts @@ -1,4 +1,5 @@ import { HttpBreadcrumb } from '../../../src/api/Breadcrumb'; +import { MinLogger } from '../../../src/api/MinLogger'; import { Recorder } from '../../../src/api/Recorder'; import FetchCollector from '../../../src/collectors/http/fetch'; @@ -140,3 +141,64 @@ describe('given a FetchCollector with a mock recorder', () => { ); }); }); + +describe('given a FetchCollector with a URL filter that throws an error', () => { + let mockRecorder: Recorder; + let collector: FetchCollector; + let mockLogger: MinLogger; + beforeEach(() => { + mockLogger = { + warn: jest.fn(), + }; + // Create mock recorder + mockRecorder = { + addBreadcrumb: jest.fn(), + captureError: jest.fn(), + captureErrorEvent: jest.fn(), + }; + collector = new FetchCollector({ + urlFilters: [ + () => { + throw new Error('test error'); + }, + ], + getLogger: () => mockLogger, + }); + }); + + it('logs an error if it fails to filter a breadcrumb', async () => { + collector.register(mockRecorder, 'test-session'); + + const mockResponse = new Response('test response', { status: 200, statusText: 'OK' }); + (initialFetch as jest.Mock).mockResolvedValue(mockResponse); + + await fetch('https://api.example.com/data'); + + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Error filtering http breadcrumb', + new Error('test error'), + ); + expect(initialFetch).toHaveBeenCalledWith('https://api.example.com/data'); + + expect(mockRecorder.addBreadcrumb).not.toHaveBeenCalled(); + }); + + it('only logs the filter error once for multiple requests', async () => { + collector.register(mockRecorder, 'test-session'); + + const mockResponse = new Response('test response', { status: 200, statusText: 'OK' }); + (initialFetch as jest.Mock).mockResolvedValue(mockResponse); + + // Make multiple fetch calls that will trigger the filter error + await fetch('https://api.example.com/data'); + await fetch('https://api.example.com/data2'); + await fetch('https://api.example.com/data3'); + + expect(mockLogger.warn).toHaveBeenCalledTimes(1); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Error filtering http breadcrumb', + new Error('test error'), + ); + expect(mockRecorder.addBreadcrumb).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/telemetry/browser-telemetry/__tests__/collectors/http/xhr.test.ts b/packages/telemetry/browser-telemetry/__tests__/collectors/http/xhr.test.ts index 125b639ab3..f301c2bff9 100644 --- a/packages/telemetry/browser-telemetry/__tests__/collectors/http/xhr.test.ts +++ b/packages/telemetry/browser-telemetry/__tests__/collectors/http/xhr.test.ts @@ -1,4 +1,5 @@ import { HttpBreadcrumb } from '../../../src/api/Breadcrumb'; +import { MinLogger } from '../../../src/api/MinLogger'; import { Recorder } from '../../../src/api/Recorder'; import XhrCollector from '../../../src/collectors/http/xhr'; @@ -137,3 +138,70 @@ it('applies URL filters to requests', () => { afterEach(() => { window.XMLHttpRequest = initialXhr; }); + +describe('given a XhrCollector with a URL filter that throws an error', () => { + let mockRecorder: Recorder; + let collector: XhrCollector; + let mockLogger: MinLogger; + beforeEach(() => { + mockLogger = { + warn: jest.fn(), + }; + mockRecorder = { + addBreadcrumb: jest.fn(), + captureError: jest.fn(), + captureErrorEvent: jest.fn(), + }; + collector = new XhrCollector({ + urlFilters: [ + () => { + throw new Error('test error'); + }, + ], + getLogger: () => mockLogger, + }); + }); + + it('logs an error if it fails to filter a breadcrumb', async () => { + collector.register(mockRecorder, 'test-session'); + + const xhr = new XMLHttpRequest(); + xhr.open('GET', 'https://api.example.com/data?token=secret123'); + xhr.send(); + + Object.defineProperty(xhr, 'status', { value: 200 }); + xhr.dispatchEvent(new Event('loadend')); + + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Error filtering http breadcrumb', + new Error('test error'), + ); + + expect(mockRecorder.addBreadcrumb).not.toHaveBeenCalled(); + }); + + it('only logs the filter error once for multiple requests', async () => { + collector.register(mockRecorder, 'test-session'); + + const xhr = new XMLHttpRequest(); + xhr.open('GET', 'https://api.example.com/data?token=secret123'); + xhr.send(); + + Object.defineProperty(xhr, 'status', { value: 200 }); + xhr.dispatchEvent(new Event('loadend')); + + const xhr2 = new XMLHttpRequest(); + xhr2.open('GET', 'https://api.example.com/data?token=secret123'); + xhr2.send(); + + Object.defineProperty(xhr2, 'status', { value: 200 }); + xhr2.dispatchEvent(new Event('loadend')); + + expect(mockLogger.warn).toHaveBeenCalledTimes(1); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Error filtering http breadcrumb', + new Error('test error'), + ); + expect(mockRecorder.addBreadcrumb).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/telemetry/browser-telemetry/__tests__/filters/defaultUrlFilter.test.ts b/packages/telemetry/browser-telemetry/__tests__/filters/defaultUrlFilter.test.ts index 9b4876d62f..e3ae2f2659 100644 --- a/packages/telemetry/browser-telemetry/__tests__/filters/defaultUrlFilter.test.ts +++ b/packages/telemetry/browser-telemetry/__tests__/filters/defaultUrlFilter.test.ts @@ -39,3 +39,35 @@ it.each([ ])('passes through other URLs unfiltered', (url) => { expect(defaultUrlFilter(url)).toBe(url); }); + +it('filters out username and password from URLs', () => { + const urls = [ + // Username only + { + input: 'https://user@sdk.launchdarkly.com/', + expected: 'https://redacted@sdk.launchdarkly.com/', + }, + // Password only + { + input: 'https://:password123@sdk.launchdarkly.com/', + expected: 'https://:redacted@sdk.launchdarkly.com/', + }, + // Both username and password + { + input: 'https://user:password123@sdk.launchdarkly.com/', + expected: 'https://redacted:redacted@sdk.launchdarkly.com/', + }, + ]; + + urls.forEach(({ input, expected }) => { + expect(defaultUrlFilter(input)).toBe(expected); + }); +}); + +it('can handle partial URLs', () => { + expect(defaultUrlFilter('/partial/url')).toBe('/partial/url'); +}); + +it('can handle invalid URLs', () => { + expect(defaultUrlFilter('invalid url')).toBe('invalid url'); +}); diff --git a/packages/telemetry/browser-telemetry/__tests__/logging.test.ts b/packages/telemetry/browser-telemetry/__tests__/logging.test.ts new file mode 100644 index 0000000000..452c821f92 --- /dev/null +++ b/packages/telemetry/browser-telemetry/__tests__/logging.test.ts @@ -0,0 +1,52 @@ +import { MinLogger } from '../src/api'; +import { fallbackLogger, prefixLog, safeMinLogger } from '../src/logging'; + +afterEach(() => { + jest.resetAllMocks(); +}); + +it('prefixes the message with the telemetry prefix', () => { + const message = 'test message'; + const prefixed = prefixLog(message); + expect(prefixed).toBe('LaunchDarkly - Browser Telemetry: test message'); +}); + +it('uses fallback logger when no logger provided', () => { + const spy = jest.spyOn(fallbackLogger, 'warn'); + const logger = safeMinLogger(undefined); + + logger.warn('test message'); + + expect(spy).toHaveBeenCalledWith('test message'); + spy.mockRestore(); +}); + +it('uses provided logger when it works correctly', () => { + const mockWarn = jest.fn(); + const testLogger: MinLogger = { + warn: mockWarn, + }; + + const logger = safeMinLogger(testLogger); + logger.warn('test message'); + + expect(mockWarn).toHaveBeenCalledWith('test message'); +}); + +it('falls back to fallback logger when provided logger throws', () => { + const spy = jest.spyOn(fallbackLogger, 'warn'); + const testLogger: MinLogger = { + warn: () => { + throw new Error('logger error'); + }, + }; + + const logger = safeMinLogger(testLogger); + logger.warn('test message'); + + expect(spy).toHaveBeenCalledWith('test message'); + expect(spy).toHaveBeenCalledWith( + 'LaunchDarkly - Browser Telemetry: The provided logger threw an exception, using fallback logger.', + ); + spy.mockRestore(); +}); diff --git a/packages/telemetry/browser-telemetry/__tests__/options.test.ts b/packages/telemetry/browser-telemetry/__tests__/options.test.ts index 1d5004ba08..b7ec98bc24 100644 --- a/packages/telemetry/browser-telemetry/__tests__/options.test.ts +++ b/packages/telemetry/browser-telemetry/__tests__/options.test.ts @@ -1,3 +1,5 @@ +import { Breadcrumb } from '../src/api/Breadcrumb'; +import { ErrorData } from '../src/api/ErrorData'; import ErrorCollector from '../src/collectors/error'; import parse, { defaultOptions } from '../src/options'; @@ -14,7 +16,30 @@ it('handles an empty configuration', () => { expect(outOptions).toEqual(defaultOptions()); }); +it('disables all breadcrumb options when breadcrumbs is false', () => { + const outOptions = parse({ + breadcrumbs: false, + }); + + expect(outOptions.breadcrumbs).toEqual({ + maxBreadcrumbs: 0, + click: false, + evaluations: false, + flagChange: false, + keyboardInput: false, + http: { + instrumentFetch: false, + instrumentXhr: false, + customUrlFilter: undefined, + }, + filters: [], + }); + expect(mockLogger.warn).not.toHaveBeenCalled(); +}); + it('can set all options at once', () => { + const breadcrumbFilter = (breadcrumb: Breadcrumb) => breadcrumb; + const errorFilter = (error: ErrorData) => error; const outOptions = parse({ maxPendingEvents: 1, breadcrumbs: { @@ -22,8 +47,10 @@ it('can set all options at once', () => { click: false, evaluations: false, flagChange: false, + filters: [breadcrumbFilter], }, collectors: [new ErrorCollector(), new ErrorCollector()], + errorFilters: [errorFilter], }); expect(outOptions).toEqual({ maxPendingEvents: 1, @@ -38,8 +65,10 @@ it('can set all options at once', () => { instrumentFetch: true, instrumentXhr: true, }, + filters: expect.arrayContaining([breadcrumbFilter]), }, stack: { + enabled: true, source: { beforeLines: 3, afterLines: 3, @@ -47,7 +76,9 @@ it('can set all options at once', () => { }, }, collectors: [new ErrorCollector(), new ErrorCollector()], + errorFilters: expect.arrayContaining([errorFilter]), }); + expect(mockLogger.warn).not.toHaveBeenCalled(); }); it('warns when maxPendingEvents is not a number', () => { @@ -61,7 +92,7 @@ it('warns when maxPendingEvents is not a number', () => { expect(outOptions.maxPendingEvents).toEqual(defaultOptions().maxPendingEvents); expect(mockLogger.warn).toHaveBeenCalledWith( - 'Config option "maxPendingEvents" should be of type number, got string, using default value', + 'LaunchDarkly - Browser Telemetry: Config option "maxPendingEvents" should be of type number, got string, using default value', ); }); @@ -88,7 +119,7 @@ it('warns when breadcrumbs config is not an object', () => { expect(outOptions.breadcrumbs).toEqual(defaultOptions().breadcrumbs); expect(mockLogger.warn).toHaveBeenCalledWith( - 'Config option "breadcrumbs" should be of type object, got string, using default value', + 'LaunchDarkly - Browser Telemetry: Config option "breadcrumbs" should be of type object, got string, using default value', ); }); @@ -103,7 +134,7 @@ it('warns when collectors is not an array', () => { expect(outOptions.collectors).toEqual(defaultOptions().collectors); expect(mockLogger.warn).toHaveBeenCalledWith( - 'Config option "collectors" should be of type Collector[], got string, using default value', + 'LaunchDarkly - Browser Telemetry: Config option "collectors" should be of type Collector[], got string, using default value', ); }); @@ -131,7 +162,7 @@ it('warns when stack config is not an object', () => { expect(outOptions.stack).toEqual(defaultOptions().stack); expect(mockLogger.warn).toHaveBeenCalledWith( - 'Config option "stack" should be of type object, got string, using default value', + 'LaunchDarkly - Browser Telemetry: Config option "stack" should be of type object, got string, using default value', ); }); @@ -150,7 +181,7 @@ it('warns when breadcrumbs.maxBreadcrumbs is not a number', () => { defaultOptions().breadcrumbs.maxBreadcrumbs, ); expect(mockLogger.warn).toHaveBeenCalledWith( - 'Config option "breadcrumbs.maxBreadcrumbs" should be of type number, got string, using default value', + 'LaunchDarkly - Browser Telemetry: Config option "breadcrumbs.maxBreadcrumbs" should be of type number, got string, using default value', ); }); @@ -181,7 +212,7 @@ it('warns when breadcrumbs.click is not boolean', () => { expect(outOptions.breadcrumbs.click).toEqual(defaultOptions().breadcrumbs.click); expect(mockLogger.warn).toHaveBeenCalledWith( - 'Config option "breadcrumbs.click" should be of type boolean, got string, using default value', + 'LaunchDarkly - Browser Telemetry: Config option "breadcrumbs.click" should be of type boolean, got string, using default value', ); }); @@ -198,7 +229,7 @@ it('warns when breadcrumbs.evaluations is not boolean', () => { expect(outOptions.breadcrumbs.evaluations).toEqual(defaultOptions().breadcrumbs.evaluations); expect(mockLogger.warn).toHaveBeenCalledWith( - 'Config option "breadcrumbs.evaluations" should be of type boolean, got string, using default value', + 'LaunchDarkly - Browser Telemetry: Config option "breadcrumbs.evaluations" should be of type boolean, got string, using default value', ); }); @@ -215,7 +246,7 @@ it('warns when breadcrumbs.flagChange is not boolean', () => { expect(outOptions.breadcrumbs.flagChange).toEqual(defaultOptions().breadcrumbs.flagChange); expect(mockLogger.warn).toHaveBeenCalledWith( - 'Config option "breadcrumbs.flagChange" should be of type boolean, got string, using default value', + 'LaunchDarkly - Browser Telemetry: Config option "breadcrumbs.flagChange" should be of type boolean, got string, using default value', ); }); @@ -232,7 +263,7 @@ it('warns when breadcrumbs.keyboardInput is not boolean', () => { expect(outOptions.breadcrumbs.keyboardInput).toEqual(defaultOptions().breadcrumbs.keyboardInput); expect(mockLogger.warn).toHaveBeenCalledWith( - 'Config option "breadcrumbs.keyboardInput" should be of type boolean, got string, using default value', + 'LaunchDarkly - Browser Telemetry: Config option "breadcrumbs.keyboardInput" should be of type boolean, got string, using default value', ); }); @@ -305,7 +336,7 @@ it('warns when breadcrumbs.http is not an object', () => { expect(outOptions.breadcrumbs.http).toEqual(defaultOptions().breadcrumbs.http); expect(mockLogger.warn).toHaveBeenCalledWith( - 'Config option "breadcrumbs.http" should be of type HttpBreadCrumbOptions | false, got string, using default value', + 'LaunchDarkly - Browser Telemetry: Config option "breadcrumbs.http" should be of type HttpBreadCrumbOptions | false, got string, using default value', ); }); @@ -326,7 +357,7 @@ it('warns when breadcrumbs.http.instrumentFetch is not boolean', () => { defaultOptions().breadcrumbs.http.instrumentFetch, ); expect(mockLogger.warn).toHaveBeenCalledWith( - 'Config option "breadcrumbs.http.instrumentFetch" should be of type boolean, got string, using default value', + 'LaunchDarkly - Browser Telemetry: Config option "breadcrumbs.http.instrumentFetch" should be of type boolean, got string, using default value', ); }); @@ -347,7 +378,7 @@ it('warns when breadcrumbs.http.instrumentXhr is not boolean', () => { defaultOptions().breadcrumbs.http.instrumentXhr, ); expect(mockLogger.warn).toHaveBeenCalledWith( - 'Config option "breadcrumbs.http.instrumentXhr" should be of type boolean, got string, using default value', + 'LaunchDarkly - Browser Telemetry: Config option "breadcrumbs.http.instrumentXhr" should be of type boolean, got string, using default value', ); }); @@ -417,6 +448,66 @@ it('warns when breadcrumbs.http.customUrlFilter is not a function', () => { expect(outOptions.breadcrumbs.http.customUrlFilter).toBeUndefined(); expect(mockLogger.warn).toHaveBeenCalledWith( - 'The "breadcrumbs.http.customUrlFilter" must be a function. Received string', + 'LaunchDarkly - Browser Telemetry: The "breadcrumbs.http.customUrlFilter" must be a function. Received string', ); }); + +it('warns when filters is not an array', () => { + const outOptions = parse( + { + breadcrumbs: { + // @ts-ignore + filters: 'not an array', + }, + }, + mockLogger, + ); + expect(outOptions.breadcrumbs.filters).toEqual([]); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'LaunchDarkly - Browser Telemetry: Config option "breadcrumbs.filters" should be of type BreadcrumbFilter[], got string, using default value', + ); +}); + +it('warns when errorFilters is not an array', () => { + const outOptions = parse( + { + // @ts-ignore + errorFilters: 'not an array', + }, + mockLogger, + ); + + expect(outOptions.errorFilters).toEqual([]); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'LaunchDarkly - Browser Telemetry: Config option "errorFilters" should be of type ErrorDataFilter[], got string, using default value', + ); +}); + +it('accepts valid error filters array', () => { + const errorFilters = [(error: any) => error]; + const outOptions = parse( + { + errorFilters, + }, + mockLogger, + ); + + expect(outOptions.errorFilters).toEqual(errorFilters); + expect(mockLogger.warn).not.toHaveBeenCalled(); +}); + +it('disables all stack options when stack is false', () => { + const outOptions = parse({ + stack: false, + }); + + expect(outOptions.stack).toEqual({ + enabled: false, + source: { + beforeLines: 0, + afterLines: 0, + maxLineLength: 0, + }, + }); + expect(mockLogger.warn).not.toHaveBeenCalled(); +}); diff --git a/packages/telemetry/browser-telemetry/__tests__/singleton/singletonInstance.test.ts b/packages/telemetry/browser-telemetry/__tests__/singleton/singletonInstance.test.ts new file mode 100644 index 0000000000..01fb77104a --- /dev/null +++ b/packages/telemetry/browser-telemetry/__tests__/singleton/singletonInstance.test.ts @@ -0,0 +1,35 @@ +import { fallbackLogger } from '../../src/logging'; +import { getTelemetryInstance, initTelemetry, resetTelemetryInstance } from '../../src/singleton'; + +beforeEach(() => { + resetTelemetryInstance(); + jest.resetAllMocks(); +}); + +it('warns and keeps existing instance when initialized multiple times', () => { + const mockLogger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; + + initTelemetry({ logger: mockLogger }); + const instanceA = getTelemetryInstance(); + initTelemetry({ logger: mockLogger }); + const instanceB = getTelemetryInstance(); + + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringMatching(/Telemetry has already been initialized/), + ); + + expect(instanceA).toBe(instanceB); +}); + +it('warns when getting telemetry instance before initialization', () => { + const spy = jest.spyOn(fallbackLogger, 'warn'); + + getTelemetryInstance(); + + expect(spy).toHaveBeenCalledWith(expect.stringMatching(/Telemetry has not been initialized/)); +}); diff --git a/packages/telemetry/browser-telemetry/__tests__/singleton/singletonMethods.test.ts b/packages/telemetry/browser-telemetry/__tests__/singleton/singletonMethods.test.ts new file mode 100644 index 0000000000..0a41e5a5c0 --- /dev/null +++ b/packages/telemetry/browser-telemetry/__tests__/singleton/singletonMethods.test.ts @@ -0,0 +1,149 @@ +import { Breadcrumb, LDClientTracking } from '../../src/api'; +import { BrowserTelemetry } from '../../src/api/BrowserTelemetry'; +import { BrowserTelemetryInspector } from '../../src/api/client/BrowserTelemetryInspector'; +import { getTelemetryInstance } from '../../src/singleton/singletonInstance'; +import { + addBreadcrumb, + captureError, + captureErrorEvent, + close, + inspectors, + register, +} from '../../src/singleton/singletonMethods'; + +jest.mock('../../src/singleton/singletonInstance'); + +const mockTelemetry: jest.Mocked = { + inspectors: jest.fn(), + captureError: jest.fn(), + captureErrorEvent: jest.fn(), + addBreadcrumb: jest.fn(), + register: jest.fn(), + close: jest.fn(), +}; + +const mockGetTelemetryInstance = getTelemetryInstance as jest.Mock; + +beforeEach(() => { + jest.resetAllMocks(); +}); + +it('returns empty array when telemetry is not initialized for inspectors', () => { + mockGetTelemetryInstance.mockReturnValue(undefined); + expect(() => inspectors()).not.toThrow(); + expect(inspectors()).toEqual([]); +}); + +it('returns inspectors when telemetry is initialized', () => { + const mockInspectors: BrowserTelemetryInspector[] = [ + { name: 'test-inspector', type: 'flag-used', synchronous: true, method: () => {} }, + ]; + mockGetTelemetryInstance.mockReturnValue(mockTelemetry); + mockTelemetry.inspectors.mockReturnValue(mockInspectors); + + expect(inspectors()).toBe(mockInspectors); +}); + +it('does not crash when calling captureError with no telemetry instance', () => { + mockGetTelemetryInstance.mockReturnValue(undefined); + const error = new Error('test error'); + + expect(() => captureError(error)).not.toThrow(); + + expect(mockTelemetry.captureError).not.toHaveBeenCalled(); +}); + +it('captures errors when telemetry is initialized', () => { + mockGetTelemetryInstance.mockReturnValue(mockTelemetry); + const error = new Error('test error'); + + captureError(error); + + expect(mockTelemetry.captureError).toHaveBeenCalledWith(error); +}); + +it('it does not crash when calling captureErrorEvent with no telemetry instance', () => { + mockGetTelemetryInstance.mockReturnValue(undefined); + const errorEvent = new ErrorEvent('error', { error: new Error('test error') }); + + expect(() => captureErrorEvent(errorEvent)).not.toThrow(); + + expect(mockTelemetry.captureErrorEvent).not.toHaveBeenCalled(); +}); + +it('captures error event when telemetry is initialized', () => { + mockGetTelemetryInstance.mockReturnValue(mockTelemetry); + const errorEvent = new ErrorEvent('error', { error: new Error('test error') }); + + captureErrorEvent(errorEvent); + + expect(mockTelemetry.captureErrorEvent).toHaveBeenCalledWith(errorEvent); +}); + +it('does not crash when calling addBreadcrumb with no telemetry instance', () => { + mockGetTelemetryInstance.mockReturnValue(undefined); + const breadcrumb: Breadcrumb = { + type: 'custom', + data: { test: 'data' }, + timestamp: Date.now(), + class: 'custom', + level: 'info', + }; + + expect(() => addBreadcrumb(breadcrumb)).not.toThrow(); + + expect(mockTelemetry.addBreadcrumb).not.toHaveBeenCalled(); +}); + +it('adds breadcrumb when telemetry is initialized', () => { + mockGetTelemetryInstance.mockReturnValue(mockTelemetry); + const breadcrumb: Breadcrumb = { + type: 'custom', + data: { test: 'data' }, + timestamp: Date.now(), + class: 'custom', + level: 'info', + }; + + addBreadcrumb(breadcrumb); + + expect(mockTelemetry.addBreadcrumb).toHaveBeenCalledWith(breadcrumb); +}); + +it('does not crash when calling register with no telemetry instance', () => { + mockGetTelemetryInstance.mockReturnValue(undefined); + const mockClient: jest.Mocked = { + track: jest.fn(), + }; + + expect(() => register(mockClient)).not.toThrow(); + + expect(mockTelemetry.register).not.toHaveBeenCalled(); +}); + +it('registers client when telemetry is initialized', () => { + mockGetTelemetryInstance.mockReturnValue(mockTelemetry); + const mockClient: jest.Mocked = { + track: jest.fn(), + }; + + register(mockClient); + + expect(mockTelemetry.register).toHaveBeenCalledWith(mockClient); +}); + +it('does not crash when calling close with no telemetry instance', () => { + mockGetTelemetryInstance.mockReturnValue(undefined); + + expect(() => close()).not.toThrow(); + + expect(mockTelemetry.close).not.toHaveBeenCalled(); +}); + +it('closes when telemetry is initialized', () => { + mockGetTelemetryInstance.mockReturnValue(mockTelemetry); + + close(); + + expect(mockTelemetry.close).toHaveBeenCalled(); +}); diff --git a/packages/telemetry/browser-telemetry/__tests__/stack/StackParser.test.ts b/packages/telemetry/browser-telemetry/__tests__/stack/StackParser.test.ts index 0fdcbd445a..183eb93c45 100644 --- a/packages/telemetry/browser-telemetry/__tests__/stack/StackParser.test.ts +++ b/packages/telemetry/browser-telemetry/__tests__/stack/StackParser.test.ts @@ -1,4 +1,4 @@ -import { +import parse, { getLines, getSrcLines, processUrlToFileName, @@ -52,12 +52,15 @@ describe('given source lines', () => { describe('given an input stack frame', () => { const inputFrame = { context: ['1234567890', 'ABCDEFGHIJ', 'the src line', '0987654321', 'abcdefghij'], + line: 3, + srcStart: 1, column: 0, }; it('can produce a full stack source in the output frame', () => { expect( getSrcLines(inputFrame, { + enabled: true, source: { beforeLines: 2, afterLines: 2, @@ -74,6 +77,7 @@ describe('given an input stack frame', () => { it('can trim all the lines', () => { expect( getSrcLines(inputFrame, { + enabled: true, source: { beforeLines: 2, afterLines: 2, @@ -90,6 +94,7 @@ describe('given an input stack frame', () => { it('can handle fewer input lines than the expected context', () => { expect( getSrcLines(inputFrame, { + enabled: true, source: { beforeLines: 3, afterLines: 3, @@ -106,6 +111,7 @@ describe('given an input stack frame', () => { it('can handle more input lines than the expected context', () => { expect( getSrcLines(inputFrame, { + enabled: true, source: { beforeLines: 1, afterLines: 1, @@ -119,3 +125,66 @@ describe('given an input stack frame', () => { }); }); }); + +it('can handle an origin before the context window', () => { + // This isn't expected, but we just want to make sure it is handle gracefully. + const inputFrame = { + context: ['1234567890', 'ABCDEFGHIJ', 'the src line', '0987654321', 'abcdefghij'], + line: 3, + srcStart: 5, + column: 0, + }; + + expect( + getSrcLines(inputFrame, { + enabled: true, + source: { + beforeLines: 1, + afterLines: 1, + maxLineLength: 280, + }, + }), + ).toMatchObject({ + srcBefore: [], + srcLine: '1234567890', + srcAfter: ['ABCDEFGHIJ'], + }); +}); + +it('can handle an origin after the context window', () => { + // This isn't expected, but we just want to make sure it is handle gracefully. + const inputFrame = { + context: ['1234567890', 'ABCDEFGHIJ', 'the src line', '0987654321', 'abcdefghij'], + line: 100, + srcStart: 5, + column: 0, + }; + + expect( + getSrcLines(inputFrame, { + enabled: true, + source: { + beforeLines: 1, + afterLines: 1, + maxLineLength: 280, + }, + }), + ).toMatchObject({ + srcBefore: ['0987654321'], + srcLine: 'abcdefghij', + srcAfter: [], + }); +}); + +it('returns an empty stack when stack parsing is disabled', () => { + expect( + parse(new Error('test'), { + enabled: false, + source: { + beforeLines: 1, + afterLines: 1, + maxLineLength: 280, + }, + }), + ).toEqual({ frames: [] }); +}); diff --git a/packages/telemetry/browser-telemetry/__tests__/vendor/TraceKit/CapturedExceptions.ts b/packages/telemetry/browser-telemetry/__tests__/vendor/TraceKit/CapturedExceptions.ts new file mode 100644 index 0000000000..cb628dbe9b --- /dev/null +++ b/packages/telemetry/browser-telemetry/__tests__/vendor/TraceKit/CapturedExceptions.ts @@ -0,0 +1,480 @@ +/** + * https://github.com/csnover/TraceKit + * @license MIT + * @namespace TraceKit + */ + +export const OPERA_854 = { + message: + 'Statement on line 44: Type mismatch (usually a non-object value used where an object is required)\n' + + 'Backtrace:\n' + + ' Line 44 of linked script http://path/to/file.js\n' + + ' this.undef();\n' + + ' Line 31 of linked script http://path/to/file.js\n' + + ' ex = ex || this.createException();\n' + + ' Line 18 of linked script http://path/to/file.js\n' + + ' var p = new printStackTrace.implementation(), result = p.run(ex);\n' + + ' Line 4 of inline#1 script in http://path/to/file.js\n' + + ' printTrace(printStackTrace());\n' + + ' Line 7 of inline#1 script in http://path/to/file.js\n' + + ' bar(n - 1);\n' + + ' Line 11 of inline#1 script in http://path/to/file.js\n' + + ' bar(2);\n' + + ' Line 15 of inline#1 script in http://path/to/file.js\n' + + ' foo();\n' + + '', + 'opera#sourceloc': 44, +}; + +export const OPERA_902 = { + message: + 'Statement on line 44: Type mismatch (usually a non-object value used where an object is required)\n' + + 'Backtrace:\n' + + ' Line 44 of linked script http://path/to/file.js\n' + + ' this.undef();\n' + + ' Line 31 of linked script http://path/to/file.js\n' + + ' ex = ex || this.createException();\n' + + ' Line 18 of linked script http://path/to/file.js\n' + + ' var p = new printStackTrace.implementation(), result = p.run(ex);\n' + + ' Line 4 of inline#1 script in http://path/to/file.js\n' + + ' printTrace(printStackTrace());\n' + + ' Line 7 of inline#1 script in http://path/to/file.js\n' + + ' bar(n - 1);\n' + + ' Line 11 of inline#1 script in http://path/to/file.js\n' + + ' bar(2);\n' + + ' Line 15 of inline#1 script in http://path/to/file.js\n' + + ' foo();\n' + + '', + 'opera#sourceloc': 44, +}; + +export const OPERA_927 = { + message: + 'Statement on line 43: Type mismatch (usually a non-object value used where an object is required)\n' + + 'Backtrace:\n' + + ' Line 43 of linked script http://path/to/file.js\n' + + ' bar(n - 1);\n' + + ' Line 31 of linked script http://path/to/file.js\n' + + ' bar(2);\n' + + ' Line 18 of linked script http://path/to/file.js\n' + + ' foo();\n' + + '', + 'opera#sourceloc': 43, +}; + +export const OPERA_964 = { + message: + 'Statement on line 42: Type mismatch (usually non-object value supplied where object required)\n' + + 'Backtrace:\n' + + ' Line 42 of linked script http://path/to/file.js\n' + + ' this.undef();\n' + + ' Line 27 of linked script http://path/to/file.js\n' + + ' ex = ex || this.createException();\n' + + ' Line 18 of linked script http://path/to/file.js: In function printStackTrace\n' + + ' var p = new printStackTrace.implementation(), result = p.run(ex);\n' + + ' Line 4 of inline#1 script in http://path/to/file.js: In function bar\n' + + ' printTrace(printStackTrace());\n' + + ' Line 7 of inline#1 script in http://path/to/file.js: In function bar\n' + + ' bar(n - 1);\n' + + ' Line 11 of inline#1 script in http://path/to/file.js: In function foo\n' + + ' bar(2);\n' + + ' Line 15 of inline#1 script in http://path/to/file.js\n' + + ' foo();\n' + + '', + 'opera#sourceloc': 42, + stacktrace: + ' ... Line 27 of linked script http://path/to/file.js\n' + + ' ex = ex || this.createException();\n' + + ' Line 18 of linked script http://path/to/file.js: In function printStackTrace\n' + + ' var p = new printStackTrace.implementation(), result = p.run(ex);\n' + + ' Line 4 of inline#1 script in http://path/to/file.js: In function bar\n' + + ' printTrace(printStackTrace());\n' + + ' Line 7 of inline#1 script in http://path/to/file.js: In function bar\n' + + ' bar(n - 1);\n' + + ' Line 11 of inline#1 script in http://path/to/file.js: In function foo\n' + + ' bar(2);\n' + + ' Line 15 of inline#1 script in http://path/to/file.js\n' + + ' foo();\n' + + '', +}; + +export const OPERA_10 = { + message: + 'Statement on line 42: Type mismatch (usually non-object value supplied where object required)', + 'opera#sourceloc': 42, + stacktrace: + ' Line 42 of linked script http://path/to/file.js\n' + + ' this.undef();\n' + + ' Line 27 of linked script http://path/to/file.js\n' + + ' ex = ex || this.createException();\n' + + ' Line 18 of linked script http://path/to/file.js: In function printStackTrace\n' + + ' var p = new printStackTrace.implementation(), result = p.run(ex);\n' + + ' Line 4 of inline#1 script in http://path/to/file.js: In function bar\n' + + ' printTrace(printStackTrace());\n' + + ' Line 7 of inline#1 script in http://path/to/file.js: In function bar\n' + + ' bar(n - 1);\n' + + ' Line 11 of inline#1 script in http://path/to/file.js: In function foo\n' + + ' bar(2);\n' + + ' Line 15 of inline#1 script in http://path/to/file.js\n' + + ' foo();\n' + + '', +}; + +export const OPERA_11 = { + message: "'this.undef' is not a function", + stack: + '([arguments not available])@http://path/to/file.js:27\n' + + 'bar([arguments not available])@http://domain.com:1234/path/to/file.js:18\n' + + 'foo([arguments not available])@http://domain.com:1234/path/to/file.js:11\n' + + '@http://path/to/file.js:15\n' + + 'Error created at @http://path/to/file.js:15', + stacktrace: + 'Error thrown at line 42, column 12 in () in http://path/to/file.js:\n' + + ' this.undef();\n' + + 'called from line 27, column 8 in (ex) in http://path/to/file.js:\n' + + ' ex = ex || this.createException();\n' + + 'called from line 18, column 4 in printStackTrace(options) in http://path/to/file.js:\n' + + ' var p = new printStackTrace.implementation(), result = p.run(ex);\n' + + 'called from line 4, column 5 in bar(n) in http://path/to/file.js:\n' + + ' printTrace(printStackTrace());\n' + + 'called from line 7, column 4 in bar(n) in http://path/to/file.js:\n' + + ' bar(n - 1);\n' + + 'called from line 11, column 4 in foo() in http://path/to/file.js:\n' + + ' bar(2);\n' + + 'called from line 15, column 3 in http://path/to/file.js:\n' + + ' foo();', +}; + +export const OPERA_12 = { + message: "Cannot convert 'x' to object", + stack: + '([arguments not available])@http://localhost:8000/ExceptionLab.html:48\n' + + 'dumpException3([arguments not available])@http://localhost:8000/ExceptionLab.html:46\n' + + '([arguments not available])@http://localhost:8000/ExceptionLab.html:1', + stacktrace: + 'Error thrown at line 48, column 12 in (x) in http://localhost:8000/ExceptionLab.html:\n' + + ' x.undef();\n' + + 'called from line 46, column 8 in dumpException3() in http://localhost:8000/ExceptionLab.html:\n' + + ' dumpException((function(x) {\n' + + 'called from line 1, column 0 in (event) in http://localhost:8000/ExceptionLab.html:\n' + + ' dumpException3();', +}; + +export const OPERA_25 = { + message: "Cannot read property 'undef' of null", + name: 'TypeError', + stack: + "TypeError: Cannot read property 'undef' of null\n" + + ' at http://path/to/file.js:47:22\n' + + ' at foo (http://path/to/file.js:52:15)\n' + + ' at bar (http://path/to/file.js:108:168)', +}; + +export const CHROME_15 = { + arguments: ['undef'], + message: "Object # has no method 'undef'", + stack: + "TypeError: Object # has no method 'undef'\n" + + ' at bar (http://path/to/file.js:13:17)\n' + + ' at bar (http://path/to/file.js:16:5)\n' + + ' at foo (http://path/to/file.js:20:5)\n' + + ' at http://path/to/file.js:24:4', +}; + +export const CHROME_36 = { + message: 'Default error', + name: 'Error', + stack: + 'Error: Default error\n' + + ' at dumpExceptionError (http://localhost:8080/file.js:41:27)\n' + + ' at HTMLButtonElement.onclick (http://localhost:8080/file.js:107:146)\n' + + ' at I.e.fn.(anonymous function) [as index] (http://localhost:8080/file.js:10:3651)', +}; + +// can be generated when Webpack is built with { devtool: eval } +export const CHROME_XX_WEBPACK = { + message: "Cannot read property 'error' of undefined", + name: 'TypeError', + stack: + "TypeError: Cannot read property 'error' of undefined\n" + + ' at TESTTESTTEST.eval(webpack:///./src/components/test/test.jsx?:295:108)\n' + + ' at TESTTESTTEST.render(webpack:///./src/components/test/test.jsx?:272:32)\n' + + ' at TESTTESTTEST.tryRender(webpack:///./~/react-transform-catch-errors/lib/index.js?:34:31)\n' + + ' at TESTTESTTEST.proxiedMethod(webpack:///./~/react-proxy/modules/createPrototypeProxy.js?:44:30)', +}; + +export const FIREFOX_3 = { + fileName: 'http://127.0.0.1:8000/js/stacktrace.js', + lineNumber: 44, + message: 'this.undef is not a function', + name: 'TypeError', + stack: + '()@http://127.0.0.1:8000/js/stacktrace.js:44\n' + + '(null)@http://127.0.0.1:8000/js/stacktrace.js:31\n' + + 'printStackTrace()@http://127.0.0.1:8000/js/stacktrace.js:18\n' + + 'bar(1)@http://127.0.0.1:8000/js/file.js:13\n' + + 'bar(2)@http://127.0.0.1:8000/js/file.js:16\n' + + 'foo()@http://127.0.0.1:8000/js/file.js:20\n' + + '@http://127.0.0.1:8000/js/file.js:24\n' + + '', +}; + +export const FIREFOX_7 = { + fileName: 'file:///G:/js/stacktrace.js', + lineNumber: 44, + stack: + '()@file:///G:/js/stacktrace.js:44\n' + + '(null)@file:///G:/js/stacktrace.js:31\n' + + 'printStackTrace()@file:///G:/js/stacktrace.js:18\n' + + 'bar(1)@file:///G:/js/file.js:13\n' + + 'bar(2)@file:///G:/js/file.js:16\n' + + 'foo()@file:///G:/js/file.js:20\n' + + '@file:///G:/js/file.js:24\n' + + '', +}; + +export const FIREFOX_14 = { + message: 'x is null', + stack: + '@http://path/to/file.js:48\n' + + 'dumpException3@http://path/to/file.js:52\n' + + 'onclick@http://path/to/file.js:1\n' + + '', + fileName: 'http://path/to/file.js', + lineNumber: 48, +}; + +export const FIREFOX_31 = { + message: 'Default error', + name: 'Error', + stack: + 'foo@http://path/to/file.js:41:13\n' + + 'bar@http://path/to/file.js:1:1\n' + + '.plugin/e.fn[c]/<@http://path/to/file.js:1:1\n' + + '', + fileName: 'http://path/to/file.js', + lineNumber: 41, + columnNumber: 12, +}; + +export const FIREFOX_43_EVAL = { + columnNumber: 30, + fileName: 'http://localhost:8080/file.js line 25 > eval line 2 > eval', + lineNumber: 1, + message: 'message string', + stack: + 'baz@http://localhost:8080/file.js line 26 > eval line 2 > eval:1:30\n' + + 'foo@http://localhost:8080/file.js line 26 > eval:2:96\n' + + '@http://localhost:8080/file.js line 26 > eval:4:18\n' + + 'speak@http://localhost:8080/file.js:26:17\n' + + '@http://localhost:8080/file.js:33:9', +}; + +// Internal errors sometimes thrown by Firefox +// More here: https://developer.mozilla.org/en-US/docs/Mozilla/Errors +// +// Note that such errors are instanceof "Exception", not "Error" +export const FIREFOX_44_NS_EXCEPTION = { + message: '', + name: 'NS_ERROR_FAILURE', + stack: + '[2] tag + '', + fileName: 'http://path/to/file.js', + columnNumber: 0, + lineNumber: 703, + result: 2147500037, +}; + +export const FIREFOX_50_RESOURCE_URL = { + stack: + 'render@resource://path/data/content/bundle.js:5529:16\n' + + 'dispatchEvent@resource://path/data/content/vendor.bundle.js:18:23028\n' + + 'wrapped@resource://path/data/content/bundle.js:7270:25', + fileName: 'resource://path/data/content/bundle.js', + lineNumber: 5529, + columnNumber: 16, + message: 'this.props.raw[this.state.dataSource].rows is undefined', + name: 'TypeError', +}; + +export const SAFARI_6 = { + message: "'null' is not an object (evaluating 'x.undef')", + stack: + '@http://path/to/file.js:48\n' + + 'dumpException3@http://path/to/file.js:52\n' + + 'onclick@http://path/to/file.js:82\n' + + '[native code]', + line: 48, + sourceURL: 'http://path/to/file.js', +}; + +export const SAFARI_7 = { + message: "'null' is not an object (evaluating 'x.undef')", + name: 'TypeError', + stack: + 'http://path/to/file.js:48:22\n' + + 'foo@http://path/to/file.js:52:15\n' + + 'bar@http://path/to/file.js:108:107', + line: 47, + sourceURL: 'http://path/to/file.js', +}; + +export const SAFARI_8 = { + message: "null is not an object (evaluating 'x.undef')", + name: 'TypeError', + stack: + 'http://path/to/file.js:47:22\n' + + 'foo@http://path/to/file.js:52:15\n' + + 'bar@http://path/to/file.js:108:23', + line: 47, + column: 22, + sourceURL: 'http://path/to/file.js', +}; + +export const SAFARI_8_EVAL = { + message: "Can't find variable: getExceptionProps", + name: 'ReferenceError', + stack: + 'eval code\n' + + 'eval@[native code]\n' + + 'foo@http://path/to/file.js:58:21\n' + + 'bar@http://path/to/file.js:109:91', + line: 1, + column: 18, +}; + +export const IE_9 = { + message: "Unable to get property 'undef' of undefined or null reference", + description: "Unable to get property 'undef' of undefined or null reference", +}; + +export const IE_10 = { + message: "Unable to get property 'undef' of undefined or null reference", + stack: + "TypeError: Unable to get property 'undef' of undefined or null reference\n" + + ' at Anonymous function (http://path/to/file.js:48:13)\n' + + ' at foo (http://path/to/file.js:46:9)\n' + + ' at bar (http://path/to/file.js:82:1)', + description: "Unable to get property 'undef' of undefined or null reference", + number: -2146823281, +}; + +export const IE_11 = { + message: "Unable to get property 'undef' of undefined or null reference", + name: 'TypeError', + stack: + "TypeError: Unable to get property 'undef' of undefined or null reference\n" + + ' at Anonymous function (http://path/to/file.js:47:21)\n' + + ' at foo (http://path/to/file.js:45:13)\n' + + ' at bar (http://path/to/file.js:108:1)', + description: "Unable to get property 'undef' of undefined or null reference", + number: -2146823281, +}; + +export const IE_11_EVAL = { + message: "'getExceptionProps' is undefined", + name: 'ReferenceError', + stack: + "ReferenceError: 'getExceptionProps' is undefined\n" + + ' at eval code (eval code:1:1)\n' + + ' at foo (http://path/to/file.js:58:17)\n' + + ' at bar (http://path/to/file.js:109:1)', + description: "'getExceptionProps' is undefined", + number: -2146823279, +}; + +export const CHROME_48_BLOB = { + message: 'Error: test', + name: 'Error', + stack: + 'Error: test\n' + + ' at Error (native)\n' + + ' at s (blob:http%3A//localhost%3A8080/abfc40e9-4742-44ed-9dcd-af8f99a29379:31:29146)\n' + + ' at Object.d [as add] (blob:http%3A//localhost%3A8080/abfc40e9-4742-44ed-9dcd-af8f99a29379:31:30039)\n' + + ' at blob:http%3A//localhost%3A8080/d4eefe0f-361a-4682-b217-76587d9f712a:15:10978\n' + + ' at blob:http%3A//localhost%3A8080/abfc40e9-4742-44ed-9dcd-af8f99a29379:1:6911\n' + + ' at n.fire (blob:http%3A//localhost%3A8080/abfc40e9-4742-44ed-9dcd-af8f99a29379:7:3019)\n' + + ' at n.handle (blob:http%3A//localhost%3A8080/abfc40e9-4742-44ed-9dcd-af8f99a29379:7:2863)', +}; + +export const CHROME_48_EVAL = { + message: 'message string', + name: 'Error', + stack: + 'Error: message string\n' + + 'at baz (eval at foo (eval at speak (http://localhost:8080/file.js:21:17)), :1:30)\n' + + 'at foo (eval at speak (http://localhost:8080/file.js:21:17), :2:96)\n' + + 'at eval (eval at speak (http://localhost:8080/file.js:21:17), :4:18)\n' + + 'at Object.speak (http://localhost:8080/file.js:21:17)\n' + + 'at http://localhost:8080/file.js:31:13\n', +}; + +export const PHANTOMJS_1_19 = { + stack: + 'Error: foo\n' + + ' at file:///path/to/file.js:878\n' + + ' at foo (http://path/to/file.js:4283)\n' + + ' at http://path/to/file.js:4287', +}; + +export const ANDROID_REACT_NATIVE = { + message: 'Error: test', + name: 'Error', + stack: + 'Error: test\n' + + 'at render(/home/username/sample-workspace/sampleapp.collect.react/src/components/GpsMonitorScene.js:78:24)\n' + + 'at _renderValidatedComponentWithoutOwnerOrContext(/home/username/sample-workspace/sampleapp.collect.react/node_modules/react-native/Libraries/Renderer/src/renderers/shared/stack/reconciler/ReactCompositeComponent.js:1050:29)\n' + + 'at _renderValidatedComponent(/home/username/sample-workspace/sampleapp.collect.react/node_modules/react-native/Libraries/Renderer/src/renderers/shared/stack/reconciler/ReactCompositeComponent.js:1075:15)\n' + + 'at renderedElement(/home/username/sample-workspace/sampleapp.collect.react/node_modules/react-native/Libraries/Renderer/src/renderers/shared/stack/reconciler/ReactCompositeComponent.js:484:29)\n' + + 'at _currentElement(/home/username/sample-workspace/sampleapp.collect.react/node_modules/react-native/Libraries/Renderer/src/renderers/shared/stack/reconciler/ReactCompositeComponent.js:346:40)\n' + + 'at child(/home/username/sample-workspace/sampleapp.collect.react/node_modules/react-native/Libraries/Renderer/src/renderers/shared/stack/reconciler/ReactReconciler.js:68:25)\n' + + 'at children(/home/username/sample-workspace/sampleapp.collect.react/node_modules/react-native/Libraries/Renderer/src/renderers/shared/stack/reconciler/ReactMultiChild.js:264:10)\n' + + 'at this(/home/username/sample-workspace/sampleapp.collect.react/node_modules/react-native/Libraries/Renderer/src/renderers/native/ReactNativeBaseComponent.js:74:41)\n', +}; + +export const ANDROID_REACT_NATIVE_PROD = { + message: 'Error: test', + name: 'Error', + stack: + 'value@index.android.bundle:12:1917\n' + + 'onPress@index.android.bundle:12:2336\n' + + 'touchableHandlePress@index.android.bundle:258:1497\n' + + '[native code]\n' + + '_performSideEffectsForTransition@index.android.bundle:252:8508\n' + + '[native code]\n' + + '_receiveSignal@index.android.bundle:252:7291\n' + + '[native code]\n' + + 'touchableHandleResponderRelease@index.android.bundle:252:4735\n' + + '[native code]\n' + + 'u@index.android.bundle:79:142\n' + + 'invokeGuardedCallback@index.android.bundle:79:459\n' + + 'invokeGuardedCallbackAndCatchFirstError@index.android.bundle:79:580\n' + + 'c@index.android.bundle:95:365\n' + + 'a@index.android.bundle:95:567\n' + + 'v@index.android.bundle:146:501\n' + + 'g@index.android.bundle:146:604\n' + + 'forEach@[native code]\n' + + 'i@index.android.bundle:149:80\n' + + 'processEventQueue@index.android.bundle:146:1432\n' + + 's@index.android.bundle:157:88\n' + + 'handleTopLevel@index.android.bundle:157:174\n' + + 'index.android.bundle:156:572\n' + + 'a@index.android.bundle:93:276\n' + + 'c@index.android.bundle:93:60\n' + + 'perform@index.android.bundle:177:596\n' + + 'batchedUpdates@index.android.bundle:188:464\n' + + 'i@index.android.bundle:176:358\n' + + 'i@index.android.bundle:93:90\n' + + 'u@index.android.bundle:93:150\n' + + '_receiveRootNodeIDEvent@index.android.bundle:156:544\n' + + 'receiveTouches@index.android.bundle:156:918\n' + + 'value@index.android.bundle:29:3016\n' + + 'index.android.bundle:29:955\n' + + 'value@index.android.bundle:29:2417\n' + + 'value@index.android.bundle:29:927\n' + + '[native code]', +}; diff --git a/packages/telemetry/browser-telemetry/__tests__/vendor/TraceKit/TraceKit.test.ts b/packages/telemetry/browser-telemetry/__tests__/vendor/TraceKit/TraceKit.test.ts new file mode 100644 index 0000000000..ac368acb68 --- /dev/null +++ b/packages/telemetry/browser-telemetry/__tests__/vendor/TraceKit/TraceKit.test.ts @@ -0,0 +1,236 @@ +import { getTraceKit } from '../../../src/vendor/TraceKit'; + +/* eslint-disable prefer-arrow-callback */ +/* eslint-disable func-names */ +/* eslint-disable no-var */ + +describe('TraceKit', function () { + describe('General', function () { + it('should not remove anonymous functions from the stack', function () { + // mock up an error object with a stack trace that includes both + // named functions and anonymous functions + var stack_str = + '' + + ' Error: \n' + + ' at new (http://example.com/js/test.js:63:1)\n' + // stack[0] + ' at namedFunc0 (http://example.com/js/script.js:10:2)\n' + // stack[1] + ' at http://example.com/js/test.js:65:10\n' + // stack[2] + ' at namedFunc2 (http://example.com/js/script.js:20:5)\n' + // stack[3] + ' at http://example.com/js/test.js:67:5\n' + // stack[4] + ' at namedFunc4 (http://example.com/js/script.js:100001:10002)'; // stack[5] + var mock_err = { stack: stack_str }; + var stackFrames = getTraceKit().computeStackTrace.computeStackTraceFromStackProp( + mock_err as unknown as Error, + ); + + // Make sure TraceKit didn't remove the anonymous functions + // from the stack like it used to :) + expect(stackFrames).toBeTruthy(); + expect(stackFrames?.stack[0].func).toEqual('new '); + expect(stackFrames?.stack[0].url).toEqual('http://example.com/js/test.js'); + expect(stackFrames?.stack[0].line).toBe(63); + expect(stackFrames?.stack[0].column).toBe(1); + + expect(stackFrames?.stack[1].func).toEqual('namedFunc0'); + expect(stackFrames?.stack[1].url).toEqual('http://example.com/js/script.js'); + expect(stackFrames?.stack[1].line).toBe(10); + expect(stackFrames?.stack[1].column).toBe(2); + + expect(stackFrames?.stack[2].func).toEqual('?'); + expect(stackFrames?.stack[2].url).toEqual('http://example.com/js/test.js'); + expect(stackFrames?.stack[2].line).toBe(65); + expect(stackFrames?.stack[2].column).toBe(10); + + expect(stackFrames?.stack[3].func).toEqual('namedFunc2'); + expect(stackFrames?.stack[3].url).toEqual('http://example.com/js/script.js'); + expect(stackFrames?.stack[3].line).toBe(20); + expect(stackFrames?.stack[3].column).toBe(5); + + expect(stackFrames?.stack[4].func).toEqual('?'); + expect(stackFrames?.stack[4].url).toEqual('http://example.com/js/test.js'); + expect(stackFrames?.stack[4].line).toBe(67); + expect(stackFrames?.stack[4].column).toBe(5); + + expect(stackFrames?.stack[5].func).toEqual('namedFunc4'); + expect(stackFrames?.stack[5].url).toEqual('http://example.com/js/script.js'); + expect(stackFrames?.stack[5].line).toBe(100001); + expect(stackFrames?.stack[5].column).toBe(10002); + }); + + it('should handle eval/anonymous strings in Chrome 46', function () { + var stack_str = + '' + + 'ReferenceError: baz is not defined\n' + + ' at bar (http://example.com/js/test.js:19:7)\n' + + ' at foo (http://example.com/js/test.js:23:7)\n' + + ' at eval (eval at (http://example.com/js/test.js:26:5)).toBe(:1:26)\n'; + + var mock_err = { stack: stack_str }; + var stackFrames = getTraceKit().computeStackTrace.computeStackTraceFromStackProp( + mock_err as unknown as Error, + ); + expect(stackFrames).toBeTruthy(); + expect(stackFrames?.stack[0].func).toEqual('bar'); + expect(stackFrames?.stack[0].url).toEqual('http://example.com/js/test.js'); + expect(stackFrames?.stack[0].line).toBe(19); + expect(stackFrames?.stack[0].column).toBe(7); + + expect(stackFrames?.stack[1].func).toEqual('foo'); + expect(stackFrames?.stack[1].url).toEqual('http://example.com/js/test.js'); + expect(stackFrames?.stack[1].line).toBe(23); + expect(stackFrames?.stack[1].column).toBe(7); + + expect(stackFrames?.stack[2].func).toEqual('eval'); + // TODO: fix nested evals + expect(stackFrames?.stack[2].url).toEqual('http://example.com/js/test.js'); + expect(stackFrames?.stack[2].line).toBe(26); + expect(stackFrames?.stack[2].column).toBe(5); + }); + }); + + describe('.computeStackTrace', function () { + it('should handle a native error object', function () { + var ex = new Error('test'); + var stack = getTraceKit().computeStackTrace(ex); + expect(stack.name).toEqual('Error'); + expect(stack.message).toEqual('test'); + }); + + it('should handle a native error object stack from Chrome', function () { + var stackStr = + '' + + 'Error: foo\n' + + ' at :2:11\n' + + ' at Object.InjectedScript._evaluateOn (:904:140)\n' + + ' at Object.InjectedScript._evaluateAndWrap (:837:34)\n' + + ' at Object.InjectedScript.evaluate (:693:21)'; + var mockErr = { + name: 'Error', + message: 'foo', + stack: stackStr, + }; + var stackFrames = getTraceKit().computeStackTrace(mockErr); + expect(stackFrames).toBeTruthy(); + expect(stackFrames.stack[0].url).toEqual(''); + }); + }); + + describe('given mock source code, xhr, and domain', () => { + // Mock source code that will be fetched + const mockSource = + 'function foo() {\n' + + ' console.log("line 2");\n' + + ' throw new Error("error on line 3");\n' + + ' console.log("line 4");\n' + + '}\n' + + 'foo();'; + + // Mock XMLHttpRequest + const mockXHR = { + open: jest.fn(), + send: jest.fn(), + responseText: mockSource, + }; + + // @ts-ignore - we know this is incomplete + window.XMLHttpRequest = jest.fn(() => mockXHR); + + window.document.domain = 'localhost'; + + it('should populate srcStart and context from source code with firefox style stack trace', () => { + const traceKit = getTraceKit(); + traceKit.remoteFetching = true; + traceKit.linesOfContext = 10; + + const error = new Error('error on line 3'); + // Firefox style stack trace + error.stack = + 'foo/<@http://localhost:8081/assets/index-BvsURM3r.js:3:2\n' + + '@http://localhost:8081/assets/index-BvsURM3r.js:6:0'; + const stackFrames = traceKit.computeStackTrace(error); + + expect(stackFrames).toBeTruthy(); + expect(stackFrames.stack[0]).toEqual({ + url: 'http://localhost:8081/assets/index-BvsURM3r.js', + func: 'foo/<', + args: [], + line: 3, + column: 2, + context: [ + 'function foo() {', + ' console.log("line 2");', + ' throw new Error("error on line 3");', + ' console.log("line 4");', + '}', + 'foo();', + ], + srcStart: 1, + }); + }); + + it('should populate srcStart and context from source code with chrome style stack trace', () => { + const traceKit = getTraceKit(); + traceKit.remoteFetching = true; + traceKit.linesOfContext = 10; + + const error = new Error('error on line 3'); + // Chrome style stack trace + error.stack = + 'Error: error on line 3\n' + + ' at foo (http://localhost:8081/assets/index-BvsURM3r.js:3:2)\n' + + ' at http://localhost:8081/assets/index-BvsURM3r.js:6:0'; + const stackFrames = traceKit.computeStackTrace(error); + + expect(stackFrames).toBeTruthy(); + expect(stackFrames.stack[0]).toEqual({ + url: 'http://localhost:8081/assets/index-BvsURM3r.js', + func: 'foo', + args: [], + line: 3, + column: 2, + context: [ + 'function foo() {', + ' console.log("line 2");', + ' throw new Error("error on line 3");', + ' console.log("line 4");', + '}', + 'foo();', + ], + srcStart: 1, + }); + }); + + it('should populate srcStart and context from source code with opera style stack trace', () => { + const traceKit = getTraceKit(); + traceKit.remoteFetching = true; + traceKit.linesOfContext = 10; + + const error = new Error('error on line 3'); + // Opera style stack trace + // @ts-ignore - Opera does what it wants. + error.stacktrace = + 'Error initially occurred at line 3, column 2 in foo() in http://localhost:8081/assets/index-BvsURM3r.js:\n' + + 'throw new Error("error on line 3");\n' + + 'called from line 6, column 1 in foo() in http://localhost:8081/assets/index-BvsURM3r.js:'; + const stackFrames = traceKit.computeStackTrace(error); + + expect(stackFrames).toBeTruthy(); + expect(stackFrames.stack[0]).toEqual({ + url: 'http://localhost:8081/assets/index-BvsURM3r.js', + func: 'foo', + args: [], + line: 3, + column: 2, + context: [ + 'function foo() {', + ' console.log("line 2");', + ' throw new Error("error on line 3");', + ' console.log("line 4");', + '}', + 'foo();', + ], + srcStart: 1, + }); + }); + }); +}); diff --git a/packages/telemetry/browser-telemetry/__tests__/vendor/TraceKit/computeStackTrace.test.ts b/packages/telemetry/browser-telemetry/__tests__/vendor/TraceKit/computeStackTrace.test.ts new file mode 100644 index 0000000000..46a0d68ef9 --- /dev/null +++ b/packages/telemetry/browser-telemetry/__tests__/vendor/TraceKit/computeStackTrace.test.ts @@ -0,0 +1,1293 @@ +/** + * https://github.com/csnover/TraceKit + * @license MIT + * @namespace TraceKit + */ +import { getTraceKit } from '../../../src/vendor/TraceKit'; +import * as CapturedExceptions from './CapturedExceptions'; + +/* eslint-disable no-plusplus */ +/* eslint-disable no-useless-escape */ +/* eslint-disable @typescript-eslint/no-use-before-define */ + +describe('computeStackTrace', () => { + describe('domain regex', () => { + const regex = /(.*)\:\/\/([^\/]+)\/{0,1}([\s\S]*)/; + it('should return subdomains properly', () => { + const url = 'https://subdomain.yoursite.com/assets/main.js'; + const domain = 'subdomain.yoursite.com'; + expect(regex.exec(url)![2]).toBe(domain); + }); + it('should return domains correctly with any protocol', () => { + const url = 'http://yoursite.com/assets/main.js'; + const domain = 'yoursite.com'; + expect(regex.exec(url)![2]).toBe(domain); + }); + it('should return the correct domain when directories match the domain', () => { + const url = 'https://mysite.com/mysite/main.js'; + const domain = 'mysite.com'; + expect(regex.exec(url)![2]).toBe(domain); + }); + }); +}); + +describe('Parser', () => { + function foo() { + return bar(); + } + + function bar() { + return baz(); + } + + function baz() { + return getTraceKit().computeStackTrace.ofCaller(); + } + + it('should get the order of functions called right', () => { + const trace = foo(); + const expected = ['baz', 'bar', 'foo']; + for (let i = 1; i <= 3; i++) { + expect(trace.stack[i].func).toBe(expected[i - 1]); + } + }); + + it('should parse Safari 6 error', () => { + const stackFrames = getTraceKit().computeStackTrace( + CapturedExceptions.SAFARI_6 as unknown as Error, + ); + expect(stackFrames).toBeTruthy(); + expect(stackFrames.stack.length).toBe(4); + expect(stackFrames.stack[0]).toEqual({ + url: 'http://path/to/file.js', + func: '?', + args: [], + line: 48, + column: null, + context: null, + }); + expect(stackFrames.stack[1]).toEqual({ + url: 'http://path/to/file.js', + func: 'dumpException3', + args: [], + line: 52, + column: null, + context: null, + }); + expect(stackFrames.stack[2]).toEqual({ + url: 'http://path/to/file.js', + func: 'onclick', + args: [], + line: 82, + column: null, + context: null, + }); + expect(stackFrames.stack[3]).toEqual({ + url: '[native code]', + func: '?', + args: [], + line: null, + column: null, + context: null, + }); + }); + + it('should parse Safari 7 error', () => { + const stackFrames = getTraceKit().computeStackTrace(CapturedExceptions.SAFARI_7); + expect(stackFrames).toBeTruthy(); + expect(stackFrames.stack.length).toBe(3); + expect(stackFrames.stack[0]).toEqual({ + url: 'http://path/to/file.js', + func: '?', + args: [], + line: 48, + column: 22, + context: null, + }); + expect(stackFrames.stack[1]).toEqual({ + url: 'http://path/to/file.js', + func: 'foo', + args: [], + line: 52, + column: 15, + context: null, + }); + expect(stackFrames.stack[2]).toEqual({ + url: 'http://path/to/file.js', + func: 'bar', + args: [], + line: 108, + column: 107, + context: null, + }); + }); + + it('should parse Safari 8 error', () => { + const stackFrames = getTraceKit().computeStackTrace(CapturedExceptions.SAFARI_8); + expect(stackFrames).toBeTruthy(); + expect(stackFrames.stack.length).toBe(3); + expect(stackFrames.stack[0]).toEqual({ + url: 'http://path/to/file.js', + func: '?', + args: [], + line: 47, + column: 22, + context: null, + }); + expect(stackFrames.stack[1]).toEqual({ + url: 'http://path/to/file.js', + func: 'foo', + args: [], + line: 52, + column: 15, + context: null, + }); + expect(stackFrames.stack[2]).toEqual({ + url: 'http://path/to/file.js', + func: 'bar', + args: [], + line: 108, + column: 23, + context: null, + }); + }); + + it('should parse Safari 8 eval error', () => { + // TODO: Take into account the line and column properties on the error object and use them for the first stack trace. + const stackFrames = getTraceKit().computeStackTrace(CapturedExceptions.SAFARI_8_EVAL); + expect(stackFrames).toBeTruthy(); + expect(stackFrames.stack.length).toBe(3); + expect(stackFrames.stack[0]).toEqual({ + url: '[native code]', + func: 'eval', + args: [], + line: null, + column: null, + context: null, + }); + expect(stackFrames.stack[1]).toEqual({ + url: 'http://path/to/file.js', + func: 'foo', + args: [], + line: 58, + column: 21, + context: null, + }); + expect(stackFrames.stack[2]).toEqual({ + url: 'http://path/to/file.js', + func: 'bar', + args: [], + line: 109, + column: 91, + context: null, + }); + }); + + it('should parse Firefox 3 error', () => { + const stackFrames = getTraceKit().computeStackTrace(CapturedExceptions.FIREFOX_3); + expect(stackFrames).toBeTruthy(); + expect(stackFrames.stack.length).toBe(7); + expect(stackFrames.stack[0]).toEqual({ + url: 'http://127.0.0.1:8000/js/stacktrace.js', + func: '?', + args: [], + line: 44, + column: null, + context: null, + }); + expect(stackFrames.stack[1]).toEqual({ + url: 'http://127.0.0.1:8000/js/stacktrace.js', + func: '?', + args: ['null'], + line: 31, + column: null, + context: null, + }); + expect(stackFrames.stack[2]).toEqual({ + url: 'http://127.0.0.1:8000/js/stacktrace.js', + func: 'printStackTrace', + args: [], + line: 18, + column: null, + context: null, + }); + expect(stackFrames.stack[3]).toEqual({ + url: 'http://127.0.0.1:8000/js/file.js', + func: 'bar', + args: ['1'], + line: 13, + column: null, + context: null, + }); + expect(stackFrames.stack[4]).toEqual({ + url: 'http://127.0.0.1:8000/js/file.js', + func: 'bar', + args: ['2'], + line: 16, + column: null, + context: null, + }); + expect(stackFrames.stack[5]).toEqual({ + url: 'http://127.0.0.1:8000/js/file.js', + func: 'foo', + args: [], + line: 20, + column: null, + context: null, + }); + expect(stackFrames.stack[6]).toEqual({ + url: 'http://127.0.0.1:8000/js/file.js', + func: '?', + args: [], + line: 24, + column: null, + context: null, + }); + }); + + it('should parse Firefox 7 error', () => { + const stackFrames = getTraceKit().computeStackTrace( + CapturedExceptions.FIREFOX_7 as unknown as Error, + ); + expect(stackFrames).toBeTruthy(); + expect(stackFrames.stack.length).toBe(7); + expect(stackFrames.stack[0]).toEqual({ + url: 'file:///G:/js/stacktrace.js', + func: '?', + args: [], + line: 44, + column: null, + context: null, + }); + expect(stackFrames.stack[1]).toEqual({ + url: 'file:///G:/js/stacktrace.js', + func: '?', + args: ['null'], + line: 31, + column: null, + context: null, + }); + expect(stackFrames.stack[2]).toEqual({ + url: 'file:///G:/js/stacktrace.js', + func: 'printStackTrace', + args: [], + line: 18, + column: null, + context: null, + }); + expect(stackFrames.stack[3]).toEqual({ + url: 'file:///G:/js/file.js', + func: 'bar', + args: ['1'], + line: 13, + column: null, + context: null, + }); + expect(stackFrames.stack[4]).toEqual({ + url: 'file:///G:/js/file.js', + func: 'bar', + args: ['2'], + line: 16, + column: null, + context: null, + }); + expect(stackFrames.stack[5]).toEqual({ + url: 'file:///G:/js/file.js', + func: 'foo', + args: [], + line: 20, + column: null, + context: null, + }); + expect(stackFrames.stack[6]).toEqual({ + url: 'file:///G:/js/file.js', + func: '?', + args: [], + line: 24, + column: null, + context: null, + }); + }); + + it('should parse Firefox 14 error', () => { + const stackFrames = getTraceKit().computeStackTrace( + CapturedExceptions.FIREFOX_14 as unknown as Error, + ); + expect(stackFrames).toBeTruthy(); + expect(stackFrames.stack.length).toBe(3); + expect(stackFrames.stack[0]).toEqual({ + url: 'http://path/to/file.js', + func: '?', + args: [], + line: 48, + column: null, + context: null, + }); + expect(stackFrames.stack[1]).toEqual({ + url: 'http://path/to/file.js', + func: 'dumpException3', + args: [], + line: 52, + column: null, + context: null, + }); + expect(stackFrames.stack[2]).toEqual({ + url: 'http://path/to/file.js', + func: 'onclick', + args: [], + line: 1, + column: null, + context: null, + }); + }); + + it('should parse Firefox 31 error', () => { + const stackFrames = getTraceKit().computeStackTrace(CapturedExceptions.FIREFOX_31); + expect(stackFrames).toBeTruthy(); + expect(stackFrames.stack.length).toBe(3); + expect(stackFrames.stack[0]).toEqual({ + url: 'http://path/to/file.js', + func: 'foo', + args: [], + line: 41, + column: 13, + context: null, + }); + expect(stackFrames.stack[1]).toEqual({ + url: 'http://path/to/file.js', + func: 'bar', + args: [], + line: 1, + column: 1, + context: null, + }); + expect(stackFrames.stack[2]).toEqual({ + url: 'http://path/to/file.js', + func: '.plugin/e.fn[c]/<', + args: [], + line: 1, + column: 1, + context: null, + }); + }); + + it('should parse Firefox 44 ns exceptions', () => { + const stackFrames = getTraceKit().computeStackTrace(CapturedExceptions.FIREFOX_44_NS_EXCEPTION); + expect(stackFrames).toBeTruthy(); + expect(stackFrames.stack.length).toBe(4); + expect(stackFrames.stack[0]).toEqual({ + url: 'http://path/to/file.js', + func: '[2] { + const stackFrames = getTraceKit().computeStackTrace({ + stack: 'error\n at Array.forEach (native)', + } as unknown as Error); + expect(stackFrames.stack.length).toBe(1); + expect(stackFrames.stack[0]).toEqual({ + url: null, + func: 'Array.forEach', + args: ['native'], + line: null, + column: null, + context: null, + }); + }); + + it('should parse Chrome 15 error', () => { + const stackFrames = getTraceKit().computeStackTrace( + CapturedExceptions.CHROME_15 as unknown as Error, + ); + expect(stackFrames).toBeTruthy(); + expect(stackFrames.stack.length).toBe(4); + expect(stackFrames.stack[0]).toEqual({ + url: 'http://path/to/file.js', + func: 'bar', + args: [], + line: 13, + column: 17, + context: null, + }); + expect(stackFrames.stack[1]).toEqual({ + url: 'http://path/to/file.js', + func: 'bar', + args: [], + line: 16, + column: 5, + context: null, + }); + expect(stackFrames.stack[2]).toEqual({ + url: 'http://path/to/file.js', + func: 'foo', + args: [], + line: 20, + column: 5, + context: null, + }); + expect(stackFrames.stack[3]).toEqual({ + url: 'http://path/to/file.js', + func: '?', + args: [], + line: 24, + column: 4, + context: null, + }); + }); + + it('should parse Chrome 36 error with port numbers', () => { + const stackFrames = getTraceKit().computeStackTrace(CapturedExceptions.CHROME_36); + expect(stackFrames).toBeTruthy(); + expect(stackFrames.stack.length).toBe(3); + expect(stackFrames.stack[0]).toEqual({ + url: 'http://localhost:8080/file.js', + func: 'dumpExceptionError', + args: [], + line: 41, + column: 27, + context: null, + }); + expect(stackFrames.stack[1]).toEqual({ + url: 'http://localhost:8080/file.js', + func: 'HTMLButtonElement.onclick', + args: [], + line: 107, + column: 146, + context: null, + }); + expect(stackFrames.stack[2]).toEqual({ + url: 'http://localhost:8080/file.js', + func: 'I.e.fn.(anonymous function) [as index]', + args: [], + line: 10, + column: 3651, + context: null, + }); + }); + + it('should parse Chrome error with webpack URLs', () => { + const stackFrames = getTraceKit().computeStackTrace(CapturedExceptions.CHROME_XX_WEBPACK); + expect(stackFrames).toBeTruthy(); + expect(stackFrames.stack.length).toBe(4); + expect(stackFrames.stack[0]).toEqual({ + url: 'webpack:///./src/components/test/test.jsx?', + func: 'TESTTESTTEST.eval', + args: [], + line: 295, + column: 108, + context: null, + }); + expect(stackFrames.stack[1]).toEqual({ + url: 'webpack:///./src/components/test/test.jsx?', + func: 'TESTTESTTEST.render', + args: [], + line: 272, + column: 32, + context: null, + }); + expect(stackFrames.stack[2]).toEqual({ + url: 'webpack:///./~/react-transform-catch-errors/lib/index.js?', + func: 'TESTTESTTEST.tryRender', + args: [], + line: 34, + column: 31, + context: null, + }); + expect(stackFrames.stack[3]).toEqual({ + url: 'webpack:///./~/react-proxy/modules/createPrototypeProxy.js?', + func: 'TESTTESTTEST.proxiedMethod', + args: [], + line: 44, + column: 30, + context: null, + }); + }); + + it('should parse nested eval() from Chrome', () => { + const stackFrames = getTraceKit().computeStackTrace(CapturedExceptions.CHROME_48_EVAL); + expect(stackFrames).toBeTruthy(); + expect(stackFrames.stack.length).toBe(5); + expect(stackFrames.stack[0]).toEqual({ + url: 'http://localhost:8080/file.js', + func: 'baz', + args: [], + line: 21, + column: 17, + context: null, + }); + expect(stackFrames.stack[1]).toEqual({ + url: 'http://localhost:8080/file.js', + func: 'foo', + args: [], + line: 21, + column: 17, + context: null, + }); + expect(stackFrames.stack[2]).toEqual({ + url: 'http://localhost:8080/file.js', + func: 'eval', + args: [], + line: 21, + column: 17, + context: null, + }); + expect(stackFrames.stack[3]).toEqual({ + url: 'http://localhost:8080/file.js', + func: 'Object.speak', + args: [], + line: 21, + column: 17, + context: null, + }); + expect(stackFrames.stack[4]).toEqual({ + url: 'http://localhost:8080/file.js', + func: '?', + args: [], + line: 31, + column: 13, + context: null, + }); + }); + + it('should parse Chrome error with blob URLs', () => { + const stackFrames = getTraceKit().computeStackTrace(CapturedExceptions.CHROME_48_BLOB); + expect(stackFrames).toBeTruthy(); + expect(stackFrames.stack.length).toBe(7); + expect(stackFrames.stack[1]).toEqual({ + url: 'blob:http%3A//localhost%3A8080/abfc40e9-4742-44ed-9dcd-af8f99a29379', + func: 's', + args: [], + line: 31, + column: 29146, + context: null, + }); + expect(stackFrames.stack[2]).toEqual({ + url: 'blob:http%3A//localhost%3A8080/abfc40e9-4742-44ed-9dcd-af8f99a29379', + func: 'Object.d [as add]', + args: [], + line: 31, + column: 30039, + context: null, + }); + expect(stackFrames.stack[3]).toEqual({ + url: 'blob:http%3A//localhost%3A8080/d4eefe0f-361a-4682-b217-76587d9f712a', + func: '?', + args: [], + line: 15, + column: 10978, + context: null, + }); + expect(stackFrames.stack[4]).toEqual({ + url: 'blob:http%3A//localhost%3A8080/abfc40e9-4742-44ed-9dcd-af8f99a29379', + func: '?', + args: [], + line: 1, + column: 6911, + context: null, + }); + expect(stackFrames.stack[5]).toEqual({ + url: 'blob:http%3A//localhost%3A8080/abfc40e9-4742-44ed-9dcd-af8f99a29379', + func: 'n.fire', + args: [], + line: 7, + column: 3019, + context: null, + }); + expect(stackFrames.stack[6]).toEqual({ + url: 'blob:http%3A//localhost%3A8080/abfc40e9-4742-44ed-9dcd-af8f99a29379', + func: 'n.handle', + args: [], + line: 7, + column: 2863, + context: null, + }); + }); + + it('should parse empty IE 9 error', () => { + const stackFrames = getTraceKit().computeStackTrace( + CapturedExceptions.IE_9 as unknown as Error, + ); + expect(stackFrames).toBeTruthy(); + if (stackFrames.stack) { + expect(stackFrames.stack.length).toBe(0); + } + }); + + it('should parse IE 10 error', () => { + const stackFrames = getTraceKit().computeStackTrace( + CapturedExceptions.IE_10 as unknown as Error, + ); + expect(stackFrames).toBeTruthy(); + expect(stackFrames.stack.length).toBe(3); + // TODO: func should be normalized + expect(stackFrames.stack[0]).toEqual({ + url: 'http://path/to/file.js', + func: 'Anonymous function', + args: [], + line: 48, + column: 13, + context: null, + }); + expect(stackFrames.stack[1]).toEqual({ + url: 'http://path/to/file.js', + func: 'foo', + args: [], + line: 46, + column: 9, + context: null, + }); + expect(stackFrames.stack[2]).toEqual({ + url: 'http://path/to/file.js', + func: 'bar', + args: [], + line: 82, + column: 1, + context: null, + }); + }); + + it('should parse IE 11 error', () => { + const stackFrames = getTraceKit().computeStackTrace(CapturedExceptions.IE_11); + expect(stackFrames).toBeTruthy(); + expect(stackFrames.stack.length).toBe(3); + // TODO: func should be normalized + expect(stackFrames.stack[0]).toEqual({ + url: 'http://path/to/file.js', + func: 'Anonymous function', + args: [], + line: 47, + column: 21, + context: null, + }); + expect(stackFrames.stack[1]).toEqual({ + url: 'http://path/to/file.js', + func: 'foo', + args: [], + line: 45, + column: 13, + context: null, + }); + expect(stackFrames.stack[2]).toEqual({ + url: 'http://path/to/file.js', + func: 'bar', + args: [], + line: 108, + column: 1, + context: null, + }); + }); + + it('should parse IE 11 eval error', () => { + const stackFrames = getTraceKit().computeStackTrace(CapturedExceptions.IE_11_EVAL); + expect(stackFrames).toBeTruthy(); + expect(stackFrames.stack.length).toBe(3); + expect(stackFrames.stack[0]).toEqual({ + url: 'eval code', + func: 'eval code', + args: [], + line: 1, + column: 1, + context: null, + }); + expect(stackFrames.stack[1]).toEqual({ + url: 'http://path/to/file.js', + func: 'foo', + args: [], + line: 58, + column: 17, + context: null, + }); + expect(stackFrames.stack[2]).toEqual({ + url: 'http://path/to/file.js', + func: 'bar', + args: [], + line: 109, + column: 1, + context: null, + }); + }); + + it('should parse Opera 8.54 error', () => { + const stackFrames = getTraceKit().computeStackTrace( + CapturedExceptions.OPERA_854 as unknown as Error, + ); + expect(stackFrames).toBeTruthy(); + expect(stackFrames.stack.length).toBe(7); + expect(stackFrames.stack[0]).toEqual({ + url: 'http://path/to/file.js', + func: '?', + args: [], + line: 44, + column: null, + context: [' this.undef();'], + }); + expect(stackFrames.stack[1]).toEqual({ + url: 'http://path/to/file.js', + func: '?', + args: [], + line: 31, + column: null, + context: [' ex = ex || this.createException();'], + }); + expect(stackFrames.stack[2]).toEqual({ + url: 'http://path/to/file.js', + func: '?', + args: [], + line: 18, + column: null, + context: [' var p = new printStackTrace.implementation(), result = p.run(ex);'], + }); + expect(stackFrames.stack[3]).toEqual({ + url: 'http://path/to/file.js', + func: '?', + args: [], + line: 4, + column: null, + context: [' printTrace(printStackTrace());'], + }); + expect(stackFrames.stack[4]).toEqual({ + url: 'http://path/to/file.js', + func: '?', + args: [], + line: 7, + column: null, + context: [' bar(n - 1);'], + }); + expect(stackFrames.stack[5]).toEqual({ + url: 'http://path/to/file.js', + func: '?', + args: [], + line: 11, + column: null, + context: [' bar(2);'], + }); + expect(stackFrames.stack[6]).toEqual({ + url: 'http://path/to/file.js', + func: '?', + args: [], + line: 15, + column: null, + context: [' foo();'], + }); + }); + + it('should parse Opera 9.02 error', () => { + const stackFrames = getTraceKit().computeStackTrace( + CapturedExceptions.OPERA_902 as unknown as Error, + ); + expect(stackFrames).toBeTruthy(); + expect(stackFrames.stack.length).toBe(7); + expect(stackFrames.stack[0]).toEqual({ + url: 'http://path/to/file.js', + func: '?', + args: [], + line: 44, + column: null, + context: [' this.undef();'], + }); + expect(stackFrames.stack[1]).toEqual({ + url: 'http://path/to/file.js', + func: '?', + args: [], + line: 31, + column: null, + context: [' ex = ex || this.createException();'], + }); + expect(stackFrames.stack[2]).toEqual({ + url: 'http://path/to/file.js', + func: '?', + args: [], + line: 18, + column: null, + context: [' var p = new printStackTrace.implementation(), result = p.run(ex);'], + }); + expect(stackFrames.stack[3]).toEqual({ + url: 'http://path/to/file.js', + func: '?', + args: [], + line: 4, + column: null, + context: [' printTrace(printStackTrace());'], + }); + expect(stackFrames.stack[4]).toEqual({ + url: 'http://path/to/file.js', + func: '?', + args: [], + line: 7, + column: null, + context: [' bar(n - 1);'], + }); + expect(stackFrames.stack[5]).toEqual({ + url: 'http://path/to/file.js', + func: '?', + args: [], + line: 11, + column: null, + context: [' bar(2);'], + }); + expect(stackFrames.stack[6]).toEqual({ + url: 'http://path/to/file.js', + func: '?', + args: [], + line: 15, + column: null, + context: [' foo();'], + }); + }); + + it('should parse Opera 9.27 error', () => { + const stackFrames = getTraceKit().computeStackTrace( + CapturedExceptions.OPERA_927 as unknown as Error, + ); + expect(stackFrames).toBeTruthy(); + expect(stackFrames.stack.length).toBe(3); + expect(stackFrames.stack[0]).toEqual({ + url: 'http://path/to/file.js', + func: '?', + args: [], + line: 43, + column: null, + context: [' bar(n - 1);'], + }); + expect(stackFrames.stack[1]).toEqual({ + url: 'http://path/to/file.js', + func: '?', + args: [], + line: 31, + column: null, + context: [' bar(2);'], + }); + expect(stackFrames.stack[2]).toEqual({ + url: 'http://path/to/file.js', + func: '?', + args: [], + line: 18, + column: null, + context: [' foo();'], + }); + }); + + it('should parse Opera 9.64 error', () => { + const stackFrames = getTraceKit().computeStackTrace( + CapturedExceptions.OPERA_964 as unknown as Error, + ); + expect(stackFrames).toBeTruthy(); + expect(stackFrames.stack.length).toBe(6); + expect(stackFrames.stack[0]).toEqual({ + url: 'http://path/to/file.js', + func: '?', + args: [], + line: 27, + column: null, + context: [' ex = ex || this.createException();'], + }); + expect(stackFrames.stack[1]).toEqual({ + url: 'http://path/to/file.js', + func: 'printStackTrace', + args: [], + line: 18, + column: null, + context: [' var p = new printStackTrace.implementation(), result = p.run(ex);'], + }); + expect(stackFrames.stack[2]).toEqual({ + url: 'http://path/to/file.js', + func: 'bar', + args: [], + line: 4, + column: null, + context: [' printTrace(printStackTrace());'], + }); + expect(stackFrames.stack[3]).toEqual({ + url: 'http://path/to/file.js', + func: 'bar', + args: [], + line: 7, + column: null, + context: [' bar(n - 1);'], + }); + expect(stackFrames.stack[4]).toEqual({ + url: 'http://path/to/file.js', + func: 'foo', + args: [], + line: 11, + column: null, + context: [' bar(2);'], + }); + expect(stackFrames.stack[5]).toEqual({ + url: 'http://path/to/file.js', + func: '?', + args: [], + line: 15, + column: null, + context: [' foo();'], + }); + }); + + it('should parse Opera 10 error', () => { + const stackFrames = getTraceKit().computeStackTrace( + CapturedExceptions.OPERA_10 as unknown as Error, + ); + expect(stackFrames).toBeTruthy(); + expect(stackFrames.stack.length).toBe(7); + expect(stackFrames.stack[0]).toEqual({ + url: 'http://path/to/file.js', + func: '?', + args: [], + line: 42, + column: null, + context: [' this.undef();'], + }); + expect(stackFrames.stack[1]).toEqual({ + url: 'http://path/to/file.js', + func: '?', + args: [], + line: 27, + column: null, + context: [' ex = ex || this.createException();'], + }); + expect(stackFrames.stack[2]).toEqual({ + url: 'http://path/to/file.js', + func: 'printStackTrace', + args: [], + line: 18, + column: null, + context: [' var p = new printStackTrace.implementation(), result = p.run(ex);'], + }); + expect(stackFrames.stack[3]).toEqual({ + url: 'http://path/to/file.js', + func: 'bar', + args: [], + line: 4, + column: null, + context: [' printTrace(printStackTrace());'], + }); + expect(stackFrames.stack[4]).toEqual({ + url: 'http://path/to/file.js', + func: 'bar', + args: [], + line: 7, + column: null, + context: [' bar(n - 1);'], + }); + expect(stackFrames.stack[5]).toEqual({ + url: 'http://path/to/file.js', + func: 'foo', + args: [], + line: 11, + column: null, + context: [' bar(2);'], + }); + expect(stackFrames.stack[6]).toEqual({ + url: 'http://path/to/file.js', + func: '?', + args: [], + line: 15, + column: null, + context: [' foo();'], + }); + }); + + it('should parse Opera 11 error', () => { + const stackFrames = getTraceKit().computeStackTrace( + CapturedExceptions.OPERA_11 as unknown as Error, + ); + expect(stackFrames).toBeTruthy(); + expect(stackFrames.stack.length).toBe(7); + expect(stackFrames.stack[0]).toEqual({ + url: 'http://path/to/file.js', + func: 'createException', + args: [], + line: 42, + column: 12, + context: [' this.undef();'], + }); + expect(stackFrames.stack[1]).toEqual({ + url: 'http://path/to/file.js', + func: 'run', + args: ['ex'], + line: 27, + column: 8, + context: [' ex = ex || this.createException();'], + }); + expect(stackFrames.stack[2]).toEqual({ + url: 'http://path/to/file.js', + func: 'printStackTrace', + args: ['options'], + line: 18, + column: 4, + context: [' var p = new printStackTrace.implementation(), result = p.run(ex);'], + }); + expect(stackFrames.stack[3]).toEqual({ + url: 'http://path/to/file.js', + func: 'bar', + args: ['n'], + line: 4, + column: 5, + context: [' printTrace(printStackTrace());'], + }); + expect(stackFrames.stack[4]).toEqual({ + url: 'http://path/to/file.js', + func: 'bar', + args: ['n'], + line: 7, + column: 4, + context: [' bar(n - 1);'], + }); + expect(stackFrames.stack[5]).toEqual({ + url: 'http://path/to/file.js', + func: 'foo', + args: [], + line: 11, + column: 4, + context: [' bar(2);'], + }); + expect(stackFrames.stack[6]).toEqual({ + url: 'http://path/to/file.js', + func: '?', + args: [], + line: 15, + column: 3, + context: [' foo();'], + }); + }); + + it('should parse Opera 12 error', () => { + // TODO: Improve anonymous function name. + const stackFrames = getTraceKit().computeStackTrace( + CapturedExceptions.OPERA_12 as unknown as Error, + ); + expect(stackFrames).toBeTruthy(); + expect(stackFrames.stack.length).toBe(3); + expect(stackFrames.stack[0]).toEqual({ + url: 'http://localhost:8000/ExceptionLab.html', + func: '', + args: ['x'], + line: 48, + column: 12, + context: [' x.undef();'], + }); + expect(stackFrames.stack[1]).toEqual({ + url: 'http://localhost:8000/ExceptionLab.html', + func: 'dumpException3', + args: [], + line: 46, + column: 8, + context: [' dumpException((function(x) {'], + }); + expect(stackFrames.stack[2]).toEqual({ + url: 'http://localhost:8000/ExceptionLab.html', + func: '', + args: ['event'], + line: 1, + column: 0, + context: [' dumpException3();'], + }); + }); + + it('should parse Opera 25 error', () => { + const stackFrames = getTraceKit().computeStackTrace(CapturedExceptions.OPERA_25); + expect(stackFrames).toBeTruthy(); + expect(stackFrames.stack.length).toBe(3); + expect(stackFrames.stack[0]).toEqual({ + url: 'http://path/to/file.js', + func: '?', + args: [], + line: 47, + column: 22, + context: null, + }); + expect(stackFrames.stack[1]).toEqual({ + url: 'http://path/to/file.js', + func: 'foo', + args: [], + line: 52, + column: 15, + context: null, + }); + expect(stackFrames.stack[2]).toEqual({ + url: 'http://path/to/file.js', + func: 'bar', + args: [], + line: 108, + column: 168, + context: null, + }); + }); + + it('should parse PhantomJS 1.19 error', () => { + const stackFrames = getTraceKit().computeStackTrace( + CapturedExceptions.PHANTOMJS_1_19 as unknown as Error, + ); + expect(stackFrames).toBeTruthy(); + expect(stackFrames.stack.length).toBe(3); + expect(stackFrames.stack[0]).toEqual({ + url: 'file:///path/to/file.js', + func: '?', + args: [], + line: 878, + column: null, + context: null, + }); + expect(stackFrames.stack[1]).toEqual({ + url: 'http://path/to/file.js', + func: 'foo', + args: [], + line: 4283, + column: null, + context: null, + }); + expect(stackFrames.stack[2]).toEqual({ + url: 'http://path/to/file.js', + func: '?', + args: [], + line: 4287, + column: null, + context: null, + }); + }); + + it('should parse Firefox errors with resource: URLs', () => { + const stackFrames = getTraceKit().computeStackTrace(CapturedExceptions.FIREFOX_50_RESOURCE_URL); + expect(stackFrames).toBeTruthy(); + expect(stackFrames.stack.length).toBe(3); + expect(stackFrames.stack[0]).toEqual({ + url: 'resource://path/data/content/bundle.js', + func: 'render', + args: [], + line: 5529, + column: 16, + context: null, + }); + }); + + it('should parse Firefox errors with eval URLs', () => { + const stackFrames = getTraceKit().computeStackTrace( + CapturedExceptions.FIREFOX_43_EVAL as unknown as Error, + ); + expect(stackFrames).toBeTruthy(); + expect(stackFrames.stack.length).toBe(5); + expect(stackFrames.stack[0]).toEqual({ + url: 'http://localhost:8080/file.js', + func: 'baz', + args: [], + line: 26, + column: null, + context: null, + }); + expect(stackFrames.stack[1]).toEqual({ + url: 'http://localhost:8080/file.js', + func: 'foo', + args: [], + line: 26, + column: null, + context: null, + }); + expect(stackFrames.stack[2]).toEqual({ + url: 'http://localhost:8080/file.js', + func: '?', + args: [], + line: 26, + column: null, + context: null, + }); + expect(stackFrames.stack[3]).toEqual({ + url: 'http://localhost:8080/file.js', + func: 'speak', + args: [], + line: 26, + column: 17, + context: null, + }); + expect(stackFrames.stack[4]).toEqual({ + url: 'http://localhost:8080/file.js', + func: '?', + args: [], + line: 33, + column: 9, + context: null, + }); + }); + + it('should parse React Native errors on Android', () => { + const stackFrames = getTraceKit().computeStackTrace(CapturedExceptions.ANDROID_REACT_NATIVE); + expect(stackFrames).toBeTruthy(); + expect(stackFrames.stack.length).toBe(8); + expect(stackFrames.stack[0]).toEqual({ + url: '/home/username/sample-workspace/sampleapp.collect.react/src/components/GpsMonitorScene.js', + func: 'render', + args: [], + line: 78, + column: 24, + context: null, + }); + expect(stackFrames.stack[7]).toEqual({ + url: '/home/username/sample-workspace/sampleapp.collect.react/node_modules/react-native/Libraries/Renderer/src/renderers/native/ReactNativeBaseComponent.js', + func: 'this', + args: [], + line: 74, + column: 41, + context: null, + }); + }); + + it('should parse React Native errors on Android Production', () => { + const stackFrames = getTraceKit().computeStackTrace( + CapturedExceptions.ANDROID_REACT_NATIVE_PROD, + ); + expect(stackFrames).toBeTruthy(); + expect(stackFrames.stack.length).toBe(37); + expect(stackFrames.stack[0]).toEqual({ + url: 'index.android.bundle', + func: 'value', + args: [], + line: 12, + column: 1917, + context: null, + }); + expect(stackFrames.stack[35]).toEqual({ + url: 'index.android.bundle', + func: 'value', + args: [], + line: 29, + column: 927, + context: null, + }); + expect(stackFrames.stack[36]).toEqual({ + url: '[native code]', + func: '?', + args: [], + line: null, + column: null, + context: null, + }); + }); +}); diff --git a/packages/telemetry/browser-telemetry/package.json b/packages/telemetry/browser-telemetry/package.json index 2b35364212..b6a49eb791 100644 --- a/packages/telemetry/browser-telemetry/package.json +++ b/packages/telemetry/browser-telemetry/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/browser-telemetry", - "version": "0.0.9", + "version": "1.0.6", "packageManager": "yarn@3.4.1", "type": "module", "main": "./dist/index.cjs", @@ -43,13 +43,9 @@ "bugs": { "url": "https://github.com/launchdarkly/js-core/issues" }, - "dependencies": { - "rrweb": "2.0.0-alpha.4", - "tracekit": "^0.4.6" - }, "devDependencies": { "@jest/globals": "^29.7.0", - "@launchdarkly/js-client-sdk": "0.3.2", + "@launchdarkly/js-client-sdk": "0.5.3", "@trivago/prettier-plugin-sort-imports": "^4.1.1", "@types/css-font-loading-module": "^0.0.13", "@types/jest": "^29.5.11", diff --git a/packages/telemetry/browser-telemetry/src/BrowserTelemetryImpl.ts b/packages/telemetry/browser-telemetry/src/BrowserTelemetryImpl.ts index 41ce9a3510..4b372e6489 100644 --- a/packages/telemetry/browser-telemetry/src/BrowserTelemetryImpl.ts +++ b/packages/telemetry/browser-telemetry/src/BrowserTelemetryImpl.ts @@ -1,15 +1,14 @@ -import * as TraceKit from 'tracekit'; - /** * A limited selection of type information is provided by the browser client SDK. * This is only a type dependency and these types should be compatible between * SDKs. */ -import type { LDContext, LDEvaluationDetail, LDInspection } from '@launchdarkly/js-client-sdk'; +import type { LDContext, LDEvaluationDetail } from '@launchdarkly/js-client-sdk'; -import { LDClientTracking } from './api'; +import { LDClientInitialization, LDClientLogging, LDClientTracking, MinLogger } from './api'; import { Breadcrumb, FeatureManagementBreadcrumb } from './api/Breadcrumb'; import { BrowserTelemetry } from './api/BrowserTelemetry'; +import { BrowserTelemetryInspector } from './api/client/BrowserTelemetryInspector'; import { Collector } from './api/Collector'; import { ErrorData } from './api/ErrorData'; import { EventData } from './api/EventData'; @@ -20,19 +19,27 @@ import FetchCollector from './collectors/http/fetch'; import XhrCollector from './collectors/http/xhr'; import defaultUrlFilter from './filters/defaultUrlFilter'; import makeInspectors from './inspectors'; +import { fallbackLogger, prefixLog } from './logging'; import { ParsedOptions, ParsedStackOptions } from './options'; import randomUuidV4 from './randomUuidV4'; import parse from './stack/StackParser'; +import { getTraceKit } from './vendor/TraceKit'; // TODO: Use a ring buffer for the breadcrumbs/pending events instead of shifting. (SDK-914) const CUSTOM_KEY_PREFIX = '$ld:telemetry'; const ERROR_KEY = `${CUSTOM_KEY_PREFIX}:error`; -const SESSION_CAPTURE_KEY = `${CUSTOM_KEY_PREFIX}:sessionCapture`; +const SESSION_INIT_KEY = `${CUSTOM_KEY_PREFIX}:session:init`; const GENERIC_EXCEPTION = 'generic'; const NULL_EXCEPTION_MESSAGE = 'exception was null or undefined'; const MISSING_MESSAGE = 'exception had no message'; +// Timeout for client initialization. The telemetry SDK doesn't require that the client be initialized, but it does +// require that the context processing that happens during initialization complete. This is some subset of the total +// initialization time, but we don't care if initialization actually completes within the, just that the context +// is available for event sending. +const INITIALIZATION_TIMEOUT = 5; + /** * Given a flag value ensure it is safe for analytics. * @@ -53,14 +60,41 @@ function safeValue(u: unknown): string | boolean | number | undefined { } } +function applyFilter(item: T | undefined, filter: (item: T) => T | undefined): T | undefined { + return item === undefined ? undefined : filter(item); +} + function configureTraceKit(options: ParsedStackOptions) { + if (!options.enabled) { + return; + } + + const TraceKit = getTraceKit(); // Include before + after + source line. // TraceKit only takes a total context size, so we have to over capture and then reduce the lines. // So, for instance if before is 3 and after is 4 we need to capture 4 and 4 and then drop a line // from the before context. // The typing for this is a bool, but it accepts a number. const beforeAfterMax = Math.max(options.source.afterLines, options.source.beforeLines); - (TraceKit as any).linesOfContext = beforeAfterMax * 2 + 1; + // The assignment here has bene split to prevent esbuild from complaining about an assignment to + // an import. TraceKit exports a single object and the interface requires modifying an exported + // var. + const anyObj = TraceKit as any; + anyObj.linesOfContext = beforeAfterMax * 2 + 1; +} + +/** + * Check if the client supports LDClientLogging. + * + * @param client The client to check. + * @returns True if the client is an instance of LDClientLogging. + */ +function isLDClientLogging(client: unknown): client is LDClientLogging { + return (client as any).logger !== undefined; +} + +function isLDClientInitialization(client: unknown): client is LDClientInitialization { + return (client as any).waitForInitialization !== undefined; } export default class BrowserTelemetryImpl implements BrowserTelemetry { @@ -72,10 +106,21 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry { private _breadcrumbs: Breadcrumb[] = []; - private _inspectorInstances: LDInspection[] = []; + private _inspectorInstances: BrowserTelemetryInspector[] = []; private _collectors: Collector[] = []; private _sessionId: string = randomUuidV4(); + private _logger: MinLogger; + + private _registrationComplete: boolean = false; + + // Used to ensure we only log the event dropped message once. + private _eventsDropped: boolean = false; + // Used to ensure we only log the breadcrumb filter error once. + private _breadcrumbFilterError: boolean = false; + // Used to ensure we only log the error filter error once. + private _errorFilterError: boolean = false; + constructor(private _options: ParsedOptions) { configureTraceKit(_options.stack); @@ -86,6 +131,10 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry { this._maxPendingEvents = _options.maxPendingEvents; this._maxBreadcrumbs = _options.breadcrumbs.maxBreadcrumbs; + // Set the initial logger, it may be replaced when the client is registered. + // For typescript purposes, we need the logger to be directly set in the constructor. + this._logger = this._options.logger ?? fallbackLogger; + const urlFilters = [defaultUrlFilter]; if (_options.breadcrumbs.http.customUrlFilter) { urlFilters.push(_options.breadcrumbs.http.customUrlFilter); @@ -95,6 +144,7 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry { this._collectors.push( new FetchCollector({ urlFilters, + getLogger: () => this._logger, }), ); } @@ -103,6 +153,7 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry { this._collectors.push( new XhrCollector({ urlFilters, + getLogger: () => this._logger, }), ); } @@ -120,19 +171,66 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry { ); const impl = this; - const inspectors: LDInspection[] = []; + const inspectors: BrowserTelemetryInspector[] = []; makeInspectors(_options, inspectors, impl); this._inspectorInstances.push(...inspectors); } register(client: LDClientTracking): void { + if (this._client !== undefined) { + return; + } + this._client = client; - this._pendingEvents.forEach((event) => { - this._client?.track(event.type, event.data); - }); + + // When the client is registered, we need to set the logger again, because we may be able to use the client's + // logger. + this._setLogger(); + + const completeRegistration = () => { + this._client?.track(SESSION_INIT_KEY, { sessionId: this._sessionId }); + + this._pendingEvents.forEach((event) => { + this._client?.track(event.type, event.data); + }); + this._pendingEvents = []; + this._registrationComplete = true; + }; + + if (isLDClientInitialization(client)) { + // We don't actually need the client initialization to complete, but we do need the context processing that + // happens during initialization to complete. This time will be some time greater than that, but we don't + // care if initialization actually completes within the timeout. + + // An immediately invoked async function is used to ensure that the registration method can be called synchronously. + // Making the `register` method async would increase the complexity for application developers. + (async () => { + try { + await client.waitForInitialization(INITIALIZATION_TIMEOUT); + } catch { + // We don't care if the initialization fails. + } + completeRegistration(); + })(); + } else { + // TODO(EMSR-36): Figure out how to handle the 4.x implementation. + completeRegistration(); + } } - inspectors(): LDInspection[] { + private _setLogger() { + // If the user has provided a logger, then we want to prioritize that over the client's logger. + // If the client supports LDClientLogging, then we to prioritize that over the fallback logger. + if (this._options.logger) { + this._logger = this._options.logger; + } else if (isLDClientLogging(this._client)) { + this._logger = this._client.logger; + } else { + this._logger = fallbackLogger; + } + } + + inspectors(): BrowserTelemetryInspector[] { return this._inspectorInstances; } @@ -145,14 +243,32 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry { * @param event The event data. */ private _capture(type: string, event: EventData) { - if (this._client === undefined) { - this._pendingEvents.push({ type, data: event }); + const filteredEvent = this._applyFilters(event, this._options.errorFilters, (e: unknown) => { + if (!this._errorFilterError) { + this._errorFilterError = true; + this._logger.warn(prefixLog(`Error applying error filters: ${e}`)); + } + }); + if (filteredEvent === undefined) { + return; + } + + if (this._registrationComplete) { + this._client?.track(type, filteredEvent); + } else { + this._pendingEvents.push({ type, data: filteredEvent }); if (this._pendingEvents.length > this._maxPendingEvents) { - // TODO: Log when pending events must be dropped. (SDK-915) + if (!this._eventsDropped) { + this._eventsDropped = true; + this._logger.warn( + prefixLog( + `Maximum pending events reached. Old events will be dropped until the SDK client is registered.`, + ), + ); + } this._pendingEvents.shift(); } } - this._client?.track(type, event); } captureError(exception: Error): void { @@ -181,14 +297,39 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry { this.captureError(errorEvent.error); } - captureSession(sessionEvent: EventData): void { - this._capture(SESSION_CAPTURE_KEY, { ...sessionEvent, breadcrumbs: [...this._breadcrumbs] }); + private _applyFilters( + item: T, + filters: ((item: T) => T | undefined)[], + handleError: (e: unknown) => void, + ): T | undefined { + try { + return filters.reduce( + (itemToFilter: T | undefined, filter: (item: T) => T | undefined) => + applyFilter(itemToFilter, filter), + item, + ); + } catch (e) { + handleError(e); + return undefined; + } } addBreadcrumb(breadcrumb: Breadcrumb): void { - this._breadcrumbs.push(breadcrumb); - if (this._breadcrumbs.length > this._maxBreadcrumbs) { - this._breadcrumbs.shift(); + const filtered = this._applyFilters( + breadcrumb, + this._options.breadcrumbs.filters, + (e: unknown) => { + if (!this._breadcrumbFilterError) { + this._breadcrumbFilterError = true; + this._logger.warn(prefixLog(`Error applying breadcrumb filters: ${e}`)); + } + }, + ); + if (filtered !== undefined) { + this._breadcrumbs.push(filtered); + if (this._breadcrumbs.length > this._maxBreadcrumbs) { + this._breadcrumbs.shift(); + } } } @@ -197,7 +338,7 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry { } /** - * Used to automatically collect flag usage for breacrumbs. + * Used to automatically collect flag usage for breadcrumbs. * * When session replay is in use the data is also forwarded to the session * replay collector. diff --git a/packages/telemetry/browser-telemetry/src/api/Breadcrumb.ts b/packages/telemetry/browser-telemetry/src/api/Breadcrumb.ts index dfeb0ec19f..ba8d755968 100644 --- a/packages/telemetry/browser-telemetry/src/api/Breadcrumb.ts +++ b/packages/telemetry/browser-telemetry/src/api/Breadcrumb.ts @@ -68,7 +68,7 @@ export interface Breadcrumb { /** * Utility type which allows for easy extension of base breadcrumb type. */ -type ImplementsCrumb = U; +export type ImplementsCrumb = U; /** * Type for custom breadcrumbs. diff --git a/packages/telemetry/browser-telemetry/src/api/BrowserTelemetry.ts b/packages/telemetry/browser-telemetry/src/api/BrowserTelemetry.ts index 74ec31f575..34156805c3 100644 --- a/packages/telemetry/browser-telemetry/src/api/BrowserTelemetry.ts +++ b/packages/telemetry/browser-telemetry/src/api/BrowserTelemetry.ts @@ -1,6 +1,5 @@ -import type { LDInspection } from '@launchdarkly/js-client-sdk'; - import { Breadcrumb } from './Breadcrumb'; +import { BrowserTelemetryInspector } from './client/BrowserTelemetryInspector'; import { LDClientTracking } from './client/LDClientTracking'; /** @@ -15,9 +14,9 @@ export interface BrowserTelemetry { * Returns an array of active SDK inspectors to use with SDK versions that do * not support hooks. * - * @returns An array of {@link LDInspection} objects. + * @returns An array of {@link BrowserTelemetryInspector} objects. */ - inspectors(): LDInspection[]; + inspectors(): BrowserTelemetryInspector[]; /** * Captures an Error object for telemetry purposes. diff --git a/packages/telemetry/browser-telemetry/src/MinLogger.ts b/packages/telemetry/browser-telemetry/src/api/MinLogger.ts similarity index 72% rename from packages/telemetry/browser-telemetry/src/MinLogger.ts rename to packages/telemetry/browser-telemetry/src/api/MinLogger.ts index dced72e958..6c76e558d1 100644 --- a/packages/telemetry/browser-telemetry/src/MinLogger.ts +++ b/packages/telemetry/browser-telemetry/src/api/MinLogger.ts @@ -5,5 +5,11 @@ * This allows usage with multiple SDK versions. */ export interface MinLogger { + /** + * The warning logger. + * + * @param args + * A sequence of any JavaScript values. + */ warn(...args: any[]): void; } diff --git a/packages/telemetry/browser-telemetry/src/api/Options.ts b/packages/telemetry/browser-telemetry/src/api/Options.ts index 4238af19b6..c3c847579a 100644 --- a/packages/telemetry/browser-telemetry/src/api/Options.ts +++ b/packages/telemetry/browser-telemetry/src/api/Options.ts @@ -1,4 +1,7 @@ +import { Breadcrumb } from './Breadcrumb'; import { Collector } from './Collector'; +import { ErrorData } from './ErrorData'; +import { MinLogger } from './MinLogger'; /** * Interface for URL filters. @@ -22,7 +25,25 @@ export interface UrlFilter { (url: string): string; } -export interface HttpBreadCrumbOptions { +/** + * Interface for breadcrumb filters. + * + * Given a breadcrumb the filter may return a modified breadcrumb or undefined to exclude the breadcrumb. + */ +export interface BreadcrumbFilter { + (breadcrumb: Breadcrumb): Breadcrumb | undefined; +} + +/** + * Interface for filtering error data before it is sent to LaunchDarkly. + * + * Given {@link ErrorData} the filter may return modified data or undefined to exclude the breadcrumb. + */ +export interface ErrorDataFilter { + (event: ErrorData): ErrorData | undefined; +} + +export interface HttpBreadcrumbOptions { /** * If fetch should be instrumented and breadcrumbs included for fetch requests. * @@ -72,6 +93,86 @@ export interface StackOptions { }; } +export interface BreadcrumbsOptions { + /** + * Set the maximum number of breadcrumbs. Defaults to 50. + */ + maxBreadcrumbs?: number; + + /** + * True to enable automatic evaluation breadcrumbs. Defaults to true. + */ + evaluations?: boolean; + + /** + * True to enable flag change breadcrumbs. Defaults to true. + */ + flagChange?: boolean; + + /** + * True to enable click breadcrumbs. Defaults to true. + */ + click?: boolean; + + /** + * True to enable input breadcrumbs for keypresses. Defaults to true. + * + * Input breadcrumbs do not include entered text, just that text was entered. + */ + keyboardInput?: boolean; + + /** + * Controls instrumentation and breadcrumbs for HTTP requests. + * The default is to instrument XMLHttpRequests and fetch requests. + * + * `false` to disable all HTTP breadcrumbs and instrumentation. + * + * Example: + * ``` + * // This would instrument only XmlHttpRequests + * http: { + * instrumentFetch: false + * instrumentXhr: true + * } + * + * // Disable all HTTP instrumentation: + * http: false + * ``` + */ + http?: HttpBreadcrumbOptions | false; + + /** + * Custom breadcrumb filters. + * + * Can be used to redact or modify breadcrumbs. + * + * Example: + * ``` + * // We want to redact any click events that include the message 'sneaky-button' + * filters: [ + * (breadcrumb) => { + * if( + * breadcrumb.class === 'ui' && + * breadcrumb.type === 'click' && + * breadcrumb.message?.includes('sneaky-button') + * ) { + * return; + * } + * return breadcrumb; + * } + * ] + * ``` + * + * If you want to redact or modify URLs in breadcrumbs, then a urlFilter should be used. + * + * If any breadcrumb filters throw an exception while processing a breadcrumb, then that breadcrumb will be excluded. + * + * If any breadcrumbFilter cannot be executed, for example because it is not a function, then all breadcrumbs will + * be excluded. + */ + filters?: BreadcrumbFilter[]; +} + /** * Options for configuring browser telemetry. */ @@ -82,57 +183,11 @@ export interface Options { * events captured during initialization. */ maxPendingEvents?: number; + /** - * Properties related to automatic breadcrumb collection. + * Properties related to automatic breadcrumb collection, or `false` to disable automatic breadcrumbs. */ - breadcrumbs?: { - /** - * Set the maximum number of breadcrumbs. Defaults to 50. - */ - maxBreadcrumbs?: number; - - /** - * True to enable automatic evaluation breadcrumbs. Defaults to true. - */ - evaluations?: boolean; - - /** - * True to enable flag change breadcrumbs. Defaults to true. - */ - flagChange?: boolean; - - /** - * True to enable click breadcrumbs. Defaults to true. - */ - click?: boolean; - - /** - * True to enable input breadcrumbs for keypresses. Defaults to true. - * - * Input breadcrumbs do not include entered text, just that text was entered. - */ - keyboardInput?: boolean; - - /** - * Controls instrumentation and breadcrumbs for HTTP requests. - * The default is to instrument XMLHttpRequests and fetch requests. - * - * `false` to disable all HTTP breadcrumbs and instrumentation. - * - * Example: - * ``` - * // This would instrument only XmlHttpRequests - * http: { - * instrumentFetch: false - * instrumentXhr: true - * } - * - * // Disable all HTTP instrumentation: - * http: false - * ``` - */ - http?: HttpBreadCrumbOptions | false; - }; + breadcrumbs?: BreadcrumbsOptions | false; /** * Additional, or custom, collectors. @@ -140,7 +195,30 @@ export interface Options { collectors?: Collector[]; /** - * Configuration that controls the capture of the stack trace. + * Configuration that controls the capture of the stack trace, or `false` to exclude stack frames from error events. + */ + stack?: StackOptions | false; + + /** + * Logger to use for warnings. + * + * This option is compatible with the `LDLogger` interface used by the LaunchDarkly SDK. + * + * If this option is not provided, the logs will be written to console.log unless the LaunchDarkly SDK is registered, + * and the registered SDK instance exposes its logger. In which case, the logs will be written to the registered SDK's + * logger. The 3.x SDKs do not expose their logger. + */ + logger?: MinLogger; + + /** + * Custom error data filters. + * + * Can be used to redact or modify error data. + * + * If any filter throws an exception, then the error data will be discarded. + * + * For filtering breadcrumbs or URLs in error data, refer to the `breadcrumbs.filters` option in {@link breadcrumbs} and + * `breadcrumbs.http.customUrlFilter` - {@link HttpBreadcrumbOptions.customUrlFilter}. */ - stack?: StackOptions; + errorFilters?: ErrorDataFilter[]; } diff --git a/packages/telemetry/browser-telemetry/src/api/client/BrowserTelemetryInspector.ts b/packages/telemetry/browser-telemetry/src/api/client/BrowserTelemetryInspector.ts new file mode 100644 index 0000000000..2a5b8db77c --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/api/client/BrowserTelemetryInspector.ts @@ -0,0 +1,29 @@ +/** + * A less constrained version of the LDInspection interface in order to allow for greater compatibility between + * SDK versions. + * + * This interface is not intended for use by application developers and is instead intended as a compatibility bridge + * to support multiple SDK versions. + */ +export interface BrowserTelemetryInspector { + /** + * The telemetry package only requires flag-detail-changed inspectors and flag-used inspectors. + */ + type: 'flag-used' | 'flag-detail-changed'; + + /** + * The name of the inspector, used for debugging purposes. + */ + name: string; + /** + * Whether the inspector is synchronous. + */ + synchronous: boolean; + /** + * The method to call when the inspector is triggered. + * + * The typing here is intentionally loose to allow for greater compatibility between SDK versions. + * This function should ONLY be called by an SDK instance and not by an application developer. + */ + method: (...args: any[]) => void; +} diff --git a/packages/telemetry/browser-telemetry/src/api/client/LDClientInitialization.ts b/packages/telemetry/browser-telemetry/src/api/client/LDClientInitialization.ts new file mode 100644 index 0000000000..acf6f578cb --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/api/client/LDClientInitialization.ts @@ -0,0 +1,6 @@ +/** + * Minimal client interface which allows waiting for initialization. + */ +export interface LDClientInitialization { + waitForInitialization(timeout?: number): Promise; +} diff --git a/packages/telemetry/browser-telemetry/src/api/client/LDClientLogging.ts b/packages/telemetry/browser-telemetry/src/api/client/LDClientLogging.ts new file mode 100644 index 0000000000..7e500a0b3c --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/api/client/LDClientLogging.ts @@ -0,0 +1,8 @@ +import { MinLogger } from '../MinLogger'; + +/** + * Minimal client interface which allows for loggng. Works with 4.x and higher versions of the javascript client. + */ +export interface LDClientLogging { + readonly logger: MinLogger; +} diff --git a/packages/telemetry/browser-telemetry/src/api/client/index.ts b/packages/telemetry/browser-telemetry/src/api/client/index.ts index d363ce8c70..8ed56f88c8 100644 --- a/packages/telemetry/browser-telemetry/src/api/client/index.ts +++ b/packages/telemetry/browser-telemetry/src/api/client/index.ts @@ -1 +1,4 @@ export * from './LDClientTracking'; +export * from './LDClientLogging'; +export * from './BrowserTelemetryInspector'; +export * from './LDClientInitialization'; diff --git a/packages/telemetry/browser-telemetry/src/api/index.ts b/packages/telemetry/browser-telemetry/src/api/index.ts index b71214eb41..7e3e94a7c3 100644 --- a/packages/telemetry/browser-telemetry/src/api/index.ts +++ b/packages/telemetry/browser-telemetry/src/api/index.ts @@ -5,3 +5,5 @@ export * from './Options'; export * from './Recorder'; export * from './stack'; export * from './client'; +export * from './MinLogger'; +export * from './BrowserTelemetry'; diff --git a/packages/telemetry/browser-telemetry/src/collectors/http/HttpCollectorOptions.ts b/packages/telemetry/browser-telemetry/src/collectors/http/HttpCollectorOptions.ts index 2f6c4bab47..13313a5a1b 100644 --- a/packages/telemetry/browser-telemetry/src/collectors/http/HttpCollectorOptions.ts +++ b/packages/telemetry/browser-telemetry/src/collectors/http/HttpCollectorOptions.ts @@ -1,3 +1,4 @@ +import { MinLogger } from '../../api/MinLogger'; import { UrlFilter } from '../../api/Options'; /** @@ -10,4 +11,12 @@ export default interface HttpCollectorOptions { * This allows for redaction of potentially sensitive information in URLs. */ urlFilters: UrlFilter[]; + + /** + * Method to get a logger for warnings. + * + * This is a function to allow for accessing the current logger, as the logger + * instance may change during runtime. + */ + getLogger?: () => MinLogger; } diff --git a/packages/telemetry/browser-telemetry/src/collectors/http/fetch.ts b/packages/telemetry/browser-telemetry/src/collectors/http/fetch.ts index 0baa0739b4..c53716cc21 100644 --- a/packages/telemetry/browser-telemetry/src/collectors/http/fetch.ts +++ b/packages/telemetry/browser-telemetry/src/collectors/http/fetch.ts @@ -9,11 +9,25 @@ import HttpCollectorOptions from './HttpCollectorOptions'; */ export default class FetchCollector implements Collector { private _destination?: Recorder; + private _loggedIssue: boolean = false; constructor(options: HttpCollectorOptions) { decorateFetch((breadcrumb) => { - filterHttpBreadcrumb(breadcrumb, options); - this._destination?.addBreadcrumb(breadcrumb); + let filtersExecuted = false; + try { + filterHttpBreadcrumb(breadcrumb, options); + filtersExecuted = true; + } catch (err) { + if (!this._loggedIssue) { + options.getLogger?.()?.warn('Error filtering http breadcrumb', err); + this._loggedIssue = true; + } + } + // Only add the breadcrumb if the filter didn't throw. We don't want to + // report a breadcrumb that may have not have had the correct information redacted. + if (filtersExecuted) { + this._destination?.addBreadcrumb(breadcrumb); + } }); } diff --git a/packages/telemetry/browser-telemetry/src/collectors/http/fetchDecorator.ts b/packages/telemetry/browser-telemetry/src/collectors/http/fetchDecorator.ts index 85dea49da7..d87d26d07d 100644 --- a/packages/telemetry/browser-telemetry/src/collectors/http/fetchDecorator.ts +++ b/packages/telemetry/browser-telemetry/src/collectors/http/fetchDecorator.ts @@ -77,7 +77,8 @@ export default function decorateFetch(callback: (breadcrumb: HttpBreadcrumb) => return response; }); } - wrapper.prototype = originalFetch.prototype; + + wrapper.prototype = originalFetch?.prototype; try { // Use defineProperty to prevent this value from being enumerable. diff --git a/packages/telemetry/browser-telemetry/src/collectors/http/xhr.ts b/packages/telemetry/browser-telemetry/src/collectors/http/xhr.ts index bf9f3b9b12..2708cf937e 100644 --- a/packages/telemetry/browser-telemetry/src/collectors/http/xhr.ts +++ b/packages/telemetry/browser-telemetry/src/collectors/http/xhr.ts @@ -10,11 +10,25 @@ import decorateXhr from './xhrDecorator'; */ export default class XhrCollector implements Collector { private _destination?: Recorder; + private _loggedIssue: boolean = false; constructor(options: HttpCollectorOptions) { decorateXhr((breadcrumb) => { - filterHttpBreadcrumb(breadcrumb, options); - this._destination?.addBreadcrumb(breadcrumb); + let filtersExecuted = false; + try { + filterHttpBreadcrumb(breadcrumb, options); + filtersExecuted = true; + } catch (err) { + if (!this._loggedIssue) { + options.getLogger?.()?.warn('Error filtering http breadcrumb', err); + this._loggedIssue = true; + } + } + // Only add the breadcrumb if the filter didn't throw. We don't want to + // report a breadcrumb that may have not have had the correct information redacted. + if (filtersExecuted) { + this._destination?.addBreadcrumb(breadcrumb); + } }); } diff --git a/packages/telemetry/browser-telemetry/src/filters/defaultUrlFilter.ts b/packages/telemetry/browser-telemetry/src/filters/defaultUrlFilter.ts index a81c45722c..9ae2062545 100644 --- a/packages/telemetry/browser-telemetry/src/filters/defaultUrlFilter.ts +++ b/packages/telemetry/browser-telemetry/src/filters/defaultUrlFilter.ts @@ -1,13 +1,52 @@ const pollingRegex = /sdk\/evalx\/[^/]+\/contexts\/(?[^/?]*)\??.*?/; const streamingREgex = /\/eval\/[^/]+\/(?[^/?]*)\??.*?/; +/** + * Filter which redacts user information (auth) from a URL. + * + * If a username/password is present, then they are replaced with 'redacted'. + * Authority reference: https://developer.mozilla.org/en-US/docs/Web/URI/Authority + * + * @param url URL to filter. + * @returns A filtered URL. + */ +function authorityUrlFilter(url: string): string { + // This will work in browser environments, but in the future we may want to consider an approach + // which doesn't rely on the browser's URL parsing. This is because other environments we may + // want to target, such as ReactNative, may not have as robust URL parsing. + // We first check if the URL can be parsed, because it may not include the base URL. + try { + // If the URL includes a protocol, if so, then it can probably be parsed. + // Credentials require a full URL. + if (url.includes('://')) { + const urlObj = new URL(url); + let hadAuth = false; + if (urlObj.username) { + urlObj.username = 'redacted'; + hadAuth = true; + } + if (urlObj.password) { + urlObj.password = 'redacted'; + hadAuth = true; + } + if (hadAuth) { + return urlObj.toString(); + } + } + } catch { + // Could not parse the URL. + } + // If there was no auth information, then we don't need to modify the URL. + return url; +} + /** * Filter which removes context information for browser JavaScript endpoints. * * @param url URL to filter. * @returns A filtered URL. */ -export default function defaultUrlFilter(url: string): string { +function ldUrlFilter(url: string): string { // TODO: Maybe we consider a way to identify LD requests so they can be filtered without // regular expressions. @@ -27,3 +66,13 @@ export default function defaultUrlFilter(url: string): string { } return url; } + +/** + * Filter which redacts user information and removes context information for browser JavaScript endpoints. + * + * @param url URL to filter. + * @returns A filtered URL. + */ +export default function defaultUrlFilter(url: string): string { + return ldUrlFilter(authorityUrlFilter(url)); +} diff --git a/packages/telemetry/browser-telemetry/src/index.ts b/packages/telemetry/browser-telemetry/src/index.ts index b1c13e7340..976dd96717 100644 --- a/packages/telemetry/browser-telemetry/src/index.ts +++ b/packages/telemetry/browser-telemetry/src/index.ts @@ -1 +1,25 @@ +import { BrowserTelemetry } from './api/BrowserTelemetry'; +import { Options } from './api/Options'; +import BrowserTelemetryImpl from './BrowserTelemetryImpl'; +import { safeMinLogger } from './logging'; +import parse from './options'; + export * from './api'; + +export * from './singleton'; + +/** + * Initialize a new telemetry instance. + * + * This instance is not global. Generally developers should use {@link initTelemetry} instead. + * + * If for some reason multiple telemetry instances are needed, this method can be used to create a new instance. + * Instances are not aware of each other and may send duplicate data from automatically captured events. + * + * @param options The options to use for the telemetry instance. + * @returns A telemetry instance. + */ +export function initTelemetryInstance(options?: Options): BrowserTelemetry { + const parsedOptions = parse(options || {}, safeMinLogger(options?.logger)); + return new BrowserTelemetryImpl(parsedOptions); +} diff --git a/packages/telemetry/browser-telemetry/src/inspectors.ts b/packages/telemetry/browser-telemetry/src/inspectors.ts index 5f8e65079e..c9f026e0bf 100644 --- a/packages/telemetry/browser-telemetry/src/inspectors.ts +++ b/packages/telemetry/browser-telemetry/src/inspectors.ts @@ -1,5 +1,6 @@ -import type { LDContext, LDEvaluationDetail, LDInspection } from '@launchdarkly/js-client-sdk'; +import type { LDContext, LDEvaluationDetail } from '@launchdarkly/js-client-sdk'; +import { BrowserTelemetryInspector } from './api/client/BrowserTelemetryInspector.js'; import BrowserTelemetryImpl from './BrowserTelemetryImpl.js'; import { ParsedOptions } from './options.js'; @@ -12,7 +13,7 @@ import { ParsedOptions } from './options.js'; */ export default function makeInspectors( options: ParsedOptions, - inspectors: LDInspection[], + inspectors: BrowserTelemetryInspector[], telemetry: BrowserTelemetryImpl, ) { if (options.breadcrumbs.evaluations) { diff --git a/packages/telemetry/browser-telemetry/src/logging.ts b/packages/telemetry/browser-telemetry/src/logging.ts new file mode 100644 index 0000000000..8bd6e79463 --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/logging.ts @@ -0,0 +1,33 @@ +import { MinLogger } from './api'; + +export const fallbackLogger: MinLogger = { + // Intentionally using console.warn as a fallback logger. + // eslint-disable-next-line no-console + warn: console.warn, +}; + +const loggingPrefix = 'LaunchDarkly - Browser Telemetry:'; + +export function prefixLog(message: string) { + return `${loggingPrefix} ${message}`; +} + +export function safeMinLogger(logger: MinLogger | undefined): MinLogger { + return { + warn: (...args: any[]) => { + if (!logger) { + fallbackLogger.warn(...args); + return; + } + + try { + logger.warn(...args); + } catch { + fallbackLogger.warn(...args); + fallbackLogger.warn( + prefixLog('The provided logger threw an exception, using fallback logger.'), + ); + } + }, + }; +} diff --git a/packages/telemetry/browser-telemetry/src/options.ts b/packages/telemetry/browser-telemetry/src/options.ts index a801f5ed47..477c149775 100644 --- a/packages/telemetry/browser-telemetry/src/options.ts +++ b/packages/telemetry/browser-telemetry/src/options.ts @@ -1,6 +1,38 @@ import { Collector } from './api/Collector'; -import { HttpBreadCrumbOptions, Options, StackOptions, UrlFilter } from './api/Options'; -import { MinLogger } from './MinLogger'; +import { MinLogger } from './api/MinLogger'; +import { + BreadcrumbFilter, + BreadcrumbsOptions, + ErrorDataFilter, + HttpBreadcrumbOptions, + Options, + StackOptions, + UrlFilter, +} from './api/Options'; +import { fallbackLogger, prefixLog, safeMinLogger } from './logging'; + +const disabledBreadcrumbs: ParsedBreadcrumbsOptions = { + maxBreadcrumbs: 0, + evaluations: false, + flagChange: false, + click: false, + keyboardInput: false, + http: { + instrumentFetch: false, + instrumentXhr: false, + customUrlFilter: undefined, + }, + filters: [], +}; + +const disabledStack: ParsedStackOptions = { + enabled: false, + source: { + beforeLines: 0, + afterLines: 0, + maxLineLength: 0, + }, +}; export function defaultOptions(): ParsedOptions { return { @@ -14,8 +46,10 @@ export function defaultOptions(): ParsedOptions { instrumentFetch: true, instrumentXhr: true, }, + filters: [], }, stack: { + enabled: true, source: { beforeLines: 3, afterLines: 3, @@ -24,11 +58,14 @@ export function defaultOptions(): ParsedOptions { }, maxPendingEvents: 100, collectors: [], + errorFilters: [], }; } function wrongOptionType(name: string, expectedType: string, actualType: string): string { - return `Config option "${name}" should be of type ${expectedType}, got ${actualType}, using default value`; + return prefixLog( + `Config option "${name}" should be of type ${expectedType}, got ${actualType}, using default value`, + ); } function checkBasic(type: string, name: string, logger?: MinLogger): (item: T) => boolean { @@ -55,7 +92,7 @@ function itemOrDefault(item: T | undefined, defaultValue: T, checker?: (item: } function parseHttp( - options: HttpBreadCrumbOptions | false | undefined, + options: HttpBreadcrumbOptions | false | undefined, defaults: ParsedHttpOptions, logger?: MinLogger, ): ParsedHttpOptions { @@ -77,7 +114,9 @@ function parseHttp( if (options?.customUrlFilter) { if (typeof options.customUrlFilter !== 'function') { logger?.warn( - `The "breadcrumbs.http.customUrlFilter" must be a function. Received ${typeof options.customUrlFilter}`, + prefixLog( + `The "breadcrumbs.http.customUrlFilter" must be a function. Received ${typeof options.customUrlFilter}`, + ), ); } } @@ -101,12 +140,29 @@ function parseHttp( }; } +function parseLogger(options: Options): MinLogger | undefined { + if (options.logger) { + const { logger } = options; + if (typeof logger === 'object' && logger !== null && 'warn' in logger) { + return safeMinLogger(logger); + } + // Using console.warn here because the logger is not suitable to log with. + fallbackLogger.warn(wrongOptionType('logger', 'MinLogger or LDLogger', typeof logger)); + } + return undefined; +} + function parseStack( - options: StackOptions | undefined, + options: StackOptions | false | undefined, defaults: ParsedStackOptions, logger?: MinLogger, ): ParsedStackOptions { + if (options === false) { + return disabledStack; + } return { + // Internal option not parsed from the options object. + enabled: true, source: { beforeLines: itemOrDefault( options?.source?.beforeLines, @@ -127,6 +183,51 @@ function parseStack( }; } +function parseBreadcrumbs( + options: BreadcrumbsOptions | false | undefined, + defaults: ParsedBreadcrumbsOptions, + logger: MinLogger | undefined, +): ParsedBreadcrumbsOptions { + if (options === false) { + return disabledBreadcrumbs; + } + return { + maxBreadcrumbs: itemOrDefault( + options?.maxBreadcrumbs, + defaults.maxBreadcrumbs, + checkBasic('number', 'breadcrumbs.maxBreadcrumbs', logger), + ), + evaluations: itemOrDefault( + options?.evaluations, + defaults.evaluations, + checkBasic('boolean', 'breadcrumbs.evaluations', logger), + ), + flagChange: itemOrDefault( + options?.flagChange, + defaults.flagChange, + checkBasic('boolean', 'breadcrumbs.flagChange', logger), + ), + click: itemOrDefault( + options?.click, + defaults.click, + checkBasic('boolean', 'breadcrumbs.click', logger), + ), + keyboardInput: itemOrDefault( + options?.keyboardInput, + defaults.keyboardInput, + checkBasic('boolean', 'breadcrumbs.keyboardInput', logger), + ), + http: parseHttp(options?.http, defaults.http, logger), + filters: itemOrDefault(options?.filters, defaults.filters, (item) => { + if (Array.isArray(item)) { + return true; + } + logger?.warn(wrongOptionType('breadcrumbs.filters', 'BreadcrumbFilter[]', typeof item)); + return false; + }), + }; +} + export default function parse(options: Options, logger?: MinLogger): ParsedOptions { const defaults = defaultOptions(); if (options.breadcrumbs) { @@ -136,35 +237,8 @@ export default function parse(options: Options, logger?: MinLogger): ParsedOptio checkBasic('object', 'stack', logger)(options.stack); } return { - breadcrumbs: { - maxBreadcrumbs: itemOrDefault( - options.breadcrumbs?.maxBreadcrumbs, - defaults.breadcrumbs.maxBreadcrumbs, - checkBasic('number', 'breadcrumbs.maxBreadcrumbs', logger), - ), - evaluations: itemOrDefault( - options.breadcrumbs?.evaluations, - defaults.breadcrumbs.evaluations, - checkBasic('boolean', 'breadcrumbs.evaluations', logger), - ), - flagChange: itemOrDefault( - options.breadcrumbs?.flagChange, - defaults.breadcrumbs.flagChange, - checkBasic('boolean', 'breadcrumbs.flagChange', logger), - ), - click: itemOrDefault( - options.breadcrumbs?.click, - defaults.breadcrumbs.click, - checkBasic('boolean', 'breadcrumbs.click', logger), - ), - keyboardInput: itemOrDefault( - options.breadcrumbs?.keyboardInput, - defaults.breadcrumbs.keyboardInput, - checkBasic('boolean', 'breadcrumbs.keyboardInput', logger), - ), - http: parseHttp(options.breadcrumbs?.http, defaults.breadcrumbs.http, logger), - }, - stack: parseStack(options.stack, defaults.stack), + breadcrumbs: parseBreadcrumbs(options.breadcrumbs, defaults.breadcrumbs, logger), + stack: parseStack(options.stack, defaults.stack, logger), maxPendingEvents: itemOrDefault( options.maxPendingEvents, defaults.maxPendingEvents, @@ -175,10 +249,18 @@ export default function parse(options: Options, logger?: MinLogger): ParsedOptio if (Array.isArray(item)) { return true; } - logger?.warn(logger?.warn(wrongOptionType('collectors', 'Collector[]', typeof item))); + logger?.warn(wrongOptionType('collectors', 'Collector[]', typeof item)); return false; }), ], + logger: parseLogger(options), + errorFilters: itemOrDefault(options.errorFilters, defaults.errorFilters, (item) => { + if (Array.isArray(item)) { + return true; + } + logger?.warn(wrongOptionType('errorFilters', 'ErrorDataFilter[]', typeof item)); + return false; + }), }; } @@ -208,6 +290,7 @@ export interface ParsedHttpOptions { * @internal */ export interface ParsedStackOptions { + enabled: boolean; source: { /** * The number of lines captured before the originating line. @@ -227,6 +310,47 @@ export interface ParsedStackOptions { }; } +/** + * Internal type for parsed breadcrumbs options. + * @internal + */ +export interface ParsedBreadcrumbsOptions { + /** + * Set the maximum number of breadcrumbs. Defaults to 50. + */ + maxBreadcrumbs: number; + + /** + * True to enable automatic evaluation breadcrumbs. Defaults to true. + */ + evaluations: boolean; + + /** + * True to enable flag change breadcrumbs. Defaults to true. + */ + flagChange: boolean; + + /** + * True to enable click breadcrumbs. Defaults to true. + */ + click: boolean; + + /** + * True to enable input breadcrumbs for keypresses. Defaults to true. + */ + keyboardInput?: boolean; + + /** + * Settings for http instrumentation and breadcrumbs. + */ + http: ParsedHttpOptions; + + /** + * Custom breadcrumb filters. + */ + filters: BreadcrumbFilter[]; +} + /** * Internal type for parsed options. * @internal @@ -238,43 +362,14 @@ export interface ParsedOptions { * events captured during initialization. */ maxPendingEvents: number; + /** - * Properties related to automatic breadcrumb collection. + * Properties related to automatic breadcrumb collection, or `false` to disable automatic breadcrumbs. */ - breadcrumbs: { - /** - * Set the maximum number of breadcrumbs. Defaults to 50. - */ - maxBreadcrumbs: number; - - /** - * True to enable automatic evaluation breadcrumbs. Defaults to true. - */ - evaluations: boolean; - - /** - * True to enable flag change breadcrumbs. Defaults to true. - */ - flagChange: boolean; - - /** - * True to enable click breadcrumbs. Defaults to true. - */ - click: boolean; - - /** - * True to enable input breadcrumbs for keypresses. Defaults to true. - */ - keyboardInput?: boolean; - - /** - * Settings for http instrumentation and breadcrumbs. - */ - http: ParsedHttpOptions; - }; + breadcrumbs: ParsedBreadcrumbsOptions; /** - * Settings which affect call stack capture. + * Settings which affect call stack capture, or `false` to exclude stack frames from error events . */ stack: ParsedStackOptions; @@ -282,4 +377,14 @@ export interface ParsedOptions { * Additional, or custom, collectors. */ collectors: Collector[]; + + /** + * Logger to use for warnings. + */ + logger?: MinLogger; + + /** + * Custom error data filters. + */ + errorFilters: ErrorDataFilter[]; } diff --git a/packages/telemetry/browser-telemetry/src/singleton/index.ts b/packages/telemetry/browser-telemetry/src/singleton/index.ts new file mode 100644 index 0000000000..00fe46e855 --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/singleton/index.ts @@ -0,0 +1,2 @@ +export * from './singletonInstance'; +export * from './singletonMethods'; diff --git a/packages/telemetry/browser-telemetry/src/singleton/singletonInstance.ts b/packages/telemetry/browser-telemetry/src/singleton/singletonInstance.ts new file mode 100644 index 0000000000..b04d26e9d0 --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/singleton/singletonInstance.ts @@ -0,0 +1,100 @@ +import { Options } from '../api'; +import { BrowserTelemetry } from '../api/BrowserTelemetry'; +import BrowserTelemetryImpl from '../BrowserTelemetryImpl'; +import { fallbackLogger, prefixLog, safeMinLogger } from '../logging'; +import parse from '../options'; + +let telemetryInstance: BrowserTelemetry | undefined; +let warnedClientNotInitialized: boolean = false; + +/** + * Initialize the LaunchDarkly telemetry client + * + * This method should be called one time as early as possible in the application lifecycle. + * + * @example + * ``` + * import { initTelemetry } from '@launchdarkly/browser-telemetry'; + * + * initTelemetry(); + * ``` + * + * After initialization the telemetry client must be registered with the LaunchDarkly SDK client. + * + * @example + * ``` + * import { initTelemetry, register } from '@launchdarkly/browser-telemetry'; + * + * initTelemetry(); + * + * // Create your LaunchDarkly client following the LaunchDarkly SDK documentation. + * + * register(ldClient); + * ``` + * + * If using the 3.x version of the LaunchDarkly SDK, then you must also add inspectors when initializing your LaunchDarkly client. + * This allows for integration with feature flag data. + * + * @example + * ``` + * import { initTelemetry, register, inspectors } from '@launchdarkly/browser-telemetry'; + * import { init } from 'launchdarkly-js-client-sdk'; + * + * initTelemetry(); + * + * const ldClient = init('YOUR_CLIENT_SIDE_ID', { + * inspectors: inspectors() + * }); + * + * register(ldClient); + * ``` + * + * @param options The options to use for the telemetry instance. Refer to {@link Options} for more information. + */ +export function initTelemetry(options?: Options) { + const logger = safeMinLogger(options?.logger); + + if (telemetryInstance) { + logger.warn(prefixLog('Telemetry has already been initialized. Ignoring new options.')); + return; + } + + const parsedOptions = parse(options || {}, logger); + telemetryInstance = new BrowserTelemetryImpl(parsedOptions); +} + +/** + * Get the telemetry instance. + * + * In typical operation this method doesn't need to be called. Instead the functions exported by this package directly + * use the telemetry instance. + * + * This function can be used when the telemetry instance needs to be injected into code instead of accessed globally. + * + * @returns The telemetry instance, or undefined if it has not been initialized. + */ +export function getTelemetryInstance(): BrowserTelemetry | undefined { + if (!telemetryInstance) { + if (warnedClientNotInitialized) { + return undefined; + } + + fallbackLogger.warn(prefixLog('Telemetry has not been initialized')); + warnedClientNotInitialized = true; + return undefined; + } + + return telemetryInstance; +} + +/** + * Reset the telemetry instance to its initial state. + * + * This method is intended to be used in tests. + * + * @internal + */ +export function resetTelemetryInstance() { + telemetryInstance = undefined; + warnedClientNotInitialized = false; +} diff --git a/packages/telemetry/browser-telemetry/src/singleton/singletonMethods.ts b/packages/telemetry/browser-telemetry/src/singleton/singletonMethods.ts new file mode 100644 index 0000000000..218f835f54 --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/singleton/singletonMethods.ts @@ -0,0 +1,97 @@ +import { LDClientTracking } from '../api'; +import { Breadcrumb } from '../api/Breadcrumb'; +import { BrowserTelemetryInspector } from '../api/client/BrowserTelemetryInspector'; +import { getTelemetryInstance } from './singletonInstance'; + +/** + * Returns an array of active SDK inspectors to use with SDK versions that do + * not support hooks. + * + * Telemetry must be initialized, using {@link initTelemetry} before calling this method. + * If telemetry is not initialized, this method will return an empty array. + * + * @returns An array of {@link BrowserTelemetryInspector} objects. + */ +export function inspectors(): BrowserTelemetryInspector[] { + return getTelemetryInstance()?.inspectors() || []; +} + +/** + * Captures an Error object for telemetry purposes. + * + * Use this method to manually capture errors during application operation. + * Unhandled errors are automatically captured, but this method can be used + * to capture errors which were handled, but are still useful for telemetry. + * + * Telemetry must be initialized, using {@link initTelemetry} before calling this method. + * If telemetry is not initialized, then the exception will be discarded. + * + * @param exception The Error object to capture + */ +export function captureError(exception: Error): void { + getTelemetryInstance()?.captureError(exception); +} + +/** + * Captures a browser ErrorEvent for telemetry purposes. + * + * This method can be used to capture a manually created error event. Use this + * function to represent application specific errors which cannot be captured + * automatically or are not `Error` types. + * + * For most errors {@link captureError} should be used. + * + * Telemetry must be initialized, using {@link initTelemetry} before calling this method. + * If telemetry is not initialized, then the error event will be discarded. + * + * @param errorEvent The ErrorEvent to capture + */ +export function captureErrorEvent(errorEvent: ErrorEvent): void { + getTelemetryInstance()?.captureErrorEvent(errorEvent); +} + +/** + * Add a breadcrumb which will be included with telemetry events. + * + * Many breadcrumbs can be automatically captured, but this method can be + * used for capturing manual breadcrumbs. For application specific breadcrumbs + * the {@link CustomBreadcrumb} type can be used. + * + * Telemetry must be initialized, using {@link initTelemetry} before calling this method. + * If telemetry is not initialized, then the breadcrumb will be discarded. + * + * @param breadcrumb The breadcrumb to add. + */ +export function addBreadcrumb(breadcrumb: Breadcrumb): void { + getTelemetryInstance()?.addBreadcrumb(breadcrumb); +} + +/** + * Registers a LaunchDarkly client instance for telemetry tracking. + * + * This method connects the telemetry system to the specific LaunchDarkly + * client instance. The client instance will be used to report telemetry + * to LaunchDarkly and also for collecting flag and context data. + * + * Telemetry must be initialized, using {@link initTelemetry} before calling this method. + * If telemetry is not initialized, then the client will not be registered, and no events will be sent to LaunchDarkly. + * + * @param client The {@link LDClientTracking} instance to register for + * telemetry. + */ +export function register(client: LDClientTracking): void { + getTelemetryInstance()?.register(client); +} + +/** + * Closes the telemetry system and stops data collection. + * + * In general usage this method is not required, but it can be used in cases + * where collection needs to be stopped independent of application + * lifecycle. + * + * If telemetry is not initialized, using {@link initTelemetry}, then this method will do nothing. + */ +export function close(): void { + getTelemetryInstance()?.close(); +} diff --git a/packages/telemetry/browser-telemetry/src/stack/StackParser.ts b/packages/telemetry/browser-telemetry/src/stack/StackParser.ts index 89b88ab869..661375fcab 100644 --- a/packages/telemetry/browser-telemetry/src/stack/StackParser.ts +++ b/packages/telemetry/browser-telemetry/src/stack/StackParser.ts @@ -1,8 +1,7 @@ -import { computeStackTrace } from 'tracekit'; - import { StackFrame } from '../api/stack/StackFrame'; import { StackTrace } from '../api/stack/StackTrace'; import { ParsedStackOptions } from '../options'; +import { getTraceKit } from '../vendor/TraceKit'; /** * In the browser we will not always be able to determine the source file that code originates @@ -48,6 +47,18 @@ export function processUrlToFileName(input: string, origin: string): string { return cleaned; } +/** + * Clamp a value to be between an inclusive max an minimum. + * + * @param min The inclusive minimum value. + * @param max The inclusive maximum value. + * @param value The value to clamp. + * @returns The clamped value in range [min, max]. + */ +function clamp(min: number, max: number, value: number): number { + return Math.min(max, Math.max(min, value)); +} + export interface TrimOptions { /** * The maximum length of the trimmed line. @@ -131,6 +142,8 @@ export function getSrcLines( // as we can. context?: string[] | null; column?: number | null; + srcStart?: number | null; + line?: number | null; }, options: ParsedStackOptions, ): { @@ -169,7 +182,8 @@ export function getSrcLines( 0, ); - const origin = Math.floor(context.length / 2); + const origin = clamp(0, context.length - 1, (inFrame?.line ?? 0) - (inFrame.srcStart ?? 0)); + return { // The lines immediately preceeding the origin line. srcBefore: getLines(origin - options.source.beforeLines, origin, context, trimmer), @@ -195,12 +209,19 @@ export function getSrcLines( * @returns The stack trace for the given error. */ export default function parse(error: Error, options: ParsedStackOptions): StackTrace { - const parsed = computeStackTrace(error); + if (!options.enabled) { + return { + frames: [], + }; + } + + const parsed = getTraceKit().computeStackTrace(error); const frames: StackFrame[] = parsed.stack.reverse().map((inFrame) => ({ fileName: processUrlToFileName(inFrame.url, window.location.origin), function: inFrame.func, - line: inFrame.line, - col: inFrame.column, + // Strip the nulls so we only ever return undefined. + line: inFrame.line ?? undefined, + col: inFrame.column ?? undefined, ...getSrcLines(inFrame, options), })); return { diff --git a/packages/telemetry/browser-telemetry/src/vendor/TraceKit.ts b/packages/telemetry/browser-telemetry/src/vendor/TraceKit.ts new file mode 100644 index 0000000000..57b605b95a --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/vendor/TraceKit.ts @@ -0,0 +1,1152 @@ +/** + * https://github.com/csnover/TraceKit + * @license MIT + * @namespace TraceKit + */ + +/** + * This file has been vendored to make it compatible with ESM and to any potential window + * level TraceKit instance. + * + * Functionality unused by this SDK has been removed to minimize size. + * + * It has additionally been converted to typescript. + * + * The functionality of computeStackTrace has been extended to allow for easier use of the context + * information in stack frames. + */ + +/** + * Currently the conversion to typescript is minimal, so the following eslint + * rules are disabled. + */ + +/* eslint-disable func-names */ +/* eslint-disable no-shadow-restricted-names */ +/* eslint-disable prefer-destructuring */ +/* eslint-disable no-param-reassign */ +/* eslint-disable no-cond-assign */ +/* eslint-disable consistent-return */ +/* eslint-disable no-empty */ +/* eslint-disable no-plusplus */ +/* eslint-disable prefer-rest-params */ +/* eslint-disable no-useless-escape */ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable @typescript-eslint/no-use-before-define */ +/* eslint-disable no-continue */ +/* eslint-disable no-underscore-dangle */ + +/** + * Source context information. + * + * This was not present in the original source, but helps to easily identify which line in the + * context is the original line. + * + * Without this knowledge of the source file is requires for a consumer to know the position + * of the original source line within the context. + */ +export interface SourceContext { + /** + * The starting location in the source code. This is 1-based. + */ + startFromSource?: number; + contextLines: string[] | null; +} + +export interface TraceKitStatic { + computeStackTrace: { + (ex: Error, depth?: number): StackTrace; + augmentStackTraceWithInitialElement: ( + stackInfo: StackTrace, + url: string, + lineNo: number | string, + message: string, + ) => boolean; + computeStackTraceFromStackProp: (ex: Error) => StackTrace | null; + guessFunctionName: (url: string, lineNo: number | string) => string; + gatherContext: (url: string, line: number | string) => SourceContext | null; + ofCaller: (depth?: number) => StackTrace; + getSource: (url: string) => string[]; + }; + remoteFetching: boolean; + collectWindowErrors: boolean; + linesOfContext: number; + debug: boolean; +} + +const TraceKit: any = {}; + +export interface StackFrame { + url: string; + func: string; + args?: string[]; + /** + * The line number of source code. + * This is 1-based. + */ + line?: number | null; + column?: number | null; + context?: string[] | null; + /** + * The source line number that is the first line of the context. + * This is 1-based. + */ + srcStart?: number; +} + +export type Mode = 'stack' | 'stacktrace' | 'multiline' | 'callers' | 'onerror' | 'failed'; + +export interface StackTrace { + name: string; + message: string; + stack: StackFrame[]; + mode: Mode; + incomplete?: boolean; + partial?: boolean; +} + +(function (window, undefined) { + if (!window) { + return; + } + + // global reference to slice + const _slice = [].slice; + const UNKNOWN_FUNCTION = '?'; + + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error#Error_types + const ERROR_TYPES_RE = + /^(?:[Uu]ncaught (?:exception: )?)?(?:((?:Eval|Internal|Range|Reference|Syntax|Type|URI|)Error): )?(.*)$/; + + /** + * A better form of hasOwnProperty
+ * Example: `_has(MainHostObject, property) === true/false` + * + * @param {Object} object to check property + * @param {string} key to check + * @return {Boolean} true if the object has the key and it is not inherited + */ + function _has(object: any, key: string): boolean { + return Object.prototype.hasOwnProperty.call(object, key); + } + + /** + * Returns true if the parameter is undefined
+ * Example: `_isUndefined(val) === true/false` + * + * @param {*} what Value to check + * @return {Boolean} true if undefined and false otherwise + */ + function _isUndefined(what: any): boolean { + return typeof what === 'undefined'; + } + + /** + * Wrap any function in a TraceKit reporter
+ * Example: `func = TraceKit.wrap(func);` + * + * @param {Function} func Function to be wrapped + * @return {Function} The wrapped func + * @memberof TraceKit + */ + TraceKit.wrap = function traceKitWrapper(func: Function): Function { + function wrapped(this: any) { + try { + return func.apply(this, arguments); + } catch (e) { + TraceKit.report(e); + throw e; + } + } + return wrapped; + }; + + /** + * TraceKit.computeStackTrace: cross-browser stack traces in JavaScript + * + * Syntax: + * ```js + * s = TraceKit.computeStackTrace.ofCaller([depth]) + * s = TraceKit.computeStackTrace(exception) // consider using TraceKit.report instead (see below) + * ``` + * + * Supports: + * - Firefox: full stack trace with line numbers and unreliable column + * number on top frame + * - Opera 10: full stack trace with line and column numbers + * - Opera 9-: full stack trace with line numbers + * - Chrome: full stack trace with line and column numbers + * - Safari: line and column number for the topmost stacktrace element + * only + * - IE: no line numbers whatsoever + * + * Tries to guess names of anonymous functions by looking for assignments + * in the source code. In IE and Safari, we have to guess source file names + * by searching for function bodies inside all page scripts. This will not + * work for scripts that are loaded cross-domain. + * Here be dragons: some function names may be guessed incorrectly, and + * duplicate functions may be mismatched. + * + * TraceKit.computeStackTrace should only be used for tracing purposes. + * Logging of unhandled exceptions should be done with TraceKit.report, + * which builds on top of TraceKit.computeStackTrace and provides better + * IE support by utilizing the window.onerror event to retrieve information + * about the top of the stack. + * + * Note: In IE and Safari, no stack trace is recorded on the Error object, + * so computeStackTrace instead walks its *own* chain of callers. + * This means that: + * * in Safari, some methods may be missing from the stack trace; + * * in IE, the topmost function in the stack trace will always be the + * caller of computeStackTrace. + * + * This is okay for tracing (because you are likely to be calling + * computeStackTrace from the function you want to be the topmost element + * of the stack trace anyway), but not okay for logging unhandled + * exceptions (because your catch block will likely be far away from the + * inner function that actually caused the exception). + * + * Tracing example: + * ```js + * function trace(message) { + * var stackInfo = TraceKit.computeStackTrace.ofCaller(); + * var data = message + "\n"; + * for(var i in stackInfo.stack) { + * var item = stackInfo.stack[i]; + * data += (item.func || '[anonymous]') + "() in " + item.url + ":" + (item.line || '0') + "\n"; + * } + * if (window.console) + * console.info(data); + * else + * alert(data); + * } + * ``` + * @memberof TraceKit + * @namespace + */ + TraceKit.computeStackTrace = (function computeStackTraceWrapper() { + const debug = false; + const sourceCache: Record = {}; + + /** + * Attempts to retrieve source code via XMLHttpRequest, which is used + * to look up anonymous function names. + * @param {string} url URL of source code. + * @return {string} Source contents. + * @memberof TraceKit.computeStackTrace + */ + function loadSource(url: string): string { + if (!TraceKit.remoteFetching) { + // Only attempt request if remoteFetching is on. + return ''; + } + try { + const getXHR = function () { + try { + return new window.XMLHttpRequest(); + } catch (e) { + // explicitly bubble up the exception if not found + // @ts-ignore + return new window.ActiveXObject('Microsoft.XMLHTTP'); + } + }; + + const request = getXHR(); + request.open('GET', url, false); + request.send(''); + return request.responseText; + } catch (e) { + return ''; + } + } + + /** + * Retrieves source code from the source code cache. + * @param {string} url URL of source code. + * @return {Array.} Source contents. + * @memberof TraceKit.computeStackTrace + */ + function getSource(url: string): string[] { + if (typeof url !== 'string') { + return []; + } + + if (!_has(sourceCache, url)) { + // URL needs to be able to fetched within the acceptable domain. Otherwise, + // cross-domain errors will be triggered. + /* + Regex matches: + 0 - Full Url + 1 - Protocol + 2 - Domain + 3 - Port (Useful for internal applications) + 4 - Path + */ + let source = ''; + let domain = ''; + try { + domain = window.document.domain; + } catch (e) {} + const match = /(.*)\:\/\/([^:\/]+)([:\d]*)\/{0,1}([\s\S]*)/.exec(url); + if (match && match[2] === domain) { + source = loadSource(url); + } + sourceCache[url] = source ? source.split('\n') : []; + } + + return sourceCache[url]; + } + + /** + * Tries to use an externally loaded copy of source code to determine + * the name of a function by looking at the name of the variable it was + * assigned to, if any. + * @param {string} url URL of source code. + * @param {(string|number)} lineNo Line number in source code. + * @return {string} The function name, if discoverable. + * @memberof TraceKit.computeStackTrace + */ + function guessFunctionName(url: string, lineNo: string | number) { + if (typeof lineNo !== 'number') { + lineNo = Number(lineNo); + } + const reFunctionArgNames = /function ([^(]*)\(([^)]*)\)/; + const reGuessFunction = /['"]?([0-9A-Za-z$_]+)['"]?\s*[:=]\s*(function|eval|new Function)/; + let line = ''; + const maxLines = 10; + const source = getSource(url); + let m; + + if (!source.length) { + return UNKNOWN_FUNCTION; + } + + // Walk backwards from the first line in the function until we find the line which + // matches the pattern above, which is the function definition + for (let i = 0; i < maxLines; ++i) { + line = source[lineNo - i] + line; + + if (!_isUndefined(line)) { + if ((m = reGuessFunction.exec(line))) { + return m[1]; + } + if ((m = reFunctionArgNames.exec(line))) { + return m[1]; + } + } + } + + return UNKNOWN_FUNCTION; + } + + /** + * Retrieves the surrounding lines from where an exception occurred. + * @param {string} url URL of source code. + * @param {(string|number)} line Line number in source code to center around for context. + * @return {SourceContext} Lines of source code and line the source context starts on. + * @memberof TraceKit.computeStackTrace + */ + function gatherContext(url: string, line: string | number): SourceContext | null { + if (typeof line !== 'number') { + line = Number(line); + } + const source = getSource(url); + + if (!source.length) { + return null; + } + + const context = []; + // linesBefore & linesAfter are inclusive with the offending line. + // if linesOfContext is even, there will be one extra line + // *before* the offending line. + const linesBefore = Math.floor(TraceKit.linesOfContext / 2); + // Add one extra line if linesOfContext is odd + const linesAfter = linesBefore + (TraceKit.linesOfContext % 2); + const start = Math.max(0, line - linesBefore - 1); + const end = Math.min(source.length, line + linesAfter - 1); + + line -= 1; // convert to 0-based index + + for (let i = start; i < end; ++i) { + if (!_isUndefined(source[i])) { + context.push(source[i]); + } + } + + return context.length > 0 + ? { + contextLines: context, + // Source lines are in base-1. + startFromSource: start + 1, + } + : null; + } + /** + * Escapes special characters, except for whitespace, in a string to be + * used inside a regular expression as a string literal. + * @param {string} text The string. + * @return {string} The escaped string literal. + * @memberof TraceKit.computeStackTrace + */ + function escapeRegExp(text: string): string { + return text.replace(/[\-\[\]{}()*+?.,\\\^$|#]/g, '\\$&'); + } + + /** + * Escapes special characters in a string to be used inside a regular + * expression as a string literal. Also ensures that HTML entities will + * be matched the same as their literal friends. + * @param {string} body The string. + * @return {string} The escaped string. + * @memberof TraceKit.computeStackTrace + */ + function escapeCodeAsRegExpForMatchingInsideHTML(body: string): string { + return escapeRegExp(body) + .replace('<', '(?:<|<)') + .replace('>', '(?:>|>)') + .replace('&', '(?:&|&)') + .replace('"', '(?:"|")') + .replace(/\s+/g, '\\s+'); + } + + /** + * Determines where a code fragment occurs in the source code. + * @param {RegExp} re The function definition. + * @param {Array.} urls A list of URLs to search. + * @return {?Object.} An object containing + * the url, line, and column number of the defined function. + * @memberof TraceKit.computeStackTrace + */ + function findSourceInUrls( + re: RegExp, + urls: string[], + ): { + url: string; + line: number; + column: number; + } | null { + let source: any; + let m: any; + for (let i = 0, j = urls.length; i < j; ++i) { + if ((source = getSource(urls[i])).length) { + source = source.join('\n'); + if ((m = re.exec(source))) { + return { + url: urls[i], + line: source.substring(0, m.index).split('\n').length, + column: m.index - source.lastIndexOf('\n', m.index) - 1, + }; + } + } + } + + return null; + } + + /** + * Determines at which column a code fragment occurs on a line of the + * source code. + * @param {string} fragment The code fragment. + * @param {string} url The URL to search. + * @param {(string|number)} line The line number to examine. + * @return {?number} The column number. + * @memberof TraceKit.computeStackTrace + */ + function findSourceInLine(fragment: string, url: string, line: string | number): number | null { + if (typeof line !== 'number') { + line = Number(line); + } + const source = getSource(url); + const re = new RegExp(`\\b${escapeRegExp(fragment)}\\b`); + let m: any; + + line -= 1; + + if (source && source.length > line && (m = re.exec(source[line]))) { + return m.index; + } + + return null; + } + + /** + * Determines where a function was defined within the source code. + * @param {(Function|string)} func A function reference or serialized + * function definition. + * @return {?Object.} An object containing + * the url, line, and column number of the defined function. + * @memberof TraceKit.computeStackTrace + */ + function findSourceByFunctionBody(func: Function | string) { + if (_isUndefined(window && window.document)) { + return; + } + + const urls = [window.location.href]; + const scripts = window.document.getElementsByTagName('script'); + let body; + const code = `${func}`; + const codeRE = /^function(?:\s+([\w$]+))?\s*\(([\w\s,]*)\)\s*\{\s*(\S[\s\S]*\S)\s*\}\s*$/; + const eventRE = /^function on([\w$]+)\s*\(event\)\s*\{\s*(\S[\s\S]*\S)\s*\}\s*$/; + let re; + let parts; + let result; + + for (let i = 0; i < scripts.length; ++i) { + const script = scripts[i]; + if (script.src) { + urls.push(script.src); + } + } + + if (!(parts = codeRE.exec(code))) { + re = new RegExp(escapeRegExp(code).replace(/\s+/g, '\\s+')); + } + + // not sure if this is really necessary, but I don’t have a test + // corpus large enough to confirm that and it was in the original. + else { + const name = parts[1] ? `\\s+${parts[1]}` : ''; + const args = parts[2].split(',').join('\\s*,\\s*'); + + body = escapeRegExp(parts[3]).replace(/;$/, ';?'); // semicolon is inserted if the function ends with a comment.replace(/\s+/g, '\\s+'); + re = new RegExp(`function${name}\\s*\\(\\s*${args}\\s*\\)\\s*{\\s*${body}\\s*}`); + } + + // look for a normal function definition + if ((result = findSourceInUrls(re, urls))) { + return result; + } + + // look for an old-school event handler function + if ((parts = eventRE.exec(code))) { + const event = parts[1]; + body = escapeCodeAsRegExpForMatchingInsideHTML(parts[2]); + + // look for a function defined in HTML as an onXXX handler + re = new RegExp(`on${event}=[\\'"]\\s*${body}\\s*[\\'"]`, 'i'); + + // The below line is as it appears in the original code. + // @ts-expect-error TODO (SDK-1037): Determine if this is a bug or handling for some unexpected case. + if ((result = findSourceInUrls(re, urls[0]))) { + return result; + } + + // look for ??? + re = new RegExp(body); + + if ((result = findSourceInUrls(re, urls))) { + return result; + } + } + + return null; + } + + // Contents of Exception in various browsers. + // + // SAFARI: + // ex.message = Can't find variable: qq + // ex.line = 59 + // ex.sourceId = 580238192 + // ex.sourceURL = http://... + // ex.expressionBeginOffset = 96 + // ex.expressionCaretOffset = 98 + // ex.expressionEndOffset = 98 + // ex.name = ReferenceError + // + // FIREFOX: + // ex.message = qq is not defined + // ex.fileName = http://... + // ex.lineNumber = 59 + // ex.columnNumber = 69 + // ex.stack = ...stack trace... (see the example below) + // ex.name = ReferenceError + // + // CHROME: + // ex.message = qq is not defined + // ex.name = ReferenceError + // ex.type = not_defined + // ex.arguments = ['aa'] + // ex.stack = ...stack trace... + // + // INTERNET EXPLORER: + // ex.message = ... + // ex.name = ReferenceError + // + // OPERA: + // ex.message = ...message... (see the example below) + // ex.name = ReferenceError + // ex.opera#sourceloc = 11 (pretty much useless, duplicates the info in ex.message) + // ex.stacktrace = n/a; see 'opera:config#UserPrefs|Exceptions Have Stacktrace' + + /** + * Computes stack trace information from the stack property. + * Chrome and Gecko use this property. + * @param {Error} ex + * @return {?TraceKit.StackTrace} Stack trace information. + * @memberof TraceKit.computeStackTrace + */ + function computeStackTraceFromStackProp(ex: any): StackTrace | null { + if (!ex.stack) { + return null; + } + + const chrome = + /^\s*at (.*?) ?\(((?:file|https?|blob|chrome-extension|native|eval|webpack||\/).*?)(?::(\d+))?(?::(\d+))?\)?\s*$/i; + const gecko = + /^\s*(.*?)(?:\((.*?)\))?(?:^|@)((?:file|https?|blob|chrome|webpack|resource|\[native).*?|[^@]*bundle)(?::(\d+))?(?::(\d+))?\s*$/i; + const winjs = + /^\s*at (?:((?:\[object object\])?.+) )?\(?((?:file|ms-appx|https?|webpack|blob):.*?):(\d+)(?::(\d+))?\)?\s*$/i; + + // Used to additionally parse URL/line/column from eval frames + let isEval; + const geckoEval = /(\S+) line (\d+)(?: > eval line \d+)* > eval/i; + const chromeEval = /\((\S*)(?::(\d+))(?::(\d+))\)/; + + const lines = ex.stack.split('\n'); + const stack: StackFrame[] = []; + let submatch: any; + let parts: any; + let element: StackFrame; + const reference: any = /^(.*) is undefined$/.exec(ex.message); + + for (let i = 0, j = lines.length; i < j; ++i) { + if ((parts = chrome.exec(lines[i]))) { + const isNative = parts[2] && parts[2].indexOf('native') === 0; // start of line + isEval = parts[2] && parts[2].indexOf('eval') === 0; // start of line + if (isEval && (submatch = chromeEval.exec(parts[2]))) { + // throw out eval line/column and use top-most line/column number + parts[2] = submatch[1]; // url + parts[3] = submatch[2]; // line + parts[4] = submatch[3]; // column + } + element = { + url: !isNative ? parts[2] : null, + func: parts[1] || UNKNOWN_FUNCTION, + args: isNative ? [parts[2]] : [], + line: parts[3] ? +parts[3] : null, + column: parts[4] ? +parts[4] : null, + }; + } else if ((parts = winjs.exec(lines[i]))) { + element = { + url: parts[2], + func: parts[1] || UNKNOWN_FUNCTION, + args: [], + line: +parts[3], + column: parts[4] ? +parts[4] : null, + }; + } else if ((parts = gecko.exec(lines[i]))) { + isEval = parts[3] && parts[3].indexOf(' > eval') > -1; + if (isEval && (submatch = geckoEval.exec(parts[3]))) { + // throw out eval line/column and use top-most line number + parts[3] = submatch[1]; + parts[4] = submatch[2]; + parts[5] = null; // no column when eval + } else if (i === 0 && !parts[5] && !_isUndefined(ex.columnNumber)) { + // FireFox uses this awesome columnNumber property for its top frame + // Also note, Firefox's column number is 0-based and everything else expects 1-based, + // so adding 1 + // NOTE: this hack doesn't work if top-most frame is eval + stack[0].column = ex.columnNumber + 1; + } + element = { + url: parts[3], + func: parts[1] || UNKNOWN_FUNCTION, + args: parts[2] ? parts[2].split(',') : [], + line: parts[4] ? +parts[4] : null, + column: parts[5] ? +parts[5] : null, + }; + } else { + continue; + } + + if (!element.func && element.line) { + element.func = guessFunctionName(element.url, element.line); + } + + const srcContext = gatherContext(element.url, element.line!); + element.context = element.line ? (srcContext?.contextLines ?? null) : null; + element.srcStart = srcContext?.startFromSource; + stack.push(element); + } + + if (!stack.length) { + return null; + } + + if (stack[0] && stack[0].line && !stack[0].column && reference) { + stack[0].column = findSourceInLine(reference[1], stack[0].url, stack[0].line); + } + + return { + mode: 'stack', + name: ex.name, + message: ex.message, + stack, + }; + } + + /** + * Computes stack trace information from the stacktrace property. + * Opera 10+ uses this property. + * @param {Error} ex + * @return {?TraceKit.StackTrace} Stack trace information. + * @memberof TraceKit.computeStackTrace + */ + function computeStackTraceFromStacktraceProp(ex: any): StackTrace | null { + // Access and store the stacktrace property before doing ANYTHING + // else to it because Opera is not very good at providing it + // reliably in other circumstances. + const { stacktrace } = ex; + if (!stacktrace) { + return null; + } + + const opera10Regex = / line (\d+).*script (?:in )?(\S+)(?:: in function (\S+))?$/i; + const opera11Regex = + / line (\d+), column (\d+)\s*(?:in (?:]+)>|([^\)]+))\((.*)\))? in (.*):\s*$/i; + const lines = stacktrace.split('\n'); + const stack = []; + let parts; + + for (let line = 0; line < lines.length; line += 2) { + let element: StackFrame | null = null; + if ((parts = opera10Regex.exec(lines[line]))) { + element = { + url: parts[2], + line: +parts[1], + column: null, + func: parts[3], + args: [], + }; + } else if ((parts = opera11Regex.exec(lines[line]))) { + element = { + url: parts[6], + line: +parts[1], + column: +parts[2], + func: parts[3] || parts[4], + args: parts[5] ? parts[5].split(',') : [], + }; + } + + if (element) { + if (!element.func && element.line) { + element.func = guessFunctionName(element.url, element.line); + } + if (element.line) { + try { + const srcContext = gatherContext(element.url, element.line); + element.srcStart = srcContext?.startFromSource; + element.context = element.line ? (srcContext?.contextLines ?? null) : null; + } catch (exc) {} + } + + if (!element.context) { + element.context = [lines[line + 1]]; + } + + stack.push(element); + } + } + + if (!stack.length) { + return null; + } + + return { + mode: 'stacktrace', + name: ex.name, + message: ex.message, + stack, + }; + } + + /** + * NOT TESTED. + * Computes stack trace information from an error message that includes + * the stack trace. + * Opera 9 and earlier use this method if the option to show stack + * traces is turned on in opera:config. + * @param {Error} ex + * @return {?TraceKit.StackTrace} Stack information. + * @memberof TraceKit.computeStackTrace + */ + function computeStackTraceFromOperaMultiLineMessage(ex: Error): StackTrace | null { + // TODO: Clean this function up + // Opera includes a stack trace into the exception message. An example is: + // + // Statement on line 3: Undefined variable: undefinedFunc + // Backtrace: + // Line 3 of linked script file://localhost/Users/andreyvit/Projects/TraceKit/javascript-client/sample.js: In function zzz + // undefinedFunc(a); + // Line 7 of inline#1 script in file://localhost/Users/andreyvit/Projects/TraceKit/javascript-client/sample.html: In function yyy + // zzz(x, y, z); + // Line 3 of inline#1 script in file://localhost/Users/andreyvit/Projects/TraceKit/javascript-client/sample.html: In function xxx + // yyy(a, a, a); + // Line 1 of function script + // try { xxx('hi'); return false; } catch(ex) { TraceKit.report(ex); } + // ... + + const lines = ex.message.split('\n'); + if (lines.length < 4) { + return null; + } + + const lineRE1 = + /^\s*Line (\d+) of linked script ((?:file|https?|blob)\S+)(?:: in function (\S+))?\s*$/i; + const lineRE2 = + /^\s*Line (\d+) of inline#(\d+) script in ((?:file|https?|blob)\S+)(?:: in function (\S+))?\s*$/i; + const lineRE3 = /^\s*Line (\d+) of function script\s*$/i; + const stack = []; + const scripts = window && window.document && window.document.getElementsByTagName('script'); + const inlineScriptBlocks = []; + let parts: any; + + for (const s in scripts) { + if (_has(scripts, s) && !scripts[s].src) { + inlineScriptBlocks.push(scripts[s]); + } + } + + for (let line = 2; line < lines.length; line += 2) { + let item: any = null; + if ((parts = lineRE1.exec(lines[line]))) { + item = { + url: parts[2], + func: parts[3], + args: [], + line: +parts[1], + column: null, + }; + } else if ((parts = lineRE2.exec(lines[line]))) { + item = { + url: parts[3], + func: parts[4], + args: [], + line: +parts[1], + column: null, // TODO: Check to see if inline#1 (+parts[2]) points to the script number or column number. + }; + const relativeLine = +parts[1]; // relative to the start of the