diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml
index 03105313e..b753256e3 100644
--- a/.buildkite/pipeline.yml
+++ b/.buildkite/pipeline.yml
@@ -20,7 +20,8 @@ steps:
- PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 HUSKY=0 yarn install --immutable
- echo "+++ Run tests"
- yarn constraints
- - yarn run test:scripts
+ - yarn scripts test
+ - yarn scripts lint
- yarn run test:check-dts
plugins:
- ssh://git@github.com/segmentio/cache-buildkite-plugin#v2.0.0:
@@ -98,6 +99,21 @@ steps:
key: "v1.1-cache-dev-{{ checksum 'yarn.lock' }}"
paths: ['.yarn/cache/']
+ - label: '[Generic Utils] Lint + Test'
+ agents:
+ queue: v1
+ commands:
+ - npm config set "//registry.npmjs.org/:_authToken" $${NPM_TOKEN}
+ - echo "--- Install dependencies"
+ - PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 HUSKY=0 yarn install --immutable
+ - echo "+++ Run QA"
+ - yarn turbo run --filter=analytics-generic-utils lint
+ - yarn turbo run --filter=analytics-generic-utils test
+ plugins:
+ - ssh://git@github.com/segmentio/cache-buildkite-plugin#v2.0.0:
+ key: "v1.1-cache-dev-{{ checksum 'yarn.lock' }}"
+ paths: ['.yarn/cache/']
+
- label: '[Consent] Lint + Test'
agents:
queue: v1
@@ -148,8 +164,8 @@ steps:
key: "v1.1-cache-dev-{{ checksum 'yarn.lock' }}"
paths: ['.yarn/cache/']
- - label: '[Examples] Lint + Test :hammer:'
- key: examples
+ - label: '[Playgrounds] Lint + Test :hammer:'
+ key: playgrounds
agents:
queue: v1
commands:
@@ -157,9 +173,9 @@ steps:
- echo "--- Install dependencies"
- PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 HUSKY=0 yarn install --immutable
- echo "--- Build bundles"
- - yarn turbo run --filter='./examples/*' build
+ - yarn turbo run --filter='./playgrounds/*' build
- echo "+++ Run tests"
- - yarn turbo run --filter='./examples/*' lint
+ - yarn turbo run --filter='./playgrounds/*' lint
plugins:
- ssh://git@github.com/segmentio/cache-buildkite-plugin#v2.0.0:
key: "v1.1-cache-dev-{{ checksum 'yarn.lock' }}"
diff --git a/.changeset/config.json b/.changeset/config.json
index e656a5735..29819e3d6 100644
--- a/.changeset/config.json
+++ b/.changeset/config.json
@@ -1,6 +1,6 @@
{
"$schema": "https://unpkg.com/@changesets/config@1.6.0/schema.json",
- "ignore": ["@example/*"],
+ "ignore": ["@playground/*"],
"changelog": [
"@changesets/changelog-github",
{
diff --git a/.eslintrc.js b/.eslintrc.js
index b52fb4eb5..380e87742 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -19,7 +19,7 @@ module.exports = {
project: [
'./tsconfig.json',
'./packages/*/tsconfig.json',
- './examples/*/tsconfig.json',
+ './playgrounds/*/tsconfig.json',
],
},
extends: [
@@ -61,11 +61,30 @@ module.exports = {
'jest/no-focused-tests': process.env.CI ? 'error' : 'off',
},
overrides: [
+ {
+ files: ['**/src/**'],
+ rules: {
+ '@typescript-eslint/no-restricted-imports': [
+ 'error',
+ {
+ paths: [
+ {
+ // Prevent accidental imports from 'lodash'
+ name: 'lodash',
+ message:
+ 'Lodash should only be used for dev-related things, and not in any public library src code (e.g. @segment/analytics-next)',
+ },
+ ],
+ },
+ ],
+ },
+ },
{
files: ['**/__tests__/**', '**/scripts/**'],
rules: {
'require-await': 'off',
'@typescript-eslint/require-await': 'off',
+ '@typescript-eslint/no-restricted-imports': 'off',
},
},
],
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6bd3821a8..e27e2ab49 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,17 +6,17 @@ env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
jobs:
analytics-node:
- name: "analytics-node QA (Node.js v${{ matrix.node-version }})"
+ name: 'analytics-node QA (Node.js v${{ matrix.node-version }})'
runs-on: ubuntu-latest
strategy:
matrix:
- node-version: [ 14, 16, 18 ]
+ node-version: [18]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- cache: "yarn"
+ cache: 'yarn'
- run: yarn install --immutable
- name: Turbo cache
uses: actions/cache@v3
@@ -27,6 +27,28 @@ jobs:
${{ runner.os }}-turbo-
- run: yarn turbo run --filter='./packages/node*' lint
- run: yarn turbo run --filter='./packages/node*' test
+ - run: yarn turbo run --filter='./packages/node-integration-tests' test:perf-and-durability
+ analytics-node-cf-workers:
+ name: 'analytics-node QA (Cloudflare Workers)'
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ node-version: [18]
+ steps:
+ - uses: actions/checkout@v3
+ - uses: actions/setup-node@v3
+ with:
+ node-version: ${{ matrix.node-version }}
+ cache: 'yarn'
+ - run: yarn install --immutable
+ - name: Turbo cache
+ uses: actions/cache@v3
+ with:
+ path: node_modules/.cache/turbo
+ key: ${{ runner.os }}-turbo-${{ github.sha }}
+ restore-keys: |
+ ${{ runner.os }}-turbo-
+ - run: yarn turbo run --filter='./packages/node-integration-tests' test:cloudflare-workers
consent-intg-tests:
name: Consent Integration Tests
runs-on: ubuntu-latest
@@ -37,7 +59,7 @@ jobs:
- uses: actions/setup-node@v3
with:
node-version: 16
- cache: "yarn"
+ cache: 'yarn'
- run: yarn install --immutable
- name: Turbo cache
uses: actions/cache@v3
@@ -47,6 +69,3 @@ jobs:
restore-keys: |
${{ runner.os }}-turbo-
- run: yarn turbo run --filter='consent-tools-integration-tests' test:int
-
-
-
diff --git a/.github/workflows/create-github-release.yml b/.github/workflows/create-github-release.yml
index 870bc6347..5a6957951 100644
--- a/.github/workflows/create-github-release.yml
+++ b/.github/workflows/create-github-release.yml
@@ -24,4 +24,4 @@ jobs:
run: |
git config --global user.name "Segment Github"
git config --global user.email "github-actions@segment.com"
- yarn ts-node-script --files scripts/create-release-from-tags/run.ts
+ yarn scripts create-release-from-tags
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 21dcfc3c9..6d5f56539 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -29,4 +29,10 @@
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
+ "editor.formatOnSave": false,
+ "editor.codeActionsOnSave": {
+ "source.fixAll.eslint": "explicit"
+ },
+ "eslint.codeActionsOnSave.mode": "all",
+ "eslint.format.enable": true,
}
diff --git a/README.md b/README.md
index ce4ef60fc..e5ee0655e 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,7 @@
+## π Flagship π
+This library is one of Segmentβs most popular Flagship libraries. It is actively maintained by Segment, benefitting from new feature releases and ongoing support.
+
+
diff --git a/constraints.pro b/constraints.pro
index 8dc1175c2..8106e7cba 100644
--- a/constraints.pro
+++ b/constraints.pro
@@ -24,7 +24,7 @@ gen_enforced_dependency(WorkspaceCwd, DependencyIdent, DependencyRange2, Depende
DependencyType2 \= 'peerDependencies',
% A list of exception to same version rule
\+ member(DependencyIdent, [
- % Allow examples to use different versions of react and
+ % Allow playgrounds to use different versions of react and
'react', 'react-dom',
'@types/react',
% Allow the usage of workspace^ -- there is a better way to do this =)
diff --git a/examples/with-next-js/README.md b/examples/with-next-js/README.md
deleted file mode 100644
index b9f3fc718..000000000
--- a/examples/with-next-js/README.md
+++ /dev/null
@@ -1,9 +0,0 @@
-## Getting Started
-
-First, run the development server:
-
-```bash
-yarn dev
-```
-
-Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
diff --git a/jest.config.js b/jest.config.js
index e03283868..a5aa55afe 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -13,7 +13,9 @@ module.exports = () =>
'/packages/core-integration-tests',
'/packages/node',
'/packages/browser',
+ '/packages/generic-utils',
'/packages/consent/consent-tools',
'/packages/consent/consent-wrapper-onetrust',
+ '/scripts',
],
})
diff --git a/package.json b/package.json
index 64dee97c1..8cffce7bd 100644
--- a/package.json
+++ b/package.json
@@ -3,22 +3,22 @@
"private": true,
"version": "0.0.0",
"workspaces": [
- "examples/*",
+ "playgrounds/*",
"packages/*",
- "packages/consent/*"
+ "packages/consent/*",
+ "scripts"
],
"engines": {
"node": "^16.16.0"
},
"scripts": {
"test": "jest",
- "test:scripts": "jest --config scripts/jest.config.js",
"test:check-dts": "yarn build && yarn ts-node meta-tests/check-dts.ts",
"test:node-int": "turbo run --filter=node-integration-tests test",
"lint": "yarn constraints && turbo run lint --continue",
"build": "turbo run build --filter='./packages/**'",
"watch": "turbo run watch --filter='./packages/**'",
- "dev": "yarn workspace @example/with-next-js run dev",
+ "dev": "yarn workspace @playground/next-playground run dev",
"postinstall": "husky install",
"changeset": "changeset",
"update-versions-and-changelogs": "changeset version && yarn version-run-all && bash scripts/update-lockfile.sh",
@@ -27,6 +27,7 @@
"core": "yarn workspace @segment/analytics-core",
"browser": "yarn workspace @segment/analytics-next",
"node": "yarn workspace @segment/analytics-node",
+ "scripts": "yarn workspace @internal/scripts",
"clean": "bash scripts/clean.sh",
"turbo": "turbo"
},
@@ -34,9 +35,9 @@
"devDependencies": {
"@changesets/changelog-github": "^0.4.5",
"@changesets/cli": "^2.23.2",
- "@npmcli/promise-spawn": "^3.0.0",
+ "@npmcli/promise-spawn": "^7.0.0",
"@types/express": "4",
- "@types/jest": "^28.1.1",
+ "@types/jest": "^29.5.11",
"@types/lodash": "^4",
"@types/node-fetch": "^2.6.2",
"@typescript-eslint/eslint-plugin": "^5.21.0",
@@ -50,15 +51,15 @@
"express": "^4.18.2",
"get-monorepo-packages": "^1.2.0",
"husky": "^8.0.0",
- "jest": "^28.1.0",
+ "jest": "^29.7.0",
"lint-staged": "^13.0.0",
"lodash": "^4.17.21",
"nock": "^13.3.0",
"node-gyp": "^9.0.0",
"prettier": "^2.6.2",
- "ts-jest": "^28.0.4",
+ "ts-jest": "^29.1.1",
"ts-node": "^10.8.0",
- "turbo": "^1.3.1",
+ "turbo": "^1.10.14",
"typescript": "^4.7.0",
"webpack": "^5.76.0",
"webpack-dev-server": "^4.15.1"
diff --git a/packages/browser-integration-tests/src/custom-global-key.test.ts b/packages/browser-integration-tests/src/custom-global-key.test.ts
new file mode 100644
index 000000000..5e1ed6187
--- /dev/null
+++ b/packages/browser-integration-tests/src/custom-global-key.test.ts
@@ -0,0 +1,54 @@
+import { test, expect } from '@playwright/test'
+import { standaloneMock } from './helpers/standalone-mock'
+import { extractWriteKeyFromUrl } from './helpers/extract-writekey'
+import { CDNSettingsBuilder } from '@internal/test-helpers'
+
+test.describe('Segment with custom global key', () => {
+ test.beforeEach(standaloneMock)
+ test.beforeEach(async ({ context }) => {
+ await context.route(
+ 'https://cdn.segment.com/v1/projects/*/settings',
+ (route, request) => {
+ if (request.method().toLowerCase() !== 'get') {
+ return route.continue()
+ }
+
+ const writeKey = extractWriteKeyFromUrl(request.url()) || 'writeKey'
+ return route.fulfill({
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(new CDNSettingsBuilder({ writeKey }).build()),
+ })
+ }
+ )
+ })
+
+ test('supports using a custom global key', async ({ page }) => {
+ // Load analytics.js
+ await page.goto('/standalone-custom-key.html')
+ await page.evaluate(() => {
+ ;(window as any).segment_analytics.track('track before load')
+ ;(window as any).segment_analytics.load('fake-key')
+ })
+
+ const req = await page.waitForRequest('https://api.segment.io/v1/t')
+
+ // confirm that any events triggered before load have been sent
+ expect(req.postDataJSON().event).toBe('track before load')
+
+ const contextObj = await page.evaluate(() =>
+ (window as any).segment_analytics.track('track after load')
+ )
+
+ // confirm that any events triggered after load return a regular context object
+ expect(contextObj).toMatchObject(
+ expect.objectContaining({
+ attempts: expect.anything(),
+ event: expect.objectContaining({ event: 'track after load' }),
+ stats: expect.objectContaining({ metrics: expect.anything() }),
+ })
+ )
+ })
+})
diff --git a/packages/browser-integration-tests/standalone-custom-key.html b/packages/browser-integration-tests/standalone-custom-key.html
new file mode 100644
index 000000000..f8d01f773
--- /dev/null
+++ b/packages/browser-integration-tests/standalone-custom-key.html
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
diff --git a/packages/browser/CHANGELOG.md b/packages/browser/CHANGELOG.md
index 954808360..ce1088627 100644
--- a/packages/browser/CHANGELOG.md
+++ b/packages/browser/CHANGELOG.md
@@ -1,5 +1,103 @@
# @segment/analytics-next
+## 1.66.0
+
+### Minor Changes
+
+- [#1037](https://github.com/segmentio/analytics-next/pull/1037) [`e435279`](https://github.com/segmentio/analytics-next/commit/e4352792ed5e58a95009a28d83abb8cfea308a82) Thanks [@danieljackins](https://github.com/danieljackins)! - Allow custom metrics endpoint on load
+
+## 1.65.0
+
+### Minor Changes
+
+- [#945](https://github.com/segmentio/analytics-next/pull/945) [`d212633`](https://github.com/segmentio/analytics-next/commit/d21263369d5980f4f57b13795524dbc345a02e5c) Thanks [@zikaari](https://github.com/zikaari)! - Load destinations lazily and start sending events as each becomes available instead of waiting for all to load first
+
+### Patch Changes
+
+- [#1036](https://github.com/segmentio/analytics-next/pull/1036) [`f65c131`](https://github.com/segmentio/analytics-next/commit/f65c131a62f979b6629b086b3eb9cd9b3ffefe31) Thanks [@danieljackins](https://github.com/danieljackins)! - Fix schema-filter bug
+
+- Updated dependencies [[`95fd2fd`](https://github.com/segmentio/analytics-next/commit/95fd2fd801da26505ddcead96ffaa83aa4364994), [`d212633`](https://github.com/segmentio/analytics-next/commit/d21263369d5980f4f57b13795524dbc345a02e5c)]:
+ - @segment/analytics-core@1.5.0
+ - @segment/analytics-generic-utils@1.2.0
+
+## 1.64.0
+
+### Minor Changes
+
+- [#1032](https://github.com/segmentio/analytics-next/pull/1032) [`5c1511f`](https://github.com/segmentio/analytics-next/commit/5c1511fe1e1d1df94967623b29ec12ffe770aacf) Thanks [@zikaari](https://github.com/zikaari)! - Support loading analytics into a custom global variable when using snippet version 5.2.1 or later
+
+## 1.63.0
+
+### Minor Changes
+
+- [#1008](https://github.com/segmentio/analytics-next/pull/1008) [`e57960e`](https://github.com/segmentio/analytics-next/commit/e57960e84f5ce5b214dde09928bee6e6bdba3a69) Thanks [@danieljackins](https://github.com/danieljackins)! - Change segmentio to destination type
+
+* [#1023](https://github.com/segmentio/analytics-next/pull/1023) [`b5b929e`](https://github.com/segmentio/analytics-next/commit/b5b929ea432198ae6aecb2b03ea2194972bcc029) Thanks [@silesky](https://github.com/silesky)! - Deprecate AnalyticsNode class (in favor of the standalone @segment/analytics-node)
+
+## 1.62.1
+
+### Patch Changes
+
+- [#1009](https://github.com/segmentio/analytics-next/pull/1009) [`f476038`](https://github.com/segmentio/analytics-next/commit/f47603881b787cc81fa1da4496bdbde9eb325a0f) Thanks [@silesky](https://github.com/silesky)! - If initialPageview is true, capture page context as early as possible
+
+- Updated dependencies [[`7b93e7b`](https://github.com/segmentio/analytics-next/commit/7b93e7b50fa293aebaf6767a44bf7708b231d5cd)]:
+ - @segment/analytics-generic-utils@1.1.1
+ - @segment/analytics-core@1.4.1
+
+## 1.62.0
+
+### Minor Changes
+
+- [#992](https://github.com/segmentio/analytics-next/pull/992) [`a72f473`](https://github.com/segmentio/analytics-next/commit/a72f4736a743e6a6487fd7b5c764639402f9e7ba) Thanks [@silesky](https://github.com/silesky)! - Add 'disable' boolean option to allow for disabling Segment in a testing environment.
+
+### Patch Changes
+
+- [#1001](https://github.com/segmentio/analytics-next/pull/1001) [`57be1ac`](https://github.com/segmentio/analytics-next/commit/57be1acd556a9779edbc5fd4d3f820fb50b65697) Thanks [@silesky](https://github.com/silesky)! - add hasUnmappedDestinations property to types
+
+- Updated dependencies [[`d9b47c4`](https://github.com/segmentio/analytics-next/commit/d9b47c43e5e08efce14fe4150536ff60b8df91e0), [`d9b47c4`](https://github.com/segmentio/analytics-next/commit/d9b47c43e5e08efce14fe4150536ff60b8df91e0)]:
+ - @segment/analytics-core@1.4.0
+ - @segment/analytics-generic-utils@1.1.0
+
+## 1.61.0
+
+### Minor Changes
+
+- [#985](https://github.com/segmentio/analytics-next/pull/985) [`083f9a1`](https://github.com/segmentio/analytics-next/commit/083f9a18e2cde4132cd31a61d76f26d07c35cad9) Thanks [@zikaari](https://github.com/zikaari)! - Update integration metrics capturing strategy
+
+## 1.60.0
+
+### Minor Changes
+
+- [#989](https://github.com/segmentio/analytics-next/pull/989) [`1faabf1`](https://github.com/segmentio/analytics-next/commit/1faabf1f51de63423f8995adf837137ab2d9d800) Thanks [@silesky](https://github.com/silesky)! - Change default retries to 10 to match docs + ajs classic
+
+## 1.59.0
+
+### Minor Changes
+
+- [#971](https://github.com/segmentio/analytics-next/pull/971) [`2f1ae75`](https://github.com/segmentio/analytics-next/commit/2f1ae75896123e0aeaa1608fde15312adddd5614) Thanks [@zikaari](https://github.com/zikaari)! - Capture action plugin metrics
+
+### Patch Changes
+
+- [#950](https://github.com/segmentio/analytics-next/pull/950) [`c0dadc7`](https://github.com/segmentio/analytics-next/commit/c0dadc759dccd88c6d95d14fcf7732fad2b051a1) Thanks [@oscb](https://github.com/oscb)! - Fixes calls to .identify() with null as id
+
+## 1.58.0
+
+### Minor Changes
+
+- [#852](https://github.com/segmentio/analytics-next/pull/852) [`897f4cc`](https://github.com/segmentio/analytics-next/commit/897f4cc69de4cdd38efd0cd70567bfed0c454fec) Thanks [@silesky](https://github.com/silesky)! - - Capture page context information faster, so context.campaign and context.page are more resilient to quick navigation changes.
+ - Parse UTM params into context.campaign if users pass an object to a page call.
+
+### Patch Changes
+
+- Updated dependencies [[`897f4cc`](https://github.com/segmentio/analytics-next/commit/897f4cc69de4cdd38efd0cd70567bfed0c454fec)]:
+ - @segment/analytics-core@1.3.2
+
+## 1.57.0
+
+### Minor Changes
+
+- [#956](https://github.com/segmentio/analytics-next/pull/956) [`f5cdb82`](https://github.com/segmentio/analytics-next/commit/f5cdb824050c22a9aaa86a450b8f1f4a7f4fb144) Thanks [@danieljackins](https://github.com/danieljackins)! - Set timezone and allow userAgentData to be overridden
+
## 1.56.0
### Minor Changes
diff --git a/packages/browser/Makefile b/packages/browser/Makefile
index 530e5bb49..f55ee1770 100644
--- a/packages/browser/Makefile
+++ b/packages/browser/Makefile
@@ -93,5 +93,5 @@ analyze:
.PHONY: analyze
dev: ## Starts a dev server that is ready for development
- yarn workspace @example/with-next-js run dev
+ yarn workspace @playground/next-playground run dev
.PHONY: dev
diff --git a/packages/browser/README.md b/packages/browser/README.md
index 262bb7c09..658b15eb4 100644
--- a/packages/browser/README.md
+++ b/packages/browser/README.md
@@ -86,8 +86,13 @@ analytics
[Self Hosting or Proxying Analytics.js documentation](
https://segment.com/docs/connections/sources/catalog/libraries/website/javascript/custom-proxy/#custom-cdn--api-proxy)
-## Usage in Common Frameworks
-### React
+## Examples / Usage in Common Frameworks and SPAs
+
+### Next.js
+- https://github.com/vercel/next.js/tree/canary/examples/with-segment-analytics
+- https://github.com/vercel/next.js/tree/canary/examples/with-segment-analytics-pages-router
+
+### Vanilla React
```tsx
import { AnalyticsBrowser } from '@segment/analytics-next'
@@ -101,12 +106,9 @@ const App = () => (
)
```
-More React Examples:
-> Warning β οΈ Some of these examples may be overly-complex for your use case, we recommend the simple approach outlined above.
-- Our [playground](/examples/with-next-js/) (written in NextJS) -- this can be run with `yarn dev`.
-- Complex [React example repo](https://github.com/segmentio/react-example/) which outlines using the [Segment snippet](https://github.com/segmentio/react-example/tree/main/src/examples/analytics-quick-start) and using the [Segment npm package](https://github.com/segmentio/react-example/tree/main/src/examples/analytics-package).
-### `Vue`
+
+### Vue
1. Export analytics instance.
@@ -146,9 +148,9 @@ export default defineComponent({
## Support for Web Workers (Experimental)
While this package does not support web workers out of the box, there are options:
-1. Run analytics.js in a web worker via [partytown.io](https://partytown.builder.io/). See [our partytown example](../../examples/with-next-js/pages/partytown). **Supports both cloud and device mode destinations, but not all device mode destinations may work.**
+1. Run analytics.js in a web worker via [partytown.io](https://partytown.builder.io/). See [our partytown example](../../playgrounds/next-playground/pages/partytown). **Supports both cloud and device mode destinations, but not all device mode destinations may work.**
-2. Try [@segment/analytics-node](../node) with `maxEventsInBatch: 1`, which should work in any runtime where `fetch` is available. **Warning: cloud destinations only!**
+2. Try [@segment/analytics-node](../node) with `flushAt: 1`, which should work in any runtime where `fetch` is available. **Warning: cloud destinations only!**
diff --git a/packages/browser/package.json b/packages/browser/package.json
index d33f61f9a..c7efcd901 100644
--- a/packages/browser/package.json
+++ b/packages/browser/package.json
@@ -1,6 +1,6 @@
{
"name": "@segment/analytics-next",
- "version": "1.56.0",
+ "version": "1.66.0",
"repository": {
"type": "git",
"url": "https://github.com/segmentio/analytics-next",
@@ -24,7 +24,7 @@
],
"sideEffects": false,
"scripts": {
- ".": "yarn run -T turbo run --filter=@segment/analytics-next",
+ ".": "yarn run -T turbo run --filter=@segment/analytics-next...",
"build-prep": "sh scripts/build-prep.sh",
"version": "yarn run build-prep && git add src/generated/version.ts",
"umd": "webpack",
@@ -44,12 +44,13 @@
"size-limit": [
{
"path": "dist/umd/index.js",
- "limit": "28.5 KB"
+ "limit": "29.6 KB"
}
],
"dependencies": {
"@lukeed/uuid": "^2.0.0",
- "@segment/analytics-core": "1.3.1",
+ "@segment/analytics-core": "1.5.0",
+ "@segment/analytics-generic-utils": "1.2.0",
"@segment/analytics.js-video-plugins": "^0.2.1",
"@segment/facade": "^3.4.9",
"@segment/tsub": "^2.0.0",
diff --git a/packages/browser/qa/__tests__/backwards-compatibility.test.ts b/packages/browser/qa/__tests__/backwards-compatibility.test.ts
index 644e0c2af..9b2ceb007 100644
--- a/packages/browser/qa/__tests__/backwards-compatibility.test.ts
+++ b/packages/browser/qa/__tests__/backwards-compatibility.test.ts
@@ -144,17 +144,17 @@ describe('Backwards compatibility', () => {
expect(next['identify']).toEqual(classic['identify'])
expect(classic['page']).toMatchInlineSnapshot(`
- Array [
- Object {
+ [
+ {
"path": "/",
"referrer": "",
"search": "?type=classic&wk=D8frB7upBChqDN9PMWksNvZYDaKJIYo6",
"title": "",
"url": "http://localhost:4000/?type=classic&wk=D8frB7upBChqDN9PMWksNvZYDaKJIYo6",
},
- Object {
- "context": Object {
- "page": Object {
+ {
+ "context": {
+ "page": {
"path": "/",
"referrer": "",
"search": "?type=classic&wk=D8frB7upBChqDN9PMWksNvZYDaKJIYo6",
diff --git a/packages/browser/qa/lib/runner.ts b/packages/browser/qa/lib/runner.ts
index bac33a49a..2ff502fe4 100644
--- a/packages/browser/qa/lib/runner.ts
+++ b/packages/browser/qa/lib/runner.ts
@@ -94,7 +94,10 @@ export async function run(params: ComparisonParams) {
await page.goto(url)
await page.waitForLoadState('networkidle')
- await page.waitForFunction(`window.analytics.initialized === true`)
+ await page.waitForFunction(
+ `window.analytics.initialized === true`,
+ undefined
+ )
// This forces every timestamp to look exactly the same.
// Moving this prototype manipulation after networkidle fixed a race condition around Object.freeze that interfered with certain scripts.
diff --git a/packages/browser/src/browser/__tests__/analytics-lazy-init.integration.test.ts b/packages/browser/src/browser/__tests__/analytics-lazy-init.integration.test.ts
index 526a7a972..8cc373708 100644
--- a/packages/browser/src/browser/__tests__/analytics-lazy-init.integration.test.ts
+++ b/packages/browser/src/browser/__tests__/analytics-lazy-init.integration.test.ts
@@ -1,8 +1,13 @@
-import { sleep } from '@segment/analytics-core'
+import { CorePlugin, PluginType, sleep } from '@segment/analytics-core'
+import { getBufferedPageCtxFixture } from '../../test-helpers/fixtures'
import unfetch from 'unfetch'
import { AnalyticsBrowser } from '..'
import { Analytics } from '../../core/analytics'
import { createSuccess } from '../../test-helpers/factories'
+import { createDeferred } from '@segment/analytics-generic-utils'
+import { PluginFactory } from '../../plugins/remote-loader'
+
+const nextTickP = () => new Promise((r) => setTimeout(r, 0))
jest.mock('unfetch')
@@ -27,7 +32,7 @@ describe('Lazy initialization', () => {
expect(trackSpy).not.toBeCalled()
analytics.load({ writeKey: 'abc' })
await track
- expect(trackSpy).toBeCalledWith('foo')
+ expect(trackSpy).toBeCalledWith('foo', getBufferedPageCtxFixture())
})
it('.load method return an analytics instance', async () => {
@@ -47,3 +52,139 @@ describe('Lazy initialization', () => {
)
})
})
+
+const createTestPluginFactory = (name: string, type: PluginType) => {
+ const lock = createDeferred()
+ const load = createDeferred()
+ const trackSpy = jest.fn()
+
+ const factory: PluginFactory = () => {
+ return {
+ name,
+ type,
+ version: '1.0.0',
+ load: jest
+ .fn()
+ .mockImplementation(() => lock.promise.then(() => load.resolve())),
+ isLoaded: () => lock.isSettled(),
+ track: trackSpy,
+ }
+ }
+
+ factory.pluginName = name
+
+ return {
+ loadingGuard: lock,
+ trackSpy,
+ factory,
+ loadPromise: load.promise,
+ }
+}
+
+describe('Lazy destination loading', () => {
+ beforeEach(() => {
+ jest.mock('unfetch')
+ jest.mocked(unfetch).mockImplementation(() =>
+ createSuccess({
+ integrations: {},
+ remotePlugins: [
+ {
+ name: 'braze',
+ libraryName: 'braze',
+ },
+ {
+ name: 'google',
+ libraryName: 'google',
+ },
+ ],
+ })
+ )
+ })
+
+ afterAll(() => jest.resetAllMocks())
+
+ it('loads destinations in the background', async () => {
+ const testEnrichmentHarness = createTestPluginFactory(
+ 'enrichIt',
+ 'enrichment'
+ )
+ const dest1Harness = createTestPluginFactory('braze', 'destination')
+ const dest2Harness = createTestPluginFactory('google', 'destination')
+
+ const analytics = new AnalyticsBrowser()
+
+ const testEnrichmentPlugin = testEnrichmentHarness.factory(
+ null
+ ) as CorePlugin
+
+ analytics.register(testEnrichmentPlugin).catch(() => {})
+
+ await analytics.load({
+ writeKey: 'abc',
+ plugins: [dest1Harness.factory, dest2Harness.factory],
+ })
+
+ // we won't hold enrichment plugin from loading since they are not lazy loaded
+ testEnrichmentHarness.loadingGuard.resolve()
+ // and we'll also let one destination load so we can assert some behaviours
+ dest1Harness.loadingGuard.resolve()
+
+ await testEnrichmentHarness.loadPromise
+ await dest1Harness.loadPromise
+
+ analytics.track('test event 1').catch(() => {})
+
+ // even though there's one destination that still hasn't loaded, the next assertions
+ // prove that the event pipeline is flowing regardless
+
+ await nextTickP()
+ expect(testEnrichmentHarness.trackSpy).toHaveBeenCalledTimes(1)
+
+ await nextTickP()
+ expect(dest1Harness.trackSpy).toHaveBeenCalledTimes(1)
+
+ // now we'll send another event
+
+ analytics.track('test event 2').catch(() => {})
+
+ // even though there's one destination that still hasn't loaded, the next assertions
+ // prove that the event pipeline is flowing regardless
+
+ await nextTickP()
+ expect(testEnrichmentHarness.trackSpy).toHaveBeenCalledTimes(2)
+
+ await nextTickP()
+ expect(dest1Harness.trackSpy).toHaveBeenCalledTimes(2)
+
+ // this whole time the other destination was not engaged with at all
+ expect(dest2Harness.trackSpy).not.toHaveBeenCalled()
+
+ // now "after some time" the other destination will load
+ dest2Harness.loadingGuard.resolve()
+ await dest2Harness.loadPromise
+
+ // and now that it is "online" - the previous events that it missed will be handed over
+ await nextTickP()
+ expect(dest2Harness.trackSpy).toHaveBeenCalledTimes(2)
+ })
+
+ it('emits initialize regardless of whether all destinations have loaded', async () => {
+ const dest1Harness = createTestPluginFactory('braze', 'destination')
+ const dest2Harness = createTestPluginFactory('google', 'destination')
+
+ const analytics = new AnalyticsBrowser()
+
+ let initializeEmitted = false
+
+ analytics.on('initialize', () => {
+ initializeEmitted = true
+ })
+
+ await analytics.load({
+ writeKey: 'abc',
+ plugins: [dest1Harness.factory, dest2Harness.factory],
+ })
+
+ expect(initializeEmitted).toBe(true)
+ })
+})
diff --git a/packages/browser/src/browser/__tests__/analytics-pre-init.integration.test.ts b/packages/browser/src/browser/__tests__/analytics-pre-init.integration.test.ts
index 10384fca6..74952b455 100644
--- a/packages/browser/src/browser/__tests__/analytics-pre-init.integration.test.ts
+++ b/packages/browser/src/browser/__tests__/analytics-pre-init.integration.test.ts
@@ -6,6 +6,7 @@ import * as Factory from '../../test-helpers/factories'
import { sleep } from '../../lib/sleep'
import { setGlobalCDNUrl } from '../../lib/parse-cdn'
import { User } from '../../core/user'
+import { getBufferedPageCtxFixture } from '../../test-helpers/fixtures'
jest.mock('unfetch')
@@ -61,7 +62,11 @@ describe('Pre-initialization', () => {
const trackCtxPromise = ajsBrowser.track('foo', { name: 'john' })
const result = await trackCtxPromise
expect(result).toBeInstanceOf(Context)
- expect(trackSpy).toBeCalledWith('foo', { name: 'john' })
+ expect(trackSpy).toBeCalledWith(
+ 'foo',
+ { name: 'john' },
+ getBufferedPageCtxFixture()
+ )
expect(trackSpy).toBeCalledTimes(1)
})
@@ -107,11 +112,19 @@ describe('Pre-initialization', () => {
await Promise.all([trackCtxPromise, trackCtxPromise2, identifyCtxPromise])
- expect(trackSpy).toBeCalledWith('foo', { name: 'john' })
- expect(trackSpy).toBeCalledWith('bar', { age: 123 })
+ expect(trackSpy).toBeCalledWith(
+ 'foo',
+ { name: 'john' },
+ getBufferedPageCtxFixture()
+ )
+ expect(trackSpy).toBeCalledWith(
+ 'bar',
+ { age: 123 },
+ getBufferedPageCtxFixture()
+ )
expect(trackSpy).toBeCalledTimes(2)
- expect(identifySpy).toBeCalledWith('hello')
+ expect(identifySpy).toBeCalledWith('hello', getBufferedPageCtxFixture())
expect(identifySpy).toBeCalledTimes(1)
})
@@ -234,8 +247,8 @@ describe('Pre-initialization', () => {
await AnalyticsBrowser.standalone(writeKey)
await sleep(100) // the snippet does not return a promise (pre-initialization) ... it sometimes has a callback as the third argument.
- expect(trackSpy).toBeCalledWith('foo')
- expect(trackSpy).toBeCalledWith('bar')
+ expect(trackSpy).toBeCalledWith('foo', getBufferedPageCtxFixture())
+ expect(trackSpy).toBeCalledWith('bar', getBufferedPageCtxFixture())
expect(trackSpy).toBeCalledTimes(2)
expect(identifySpy).toBeCalledTimes(1)
@@ -262,11 +275,11 @@ describe('Pre-initialization', () => {
await AnalyticsBrowser.standalone(writeKey)
await sleep(100) // the snippet does not return a promise (pre-initialization) ... it sometimes has a callback as the third argument.
- expect(trackSpy).toBeCalledWith('foo')
- expect(trackSpy).toBeCalledWith('bar')
+ expect(trackSpy).toBeCalledWith('foo', getBufferedPageCtxFixture())
+ expect(trackSpy).toBeCalledWith('bar', getBufferedPageCtxFixture())
expect(trackSpy).toBeCalledTimes(2)
- expect(identifySpy).toBeCalledWith()
+ expect(identifySpy).toBeCalledWith(getBufferedPageCtxFixture())
expect(identifySpy).toBeCalledTimes(1)
expect(consoleErrorSpy).toBeCalledTimes(1)
@@ -292,8 +305,8 @@ describe('Pre-initialization', () => {
})
await sleep(100) // the snippet does not return a promise (pre-initialization) ... it sometimes has a callback as the third argument.
- expect(trackSpy).toBeCalledWith('foo')
- expect(trackSpy).toBeCalledWith('bar')
+ expect(trackSpy).toBeCalledWith('foo', getBufferedPageCtxFixture())
+ expect(trackSpy).toBeCalledWith('bar', getBufferedPageCtxFixture())
expect(trackSpy).toBeCalledTimes(2)
expect(identifySpy).toBeCalledTimes(1)
diff --git a/packages/browser/src/browser/__tests__/inspector.integration.test.ts b/packages/browser/src/browser/__tests__/inspector.integration.test.ts
index c56e6d986..a5b8df713 100644
--- a/packages/browser/src/browser/__tests__/inspector.integration.test.ts
+++ b/packages/browser/src/browser/__tests__/inspector.integration.test.ts
@@ -55,7 +55,7 @@ describe('Inspector', () => {
await deliveryPromise
- expect(enrichedFn).toHaveBeenCalledTimes(2)
+ expect(enrichedFn).toHaveBeenCalledTimes(2) // will be called once for every before or enrichment plugin.
expect(deliveredFn).toHaveBeenCalledTimes(1)
})
diff --git a/packages/browser/src/browser/__tests__/integration.test.ts b/packages/browser/src/browser/__tests__/integration.test.ts
index f66471157..02ec2aee9 100644
--- a/packages/browser/src/browser/__tests__/integration.test.ts
+++ b/packages/browser/src/browser/__tests__/integration.test.ts
@@ -23,7 +23,8 @@ import {
highEntropyTestData,
lowEntropyTestData,
} from '../../test-helpers/fixtures/client-hints'
-import { getGlobalAnalytics } from '../..'
+import { getGlobalAnalytics, NullAnalytics } from '../..'
+import { recordIntegrationMetric } from '../../core/stats/metric-helpers'
let fetchCalls: ReturnType[] = []
@@ -75,6 +76,16 @@ const googleAnalytics: Plugin = {
type: 'destination',
}
+const slowPlugin: Plugin = {
+ ...xt,
+ name: 'Slow Plugin',
+ type: 'destination',
+ track: async (ctx) => {
+ await sleep(3000)
+ return ctx
+ },
+}
+
const enrichBilling: Plugin = {
...xt,
name: 'Billing Enrichment',
@@ -94,11 +105,11 @@ const amplitudeWriteKey = 'bar'
beforeEach(() => {
setGlobalCDNUrl(undefined as any)
+ fetchCalls = []
})
describe('Initialization', () => {
beforeEach(async () => {
- fetchCalls = []
jest.resetAllMocks()
jest.resetModules()
})
@@ -300,21 +311,17 @@ describe('Initialization', () => {
})
})
it('calls page if initialpageview is set', async () => {
- jest.mock('../../core/analytics')
- const mockPage = jest.fn().mockImplementation(() => Promise.resolve())
- Analytics.prototype.page = mockPage
-
+ const page = jest.spyOn(Analytics.prototype, 'page')
await AnalyticsBrowser.load({ writeKey }, { initialPageview: true })
-
- expect(mockPage).toHaveBeenCalled()
+ await sleep(0) // flushed in new task
+ expect(page).toHaveBeenCalledTimes(1)
})
it('does not call page if initialpageview is not set', async () => {
- jest.mock('../../core/analytics')
- const mockPage = jest.fn()
- Analytics.prototype.page = mockPage
+ const page = jest.spyOn(Analytics.prototype, 'page')
await AnalyticsBrowser.load({ writeKey }, { initialPageview: false })
- expect(mockPage).not.toHaveBeenCalled()
+ await sleep(0) // flush happens async
+ expect(page).not.toHaveBeenCalled()
})
it('does not use a persisted queue when disableClientPersistence is true', async () => {
@@ -578,6 +585,34 @@ describe('Dispatch', () => {
expect(segmentSpy).toHaveBeenCalledWith(boo)
})
+ it('dispatching to Segmentio not blocked by other destinations', async () => {
+ const [ajs] = await AnalyticsBrowser.load({
+ writeKey,
+ plugins: [slowPlugin],
+ })
+
+ const segmentio = ajs.queue.plugins.find((p) => p.name === 'Segment.io')
+ const segmentSpy = jest.spyOn(segmentio!, 'track')
+
+ await Promise.race([
+ ajs.track(
+ 'Boo!',
+ {
+ total: 25,
+ userId: 'π»',
+ },
+ {
+ integrations: {
+ All: true,
+ },
+ }
+ ),
+ sleep(100),
+ ])
+
+ expect(segmentSpy).toHaveBeenCalled()
+ })
+
it('enriches events before dispatching', async () => {
const [ajs] = await AnalyticsBrowser.load({
writeKey,
@@ -589,7 +624,7 @@ describe('Dispatch', () => {
})
expect(boo.event.properties).toMatchInlineSnapshot(`
- Object {
+ {
"billingPlan": "free-99",
"total": 25,
}
@@ -610,17 +645,54 @@ describe('Dispatch', () => {
const metrics = delivered.stats.metrics
expect(metrics.map((m) => m.metric)).toMatchInlineSnapshot(`
- Array [
+ [
"message_dispatched",
"plugin_time",
"plugin_time",
"plugin_time",
- "message_delivered",
"plugin_time",
+ "message_delivered",
"delivered",
]
`)
})
+
+ it('respects api and protocol overrides for metrics endpoint', async () => {
+ const [ajs] = await AnalyticsBrowser.load(
+ {
+ writeKey,
+ cdnSettings: {
+ integrations: {
+ 'Segment.io': {
+ apiHost: 'cdnSettings.api.io',
+ },
+ },
+ metrics: {
+ flushTimer: 0,
+ },
+ },
+ },
+ {
+ integrations: {
+ 'Segment.io': {
+ apiHost: 'new.api.io',
+ protocol: 'http',
+ },
+ },
+ }
+ )
+
+ const event = await ajs.track('foo')
+
+ recordIntegrationMetric(event, {
+ integrationName: 'foo',
+ methodName: 'bar',
+ type: 'action',
+ })
+
+ await sleep(10)
+ expect(fetchCalls[1].url).toBe('http://new.api.io/m')
+ })
})
describe('Group', () => {
@@ -1126,7 +1198,7 @@ describe('Options', () => {
iso: '2020-10-10',
})
- const [integrationEvent] = integrationMock.mock.lastCall
+ const [integrationEvent] = integrationMock.mock.lastCall!
expect(integrationEvent.properties()).toEqual({
date: expect.any(Date),
@@ -1163,7 +1235,7 @@ describe('Options', () => {
iso: '2020-10-10',
})
- const [integrationEvent] = integrationMock.mock.lastCall
+ const [integrationEvent] = integrationMock.mock.lastCall!
expect(integrationEvent.properties()).toEqual({
date: expect.any(Date),
@@ -1200,7 +1272,7 @@ describe('Options', () => {
iso: '2020-10-10',
})
- const [integrationEvent] = integrationMock.mock.lastCall
+ const [integrationEvent] = integrationMock.mock.lastCall!
expect(integrationEvent.properties()).toEqual({
date: expect.any(Date),
@@ -1209,4 +1281,56 @@ describe('Options', () => {
expect(integrationEvent.timestamp()).toBeInstanceOf(Date)
})
})
+
+ describe('disable', () => {
+ /**
+ * Note: other tests in null-analytics.test.ts cover the NullAnalytics class (including persistence)
+ */
+ it('should return a null version of analytics / context', async () => {
+ const [analytics, context] = await AnalyticsBrowser.load(
+ {
+ writeKey,
+ },
+ { disable: true }
+ )
+ expect(context).toBeInstanceOf(Context)
+ expect(analytics).toBeInstanceOf(NullAnalytics)
+ expect(analytics.initialized).toBe(true)
+ })
+
+ it('should not fetch cdn settings or dispatch events', async () => {
+ const [analytics] = await AnalyticsBrowser.load(
+ {
+ writeKey,
+ },
+ { disable: true }
+ )
+ await analytics.track('foo')
+ expect(fetchCalls.length).toBe(0)
+ })
+
+ it('should only accept a boolean value', async () => {
+ const [analytics] = await AnalyticsBrowser.load(
+ {
+ writeKey,
+ },
+ // @ts-ignore
+ { disable: 'true' }
+ )
+ expect(analytics).not.toBeInstanceOf(NullAnalytics)
+ })
+
+ it('should allow access to cdnSettings', async () => {
+ const disableSpy = jest.fn().mockReturnValue(true)
+ const [analytics] = await AnalyticsBrowser.load(
+ {
+ cdnSettings: { integrations: {}, foo: 123 },
+ writeKey,
+ },
+ { disable: disableSpy }
+ )
+ expect(analytics).toBeInstanceOf(NullAnalytics)
+ expect(disableSpy).toBeCalledWith({ integrations: {}, foo: 123 })
+ })
+ })
})
diff --git a/packages/browser/src/browser/__tests__/integrations.integration.test.ts b/packages/browser/src/browser/__tests__/integrations.integration.test.ts
index a70f12095..12ec75789 100644
--- a/packages/browser/src/browser/__tests__/integrations.integration.test.ts
+++ b/packages/browser/src/browser/__tests__/integrations.integration.test.ts
@@ -19,6 +19,17 @@ const mockFetchCdnSettings = (cdnSettings: any = {}) => {
.mockImplementation(createMockFetchImplementation(cdnSettings))
}
+jest.spyOn(console, 'warn').mockImplementation((...errMsgs) => {
+ if (errMsgs[0].includes('deprecate')) {
+ // get rid of deprecation wawrning spam
+ return
+ }
+ console.warn(
+ 'Unexpected console.warn spam in your jest test - please stub out. ' +
+ JSON.stringify(errMsgs)
+ )
+})
+
describe('Integrations', () => {
beforeEach(async () => {
mockFetchCdnSettings()
@@ -153,11 +164,11 @@ describe('Integrations', () => {
await analytics.ready()
expect(analytics.Integrations).toMatchInlineSnapshot(`
- Object {
- "Amplitude": [Function],
- "Google-Analytics": [Function],
- }
- `)
+ {
+ "Amplitude": [Function],
+ "Google-Analytics": [Function],
+ }
+ `)
})
it('catches destinations with dots in their names', async () => {
@@ -194,12 +205,12 @@ describe('Integrations', () => {
await analytics.ready()
expect(analytics.Integrations).toMatchInlineSnapshot(`
- Object {
- "Amplitude": [Function],
- "Customer.io": [Function],
- "Google-Analytics": [Function],
- }
- `)
+ {
+ "Amplitude": [Function],
+ "Customer.io": [Function],
+ "Google-Analytics": [Function],
+ }
+ `)
})
it('uses directly provided classic integrations without fetching them from cdn', async () => {
diff --git a/packages/browser/src/browser/__tests__/page-enrichment.integration.test.ts b/packages/browser/src/browser/__tests__/page-enrichment.integration.test.ts
new file mode 100644
index 000000000..23aca8285
--- /dev/null
+++ b/packages/browser/src/browser/__tests__/page-enrichment.integration.test.ts
@@ -0,0 +1,216 @@
+import unfetch from 'unfetch'
+import { Analytics, AnalyticsBrowser } from '../..'
+import { PageContext } from '../../core/page'
+import {
+ cdnSettingsMinimal,
+ createMockFetchImplementation,
+} from '../../test-helpers/fixtures'
+
+jest.mock('unfetch')
+jest.mocked(unfetch).mockImplementation(createMockFetchImplementation())
+
+let ajs: Analytics
+
+beforeEach(async () => {
+ const [analytics] = await AnalyticsBrowser.load({
+ writeKey: 'abc_123',
+ cdnSettings: { ...cdnSettingsMinimal },
+ })
+ ajs = analytics
+})
+describe('Page Enrichment', () => {
+ it('enriches page calls', async () => {
+ const ctx = await ajs.page('Checkout', {})
+
+ expect(ctx.event.properties).toMatchInlineSnapshot(`
+ {
+ "name": "Checkout",
+ "path": "/",
+ "referrer": "",
+ "search": "",
+ "title": "",
+ "url": "http://localhost/",
+ }
+ `)
+ })
+
+ it('enriches track events with the page context', async () => {
+ const ctx = await ajs.track('My event', {
+ banana: 'phone',
+ })
+ expect(ctx.event.context?.page).toMatchInlineSnapshot(`
+ {
+ "path": "/",
+ "referrer": "",
+ "search": "",
+ "title": "",
+ "url": "http://localhost/",
+ }
+ `)
+ })
+
+ describe('event.properties override behavior', () => {
+ it('special page properties in event.properties (url, referrer, etc) are copied to context.page', async () => {
+ const pageProps: PageContext & { [key: string]: unknown } = Object.freeze(
+ {
+ path: 'foo',
+ referrer: 'bar',
+ search: 'baz',
+ title: 'qux',
+ url: 'http://fake.com',
+ should_not_show_up: 'hello',
+ }
+ )
+ const ctx = await ajs.page('My Event', pageProps)
+ const page = ctx.event.context!.page
+ expect(page).toMatchInlineSnapshot(`
+ {
+ "path": "foo",
+ "referrer": "bar",
+ "search": "baz",
+ "title": "qux",
+ "url": "http://fake.com",
+ }
+ `)
+ })
+
+ it('special page properties in event.properties (url, referrer, etc) are not copied to context.page in non-page calls', async () => {
+ const eventProps = Object.freeze({
+ path: 'foo',
+ referrer: 'bar',
+ search: 'baz',
+ url: 'http://fake.com',
+ foo: 'hello',
+ })
+ const ctx = await ajs.track('My Event', eventProps)
+ expect(ctx.event.properties).toMatchInlineSnapshot(`
+ {
+ "foo": "hello",
+ "path": "foo",
+ "referrer": "bar",
+ "search": "baz",
+ "url": "http://fake.com",
+ }
+ `)
+ expect(ctx.event.context!.page).toMatchInlineSnapshot(`
+ {
+ "path": "/",
+ "referrer": "",
+ "search": "",
+ "title": "",
+ "url": "http://localhost/",
+ }
+ `)
+ })
+
+ it('page properties should override defaults in page calls', async () => {
+ const pageProps = Object.freeze({ path: 'override' })
+ const ctx = await ajs.page('My Event', pageProps)
+ const page = ctx.event.context!.page
+ expect(page).toMatchInlineSnapshot(`
+ {
+ "path": "override",
+ "referrer": "",
+ "search": "",
+ "title": "",
+ "url": "http://localhost/",
+ }
+ `)
+ })
+
+ it('undefined / null / empty string properties on event get overridden as usual', async () => {
+ const eventProps = Object.freeze({
+ referrer: '',
+ path: undefined,
+ title: null,
+ })
+
+ const ctx = await ajs.page('My Event', eventProps)
+ const page = ctx.event.context!.page
+ expect(page).toEqual(
+ expect.objectContaining({ referrer: '', path: undefined, title: null })
+ )
+ })
+ })
+
+ it('enriches page events with the page context', async () => {
+ const ctx = await ajs.page(
+ 'My event',
+ { banana: 'phone' },
+ { page: { url: 'not-localhost' } }
+ )
+
+ expect(ctx.event.context?.page).toMatchInlineSnapshot(`
+ {
+ "path": "/",
+ "referrer": "",
+ "search": "",
+ "title": "",
+ "url": "not-localhost",
+ }
+ `)
+ })
+ it('enriches page events using properties', async () => {
+ const ctx = await ajs.page('My event', { banana: 'phone', referrer: 'foo' })
+
+ expect(ctx.event.context?.page).toMatchInlineSnapshot(`
+ {
+ "path": "/",
+ "referrer": "foo",
+ "search": "",
+ "title": "",
+ "url": "http://localhost/",
+ }
+ `)
+ })
+
+ it('in page events, event.name overrides event.properties.name', async () => {
+ const ctx = await ajs.page('My Event', undefined, undefined, {
+ name: 'some propery name',
+ })
+ expect(ctx.event.properties!.name).toBe('My Event')
+ })
+
+ it('in non-page events, event.name does not override event.properties.name', async () => {
+ const ctx = await ajs.track('My Event', {
+ name: 'some propery name',
+ })
+ expect(ctx.event.properties!.name).toBe('some propery name')
+ })
+
+ it('enriches identify events with the page context', async () => {
+ const ctx = await ajs.identify('Netto', {
+ banana: 'phone',
+ })
+
+ expect(ctx.event.context?.page).toMatchInlineSnapshot(`
+ {
+ "path": "/",
+ "referrer": "",
+ "search": "",
+ "title": "",
+ "url": "http://localhost/",
+ }
+ `)
+ })
+
+ it('page object is accessible in all plugins', async () => {
+ await ajs.addSourceMiddleware(({ payload, next }) => {
+ expect(payload.obj?.context?.page).toMatchInlineSnapshot(`
+ {
+ "path": "/",
+ "referrer": "",
+ "search": "",
+ "title": "",
+ "url": "http://localhost/",
+ }
+ `)
+ next(payload)
+ })
+
+ await ajs.track('My event', {
+ banana: 'phone',
+ })
+ expect.assertions(1)
+ })
+})
diff --git a/packages/browser/src/browser/__tests__/standalone-analytics.test.ts b/packages/browser/src/browser/__tests__/standalone-analytics.test.ts
index c13c5f426..e569b9f93 100644
--- a/packages/browser/src/browser/__tests__/standalone-analytics.test.ts
+++ b/packages/browser/src/browser/__tests__/standalone-analytics.test.ts
@@ -9,6 +9,7 @@ import { sleep } from '../../lib/sleep'
import * as Factory from '../../test-helpers/factories'
import { EventQueue } from '../../core/queue/event-queue'
import { AnalyticsStandalone } from '../standalone-interface'
+import { getBufferedPageCtxFixture } from '../../test-helpers/fixtures'
const track = jest.fn()
const identify = jest.fn()
@@ -27,10 +28,9 @@ jest.mock('../../core/analytics', () => ({
addSourceMiddleware,
register,
emit: jest.fn(),
+ ready: () => Promise.resolve(),
on,
- queue: new EventQueue(
- new PersistedPriorityQueue(1, 'foo:event-queue') as any
- ),
+ queue: new EventQueue(new PersistedPriorityQueue(1, 'event-queue') as any),
options,
}),
}))
@@ -68,6 +68,7 @@ describe('standalone bundle', () => {
`.trim()
const virtualConsole = new jsdom.VirtualConsole()
+
const jsd = new JSDOM(html, {
runScripts: 'dangerously',
resources: 'usable',
@@ -159,12 +160,20 @@ describe('standalone bundle', () => {
await sleep(0)
- expect(track).toHaveBeenCalledWith('fruit basket', {
- fruits: ['π', 'π'],
- })
- expect(identify).toHaveBeenCalledWith('netto', {
- employer: 'segment',
- })
+ expect(track).toHaveBeenCalledWith(
+ 'fruit basket',
+ {
+ fruits: ['π', 'π'],
+ },
+ getBufferedPageCtxFixture()
+ )
+ expect(identify).toHaveBeenCalledWith(
+ 'netto',
+ {
+ employer: 'segment',
+ },
+ getBufferedPageCtxFixture()
+ )
expect(page).toHaveBeenCalled()
})
@@ -270,13 +279,25 @@ describe('standalone bundle', () => {
await sleep(0)
- expect(track).toHaveBeenCalledWith('fruit basket', {
- fruits: ['π', 'π'],
- })
- expect(track).toHaveBeenCalledWith('race conditions', { foo: 'bar' })
- expect(identify).toHaveBeenCalledWith('netto', {
- employer: 'segment',
- })
+ expect(track).toHaveBeenCalledWith(
+ 'fruit basket',
+ {
+ fruits: ['π', 'π'],
+ },
+ getBufferedPageCtxFixture()
+ )
+ expect(track).toHaveBeenCalledWith(
+ 'race conditions',
+ { foo: 'bar' },
+ getBufferedPageCtxFixture()
+ )
+ expect(identify).toHaveBeenCalledWith(
+ 'netto',
+ {
+ employer: 'segment',
+ },
+ getBufferedPageCtxFixture()
+ )
expect(page).toHaveBeenCalled()
})
diff --git a/packages/browser/src/browser/__tests__/standalone-errors.test.ts b/packages/browser/src/browser/__tests__/standalone-errors.test.ts
index b402b26b3..636a30743 100644
--- a/packages/browser/src/browser/__tests__/standalone-errors.test.ts
+++ b/packages/browser/src/browser/__tests__/standalone-errors.test.ts
@@ -111,10 +111,10 @@ describe('standalone bundle', () => {
)
expect(metrics).toMatchInlineSnapshot(`
- Array [
- Object {
+ [
+ {
"metric": "analytics_js.invoke.error",
- "tags": Array [
+ "tags": [
"type:initialization",
"message:Ohhh nooo",
"name:Error",
@@ -124,7 +124,7 @@ describe('standalone bundle', () => {
]
`)
expect(errorMessages).toMatchInlineSnapshot(`
- Array [
+ [
"[analytics.js],Failed to load Analytics.js,Error: Ohhh nooo",
]
`)
diff --git a/packages/browser/src/browser/index.ts b/packages/browser/src/browser/index.ts
index 104f416b6..1c8eed21e 100644
--- a/packages/browser/src/browser/index.ts
+++ b/packages/browser/src/browser/index.ts
@@ -2,14 +2,19 @@ import { getProcessEnv } from '../lib/get-process-env'
import { getCDN, setGlobalCDNUrl } from '../lib/parse-cdn'
import { fetch } from '../lib/fetch'
-import { Analytics, AnalyticsSettings, InitOptions } from '../core/analytics'
+import {
+ Analytics,
+ AnalyticsSettings,
+ NullAnalytics,
+ InitOptions,
+} from '../core/analytics'
import { Context } from '../core/context'
import { Plan } from '../core/events'
import { Plugin } from '../core/plugin'
import { MetricsOptions } from '../core/stats/remote-metrics'
import { mergedOptions } from '../lib/merged-options'
-import { createDeferred } from '../lib/create-deferred'
-import { pageEnrichment } from '../plugins/page-enrichment'
+import { createDeferred } from '@segment/analytics-generic-utils'
+import { envEnrichment } from '../plugins/env-enrichment'
import {
PluginFactory,
remoteLoader,
@@ -25,8 +30,8 @@ import {
flushAddSourceMiddleware,
flushSetAnonymousID,
flushOn,
+ PreInitMethodCall,
} from '../core/buffer'
-import { popSnippetWindowBuffer } from '../core/buffer/snippet'
import { ClassicIntegrationSource } from '../plugins/ajs-destination/types'
import { attachInspector } from '../core/inspector'
import { Stats } from '../core/stats'
@@ -87,11 +92,16 @@ export interface LegacySettings {
*/
consentSettings?: {
/**
- * All unique consent categories.
+ * All unique consent categories for enabled destinations.
* There can be categories in this array that are important for consent that are not included in any integration (e.g. 2 cloud mode categories).
* @example ["Analytics", "Advertising", "CAT001"]
*/
allCategories: string[]
+
+ /**
+ * Whether or not there are any unmapped destinations for enabled destinations.
+ */
+ hasUnmappedDestinations: boolean
}
}
@@ -156,7 +166,6 @@ function flushPreBuffer(
analytics: Analytics,
buffer: PreInitMethodCallBuffer
): void {
- buffer.push(...popSnippetWindowBuffer())
flushSetAnonymousID(analytics, buffer)
flushOn(analytics, buffer)
}
@@ -170,9 +179,7 @@ async function flushFinalBuffer(
): Promise {
// Call popSnippetWindowBuffer before each flush task since there may be
// analytics calls during async function calls.
- buffer.push(...popSnippetWindowBuffer())
await flushAddSourceMiddleware(analytics, buffer)
- buffer.push(...popSnippetWindowBuffer())
flushAnalyticsCallsInNewTask(analytics, buffer)
// Clear buffer, just in case analytics is loaded twice; we don't want to fire events off again.
buffer.clear()
@@ -182,7 +189,6 @@ async function registerPlugins(
writeKey: string,
legacySettings: LegacySettings,
analytics: Analytics,
- opts: InitOptions,
options: InitOptions,
pluginLikes: (Plugin | PluginFactory)[] = [],
legacyIntegrationSources: ClassicIntegrationSource[]
@@ -216,7 +222,7 @@ async function registerPlugins(
writeKey,
legacySettings,
analytics.integrations,
- opts,
+ options,
tsubMiddleware,
legacyIntegrationSources
)
@@ -231,11 +237,11 @@ async function registerPlugins(
})
}
- const schemaFilter = opts.plan?.track
+ const schemaFilter = options.plan?.track
? await import(
/* webpackChunkName: "schemaFilter" */ '../plugins/schema-filter'
).then((mod) => {
- return mod.schemaFilter(opts.plan?.track, legacySettings)
+ return mod.schemaFilter(options.plan?.track, legacySettings)
})
: undefined
@@ -244,14 +250,14 @@ async function registerPlugins(
legacySettings,
analytics.integrations,
mergedSettings,
- options.obfuscate,
+ options,
tsubMiddleware,
pluginSources
).catch(() => [])
const toRegister = [
validation,
- pageEnrichment,
+ envEnrichment,
...plugins,
...legacyDestinations,
...remotePlugins,
@@ -262,8 +268,9 @@ async function registerPlugins(
}
const shouldIgnoreSegmentio =
- (opts.integrations?.All === false && !opts.integrations['Segment.io']) ||
- (opts.integrations && opts.integrations['Segment.io'] === false)
+ (options.integrations?.All === false &&
+ !options.integrations['Segment.io']) ||
+ (options.integrations && options.integrations['Segment.io'] === false)
if (!shouldIgnoreSegmentio) {
toRegister.push(
@@ -301,29 +308,59 @@ async function loadAnalytics(
options: InitOptions = {},
preInitBuffer: PreInitMethodCallBuffer
): Promise<[Analytics, Context]> {
+ // return no-op analytics instance if disabled
+ if (options.disable === true) {
+ return [new NullAnalytics(), Context.system()]
+ }
+
if (options.globalAnalyticsKey)
setGlobalAnalyticsKey(options.globalAnalyticsKey)
// this is an ugly side-effect, but it's for the benefits of the plugins that get their cdn via getCDN()
if (settings.cdnURL) setGlobalCDNUrl(settings.cdnURL)
let legacySettings: any = { integrations: {} }
+ if (options.initialPageview) {
+ // capture the page context early, so it's always up-to-date
+ preInitBuffer.push(new PreInitMethodCall('page', []))
+ }
if (options.updateCDNSettings) {
legacySettings = options.updateCDNSettings(legacySettings)
}
+ // if options.disable is a function, we allow user to disable analytics based on CDN Settings
+ if (typeof options.disable === 'function') {
+ const disabled = await options.disable(legacySettings)
+ if (disabled) {
+ return [new NullAnalytics(), Context.system()]
+ }
+ }
+
const retryQueue: boolean =
legacySettings.integrations['Segment.io']?.retryQueue ?? true
- const opts: InitOptions = { retryQueue, ...options }
- const analytics = new Analytics(settings, opts)
+ options = {
+ retryQueue,
+ ...options,
+ }
+
+ const analytics = new Analytics(settings, options)
attachInspector(analytics)
const plugins = settings.plugins ?? []
const classicIntegrations = settings.classicIntegrations ?? []
- Stats.initRemoteMetrics(legacySettings.metrics)
+
+ const segmentLoadOptions = options.integrations?.['Segment.io'] as
+ | SegmentioSettings
+ | undefined
+
+ Stats.initRemoteMetrics({
+ ...legacySettings.metrics,
+ host: segmentLoadOptions?.apiHost ?? legacySettings.metrics?.host,
+ protocol: segmentLoadOptions?.protocol,
+ })
// needs to be flushed before plugins are registered
flushPreBuffer(analytics, preInitBuffer)
@@ -332,7 +369,6 @@ async function loadAnalytics(
settings.writeKey,
legacySettings,
analytics,
- opts,
options,
plugins,
classicIntegrations
@@ -354,10 +390,6 @@ async function loadAnalytics(
analytics.initialized = true
analytics.emit('initialize', settings, options)
- if (options.initialPageview) {
- analytics.page().catch(console.error)
- }
-
await flushFinalBuffer(analytics, preInitBuffer)
return [analytics, ctx]
diff --git a/packages/browser/src/browser/standalone.ts b/packages/browser/src/browser/standalone.ts
index 69f86f6fe..039653eee 100644
--- a/packages/browser/src/browser/standalone.ts
+++ b/packages/browser/src/browser/standalone.ts
@@ -28,6 +28,7 @@ import {
loadAjsClassicFallback,
isAnalyticsCSPError,
} from '../lib/csp-detection'
+import { setGlobalAnalyticsKey } from '../lib/global-analytics-helper'
let ajsIdentifiedCSP = false
@@ -71,6 +72,16 @@ async function attempt(promise: () => Promise) {
}
}
+const globalAnalyticsKey = (
+ document.querySelector(
+ 'script[data-global-segment-analytics-key]'
+ ) as HTMLScriptElement
+)?.dataset.globalSegmentAnalyticsKey
+
+if (globalAnalyticsKey) {
+ setGlobalAnalyticsKey(globalAnalyticsKey)
+}
+
if (shouldPolyfill()) {
// load polyfills in order to get AJS to work with old browsers
const script = document.createElement('script')
diff --git a/packages/browser/src/core/__tests__/auto-track.test.ts b/packages/browser/src/core/__tests__/auto-track.test.ts
deleted file mode 100644
index 1c9d27ebd..000000000
--- a/packages/browser/src/core/__tests__/auto-track.test.ts
+++ /dev/null
@@ -1,440 +0,0 @@
-import { JSDOM } from 'jsdom'
-import { Analytics } from '../analytics'
-
-const sleep = (time: number): Promise =>
- new Promise((resolve) => {
- setTimeout(resolve, time)
- })
-
-async function resolveWhen(
- condition: () => boolean,
- timeout?: number
-): Promise {
- return new Promise((resolve, _reject) => {
- if (condition()) {
- resolve()
- return
- }
-
- const check = () =>
- setTimeout(() => {
- if (condition()) {
- resolve()
- } else {
- check()
- }
- }, timeout)
-
- check()
- })
-}
-
-describe('track helpers', () => {
- describe('trackLink', () => {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- let link: any
- let wrap: SVGSVGElement
- let svg: SVGAElement
-
- let analytics = new Analytics({ writeKey: 'foo' })
- let mockTrack = jest.spyOn(analytics, 'track')
-
- beforeEach(() => {
- analytics = new Analytics({ writeKey: 'foo' })
-
- // @ts-ignore
- global.jQuery = require('jquery')
-
- const jsd = new JSDOM('', {
- runScripts: 'dangerously',
- resources: 'usable',
- })
- // eslint-disable-next-line no-global-assign
- document = jsd.window.document
-
- jest.spyOn(console, 'error').mockImplementationOnce(() => {})
-
- document.querySelector('html')!.innerHTML = `
-
-
-
-
-
- `
-
- link = document.getElementById('foo')
- wrap = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
- svg = document.createElementNS('http://www.w3.org/2000/svg', 'a')
- wrap.appendChild(svg)
- document.body.appendChild(wrap)
-
- jest.spyOn(window, 'location', 'get').mockReturnValue({
- ...window.location,
- })
-
- mockTrack = jest.spyOn(analytics, 'track')
-
- // We need to mock the track function for the .catch() call not to break when testing
- // eslint-disable-next-line @typescript-eslint/unbound-method
- mockTrack.mockImplementation(Analytics.prototype.track)
- })
-
- it('should respect options object', async () => {
- await analytics.trackLink(
- link!,
- 'foo',
- {},
- { context: { ip: '0.0.0.0' } }
- )
- link.click()
-
- expect(mockTrack).toHaveBeenCalledWith(
- 'foo',
- {},
- { context: { ip: '0.0.0.0' } }
- )
- })
-
- it('should stay on same page with blank href', async () => {
- link.href = ''
- await analytics.trackLink(link!, 'foo')
- link.click()
-
- expect(mockTrack).toHaveBeenCalled()
- expect(window.location.href).toBe('http://localhost/')
- })
-
- it('should work with nested link', async () => {
- const nested = document.getElementById('bar')
- await analytics.trackLink(nested, 'foo')
- nested!.click()
-
- expect(mockTrack).toHaveBeenCalled()
-
- await resolveWhen(() => window.location.href === 'bar.com')
- expect(window.location.href).toBe('bar.com')
- })
-
- it('should make a track call', async () => {
- await analytics.trackLink(link!, 'foo')
- link.click()
-
- expect(mockTrack).toHaveBeenCalled()
- })
-
- it('should still navigate even if the track call fails', async () => {
- mockTrack.mockClear()
-
- let rejected = false
- mockTrack.mockImplementationOnce(() => {
- rejected = true
- return Promise.reject(new Error('boo!'))
- })
-
- const nested = document.getElementById('bar')
- await analytics.trackLink(nested, 'foo')
- nested!.click()
-
- await resolveWhen(() => rejected)
- await resolveWhen(() => window.location.href === 'bar.com')
- expect(window.location.href).toBe('bar.com')
- })
-
- it('should still navigate even if the track call times out', async () => {
- mockTrack.mockClear()
-
- let timedOut = false
- mockTrack.mockImplementationOnce(async () => {
- await sleep(600)
- timedOut = true
- return Promise.resolve() as any
- })
-
- const nested = document.getElementById('bar')
- await analytics.trackLink(nested, 'foo')
- nested!.click()
-
- await resolveWhen(() => window.location.href === 'bar.com')
- expect(window.location.href).toBe('bar.com')
- expect(timedOut).toBe(false)
-
- await resolveWhen(() => timedOut)
- })
-
- it('should accept a jquery object for an element', async () => {
- const $link = jQuery(link)
- await analytics.trackLink($link, 'foo')
- link.click()
- expect(mockTrack).toBeCalled()
- })
-
- it('accepts array of elements', async () => {
- const links = [link, link]
- await analytics.trackLink(links, 'foo')
- link.click()
-
- expect(mockTrack).toHaveBeenCalled()
- })
-
- it('should send an event and properties', async () => {
- await analytics.trackLink(link, 'event', { property: true })
- link.click()
-
- expect(mockTrack).toBeCalledWith('event', { property: true }, {})
- })
-
- it('should accept an event function', async () => {
- function event(el: Element): string {
- return el.nodeName
- }
- await analytics.trackLink(link, event, { foo: 'bar' })
- link.click()
-
- expect(mockTrack).toBeCalledWith('A', { foo: 'bar' }, {})
- })
-
- it('should accept a properties function', async () => {
- function properties(el: Record): Record {
- return { type: el.nodeName }
- }
- await analytics.trackLink(link, 'event', properties)
- link.click()
-
- expect(mockTrack).toBeCalledWith('event', { type: 'A' }, {})
- })
-
- it('should load an href on click', async () => {
- link.href = '#test'
- await analytics.trackLink(link, 'foo')
- link.click()
-
- await resolveWhen(() => window.location.href === '#test')
- expect(global.window.location.href).toBe('#test')
- })
-
- it('should only navigate after the track call has been completed', async () => {
- link.href = '#test'
- await analytics.trackLink(link, 'foo')
- link.click()
-
- await resolveWhen(() => mockTrack.mock.calls.length === 1)
- await resolveWhen(() => window.location.href === '#test')
-
- expect(global.window.location.href).toBe('#test')
- })
-
- it('should support svg .href attribute', async () => {
- svg.setAttribute('href', '#svg')
- await analytics.trackLink(svg, 'foo')
- const clickEvent = new Event('click')
- svg.dispatchEvent(clickEvent)
-
- await resolveWhen(() => window.location.href === '#svg')
- expect(global.window.location.href).toBe('#svg')
- })
-
- it('should fallback to getAttributeNS', async () => {
- svg.setAttributeNS('http://www.w3.org/1999/xlink', 'href', '#svg')
- await analytics.trackLink(svg, 'foo')
- const clickEvent = new Event('click')
- svg.dispatchEvent(clickEvent)
-
- await resolveWhen(() => window.location.href === '#svg')
- expect(global.window.location.href).toBe('#svg')
- })
-
- it('should support xlink:href', async () => {
- svg.setAttribute('xlink:href', '#svg')
- await analytics.trackLink(svg, 'foo')
- const clickEvent = new Event('click')
- svg.dispatchEvent(clickEvent)
-
- await resolveWhen(() => window.location.href === '#svg')
- expect(global.window.location.href).toBe('#svg')
- })
-
- it('should not load an href for a link with a blank target', async () => {
- link.href = '/base/test/support/mock.html'
- link.target = '_blank'
- await analytics.trackLink(link, 'foo')
- link.click()
-
- await sleep(300)
- expect(global.window.location.href).not.toBe('#test')
- })
- })
-
- describe('trackForm', () => {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- let form: any
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- let submit: any
-
- const analytics = new Analytics({ writeKey: 'foo' })
- let mockTrack = jest.spyOn(analytics, 'track')
-
- beforeEach(() => {
- document.querySelector('html')!.innerHTML = `
-
-
-
-
- `
- form = document.getElementById('form')
- submit = document.getElementById('submit')
-
- // @ts-ignore
- global.jQuery = require('jquery')
-
- mockTrack = jest.spyOn(analytics, 'track')
- // eslint-disable-next-line @typescript-eslint/unbound-method
- mockTrack.mockImplementation(Analytics.prototype.track)
- })
-
- afterEach(() => {
- window.location.hash = ''
- document.body.removeChild(form)
- })
-
- it('should not error or send track event on null form', async () => {
- const form = document.getElementById('fake-form') as HTMLFormElement
-
- await analytics.trackForm(form, 'Signed Up', {
- plan: 'Premium',
- revenue: 99.0,
- })
- submit.click()
- expect(mockTrack).not.toBeCalled()
- })
-
- it('should respect options object', async () => {
- await analytics.trackForm(form, 'foo', {}, { context: { ip: '0.0.0.0' } })
- submit.click()
-
- expect(mockTrack).toHaveBeenCalledWith(
- 'foo',
- {},
- { context: { ip: '0.0.0.0' } }
- )
- })
-
- it('should trigger a track on a form submit', async () => {
- await analytics.trackForm(form, 'foo')
- submit.click()
- expect(mockTrack).toBeCalled()
- })
-
- it('should accept a jquery object for an element', async () => {
- await analytics.trackForm(form, 'foo')
- submit.click()
- expect(mockTrack).toBeCalled()
- })
-
- it('should not accept a string for an element', async () => {
- try {
- // @ts-expect-error
- await analytics.trackForm('foo')
- submit.click()
- } catch (e) {
- expect(e instanceof TypeError).toBe(true)
- }
- expect(mockTrack).not.toBeCalled()
- })
-
- it('should send an event and properties', async () => {
- await analytics.trackForm(form, 'event', { property: true })
- submit.click()
- expect(mockTrack).toBeCalledWith('event', { property: true }, {})
- })
-
- it('should accept an event function', async () => {
- function event(): string {
- return 'event'
- }
- await analytics.trackForm(form, event, { foo: 'bar' })
- submit.click()
- expect(mockTrack).toBeCalledWith('event', { foo: 'bar' }, {})
- })
-
- it('should accept a properties function', async () => {
- function properties(): Record {
- return { property: true }
- }
- await analytics.trackForm(form, 'event', properties)
- submit.click()
- expect(mockTrack).toBeCalledWith('event', { property: true }, {})
- })
-
- it('should call submit after a timeout', async () => {
- const submitSpy = jest.spyOn(form, 'submit')
- const mockedTrack = jest.fn()
-
- // eslint-disable-next-line @typescript-eslint/unbound-method
- mockedTrack.mockImplementation(Analytics.prototype.track)
-
- analytics.track = mockedTrack
- await analytics.trackForm(form, 'foo')
-
- submit.click()
-
- await sleep(500)
-
- expect(submitSpy).toHaveBeenCalled()
- })
-
- it('should trigger an existing submit handler', async () => {
- const submitPromise = new Promise((resolve) => {
- form.addEventListener('submit', () => {
- resolve()
- })
- })
-
- await analytics.trackForm(form, 'foo')
- submit.click()
- await submitPromise
- })
-
- it('should trigger an existing jquery submit handler', async () => {
- const $form = jQuery(form)
-
- const submitPromise = new Promise((resolve) => {
- $form.submit(function () {
- resolve()
- })
- })
-
- await analytics.trackForm(form, 'foo')
- submit.click()
- await submitPromise
- })
-
- it('should track on a form submitted via jquery', async () => {
- const $form = jQuery(form)
-
- await analytics.trackForm(form, 'foo')
- $form.submit()
-
- expect(mockTrack).toBeCalled()
- })
-
- it('should trigger an existing jquery submit handler on a form submitted via jquery', async () => {
- const $form = jQuery(form)
-
- const submitPromise = new Promise((resolve) => {
- $form.submit(function () {
- resolve()
- })
- })
-
- await analytics.trackForm(form, 'foo')
- $form.submit()
- await submitPromise
- })
- })
-})
diff --git a/packages/browser/src/core/__tests__/track-form.test.ts b/packages/browser/src/core/__tests__/track-form.test.ts
new file mode 100644
index 000000000..92fff0d58
--- /dev/null
+++ b/packages/browser/src/core/__tests__/track-form.test.ts
@@ -0,0 +1,193 @@
+import { Analytics } from '../analytics'
+import { sleep } from '../../lib/sleep'
+import { getDefaultPageContext } from '../page'
+
+let analytics: Analytics
+let mockTrack: jest.SpiedFunction
+const ogLocation = {
+ ...global.window.location,
+}
+
+beforeEach(() => {
+ // @ts-ignore
+ global.jQuery = require('jquery')
+
+ jest.spyOn(console, 'error').mockImplementationOnce(() => {})
+ Object.defineProperty(window, 'location', {
+ value: ogLocation,
+ writable: true,
+ })
+ mockTrack = jest.spyOn(Analytics.prototype, 'track')
+ analytics = new Analytics({ writeKey: 'foo' })
+})
+
+describe('trackForm', () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let form: any
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let submit: any
+
+ beforeEach(() => {
+ document.querySelector('html')!.innerHTML = `
+
+
+
+
+ `
+ form = document.getElementById('form')
+ submit = document.getElementById('submit')
+
+ // @ts-ignore
+ global.jQuery = require('jquery')
+ })
+
+ afterEach(() => {
+ document.querySelector('html')!.innerHTML = ''
+ })
+
+ it('should have the correct page context', async () => {
+ window.location.href = 'http://bar.com?foo=123'
+ window.location.search = '?foo=123'
+ await analytics.trackForm(form, 'foo', {}, { context: { ip: '0.0.0.0' } })
+ submit.click()
+ const [, , properties] = mockTrack.mock.lastCall as any[]
+
+ expect((properties.context as any).page).toEqual({
+ ...getDefaultPageContext(),
+ url: 'http://bar.com?foo=123',
+ search: '?foo=123',
+ })
+ })
+
+ it('should not error or send track event on null form', async () => {
+ const form = document.getElementById('fake-form') as HTMLFormElement
+
+ await analytics.trackForm(form, 'Signed Up', {
+ plan: 'Premium',
+ revenue: 99.0,
+ })
+ submit.click()
+ expect(mockTrack).not.toBeCalled()
+ })
+
+ it('should respect options object', async () => {
+ await analytics.trackForm(form, 'foo', {}, { context: { ip: '0.0.0.0' } })
+ submit.click()
+
+ expect(mockTrack).toHaveBeenCalledWith(
+ 'foo',
+ {},
+ { context: expect.objectContaining({ ip: '0.0.0.0' }) }
+ )
+ })
+
+ it('should trigger a track on a form submit', async () => {
+ await analytics.trackForm(form, 'foo')
+ submit.click()
+ expect(mockTrack).toBeCalled()
+ })
+
+ it('should accept a jquery object for an element', async () => {
+ await analytics.trackForm(form, 'foo')
+ submit.click()
+ expect(mockTrack).toBeCalled()
+ })
+
+ it('should not accept a string for an element', async () => {
+ try {
+ // @ts-expect-error
+ await analytics.trackForm('foo')
+ submit.click()
+ } catch (e) {
+ expect(e instanceof TypeError).toBe(true)
+ }
+ expect(mockTrack).not.toBeCalled()
+ })
+
+ it('should send an event and properties', async () => {
+ await analytics.trackForm(form, 'event', { property: true })
+ submit.click()
+ expect(mockTrack).toBeCalledWith('event', { property: true }, {})
+ })
+
+ it('should accept an event function', async () => {
+ function event(): string {
+ return 'event'
+ }
+ await analytics.trackForm(form, event, { foo: 'bar' })
+ submit.click()
+ expect(mockTrack).toBeCalledWith('event', { foo: 'bar' }, {})
+ })
+
+ it('should accept a properties function', async () => {
+ function properties(): Record {
+ return { property: true }
+ }
+ await analytics.trackForm(form, 'event', properties)
+ submit.click()
+ expect(mockTrack).toBeCalledWith('event', { property: true }, {})
+ })
+
+ it('should call submit after a timeout', async () => {
+ const submitSpy = jest.spyOn(form, 'submit')
+
+ await analytics.trackForm(form, 'foo')
+
+ submit.click()
+
+ await sleep(300)
+
+ expect(submitSpy).toHaveBeenCalled()
+ })
+
+ it('should trigger an existing submit handler', async () => {
+ const submitPromise = new Promise((resolve) => {
+ form.addEventListener('submit', () => {
+ resolve()
+ })
+ })
+
+ await analytics.trackForm(form, 'foo')
+ submit.click()
+ await submitPromise
+ })
+
+ it('should trigger an existing jquery submit handler', async () => {
+ const $form = jQuery(form)
+
+ const submitPromise = new Promise((resolve) => {
+ $form.submit(function () {
+ resolve()
+ })
+ })
+
+ await analytics.trackForm(form, 'foo')
+ submit.click()
+ await submitPromise
+ })
+
+ it('should track on a form submitted via jquery', async () => {
+ const $form = jQuery(form)
+
+ await analytics.trackForm(form, 'foo')
+ $form.submit()
+
+ expect(mockTrack).toBeCalled()
+ })
+
+ it('should trigger an existing jquery submit handler on a form submitted via jquery', async () => {
+ const $form = jQuery(form)
+
+ const submitPromise = new Promise((resolve) => {
+ $form.submit(function () {
+ resolve()
+ })
+ })
+
+ await analytics.trackForm(form, 'foo')
+ $form.submit()
+ await submitPromise
+ })
+})
diff --git a/packages/browser/src/core/__tests__/track-link.test.ts b/packages/browser/src/core/__tests__/track-link.test.ts
new file mode 100644
index 000000000..363de0cfd
--- /dev/null
+++ b/packages/browser/src/core/__tests__/track-link.test.ts
@@ -0,0 +1,252 @@
+import { Analytics } from '../analytics'
+import { sleep } from '../../lib/sleep'
+
+async function resolveWhen(
+ condition: () => boolean,
+ timeout?: number
+): Promise {
+ return new Promise((resolve, _reject) => {
+ if (condition()) {
+ resolve()
+ return
+ }
+
+ const check = () =>
+ setTimeout(() => {
+ if (condition()) {
+ resolve()
+ } else {
+ check()
+ }
+ }, timeout)
+
+ check()
+ })
+}
+
+const ogLocation = {
+ ...global.window.location,
+}
+
+let analytics: Analytics
+let mockTrack: jest.SpiedFunction
+beforeEach(() => {
+ Object.defineProperty(window, 'location', {
+ value: ogLocation,
+ writable: true,
+ })
+ mockTrack = jest.spyOn(Analytics.prototype, 'track')
+ analytics = new Analytics({ writeKey: 'foo' })
+})
+describe('trackLink', () => {
+ let link: any
+ let wrap: SVGSVGElement
+ let svg: SVGAElement
+
+ beforeEach(() => {
+ // @ts-ignore
+ global.jQuery = require('jquery')
+
+ jest.spyOn(console, 'error').mockImplementationOnce(() => {})
+
+ document.querySelector('html')!.innerHTML = `
+
+
+
+
+
+ `
+
+ link = document.getElementById('foo')
+ wrap = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
+ svg = document.createElementNS('http://www.w3.org/2000/svg', 'a')
+ wrap.appendChild(svg)
+ document.body.appendChild(wrap)
+ })
+
+ afterEach(() => {
+ document.querySelector('html')!.innerHTML = ''
+ })
+ it('should respect options object', async () => {
+ await analytics.trackLink(link!, 'foo', {}, { context: { ip: '0.0.0.0' } })
+ link.click()
+
+ expect(mockTrack).toHaveBeenCalledWith(
+ 'foo',
+ {},
+ { context: expect.objectContaining({ ip: '0.0.0.0' }) }
+ )
+ })
+
+ it('should stay on same page with blank href', async () => {
+ link.href = ''
+ await analytics.trackLink(link!, 'foo')
+ link.click()
+
+ expect(mockTrack).toHaveBeenCalled()
+ expect(window.location.href).toBe('http://localhost/')
+ })
+
+ it('should work with nested link', async () => {
+ const nested = document.getElementById('bar')
+ await analytics.trackLink(nested, 'foo')
+ nested!.click()
+
+ expect(mockTrack).toHaveBeenCalled()
+
+ await resolveWhen(() => window.location.href === 'bar.com')
+ expect(window.location.href).toBe('bar.com')
+ })
+
+ it('should make a track call', async () => {
+ await analytics.trackLink(link!, 'foo')
+ link.click()
+
+ expect(mockTrack).toHaveBeenCalled()
+ })
+
+ it('should still navigate even if the track call fails', async () => {
+ mockTrack.mockClear()
+
+ let rejected = false
+ mockTrack.mockImplementationOnce(() => {
+ rejected = true
+ return Promise.reject(new Error('boo!'))
+ })
+
+ const nested = document.getElementById('bar')
+ await analytics.trackLink(nested, 'foo')
+ nested!.click()
+
+ await resolveWhen(() => rejected)
+ await resolveWhen(() => window.location.href === 'bar.com')
+ expect(window.location.href).toBe('bar.com')
+ })
+
+ it('should still navigate even if the track call times out', async () => {
+ mockTrack.mockClear()
+
+ let timedOut = false
+ mockTrack.mockImplementationOnce(async () => {
+ await sleep(600)
+ timedOut = true
+ return Promise.resolve() as any
+ })
+
+ const nested = document.getElementById('bar')
+ await analytics.trackLink(nested, 'foo')
+ nested!.click()
+
+ await resolveWhen(() => window.location.href === 'bar.com')
+ expect(window.location.href).toBe('bar.com')
+ expect(timedOut).toBe(false)
+
+ await resolveWhen(() => timedOut)
+ })
+
+ it('should accept a jquery object for an element', async () => {
+ const $link = jQuery(link)
+ await analytics.trackLink($link, 'foo')
+ link.click()
+ expect(mockTrack).toBeCalled()
+ })
+
+ it('accepts array of elements', async () => {
+ const links = [link, link]
+ await analytics.trackLink(links, 'foo')
+ link.click()
+
+ expect(mockTrack).toHaveBeenCalled()
+ })
+
+ it('should send an event and properties', async () => {
+ await analytics.trackLink(link, 'event', { property: true })
+ link.click()
+
+ expect(mockTrack).toBeCalledWith('event', { property: true }, {})
+ })
+
+ it('should accept an event function', async () => {
+ function event(el: Element): string {
+ return el.nodeName
+ }
+ await analytics.trackLink(link, event, { foo: 'bar' })
+ link.click()
+
+ expect(mockTrack).toBeCalledWith('A', { foo: 'bar' }, {})
+ })
+
+ it('should accept a properties function', async () => {
+ function properties(el: Record): Record {
+ return { type: el.nodeName }
+ }
+ await analytics.trackLink(link, 'event', properties)
+ link.click()
+
+ expect(mockTrack).toBeCalledWith('event', { type: 'A' }, {})
+ })
+
+ it('should load an href on click', async () => {
+ link.href = '#test'
+ await analytics.trackLink(link, 'foo')
+ link.click()
+
+ await resolveWhen(() => window.location.href === '#test')
+ expect(global.window.location.href).toBe('#test')
+ })
+
+ it('should only navigate after the track call has been completed', async () => {
+ link.href = '#test'
+ await analytics.trackLink(link, 'foo')
+ link.click()
+
+ await resolveWhen(() => mockTrack.mock.calls.length === 1)
+ await resolveWhen(() => window.location.href === '#test')
+
+ expect(global.window.location.href).toBe('#test')
+ })
+
+ it('should support svg .href attribute', async () => {
+ svg.setAttribute('href', '#svg')
+ await analytics.trackLink(svg, 'foo')
+ const clickEvent = new Event('click')
+ svg.dispatchEvent(clickEvent)
+
+ await resolveWhen(() => window.location.href === '#svg')
+ expect(global.window.location.href).toBe('#svg')
+ })
+
+ it('should fallback to getAttributeNS', async () => {
+ svg.setAttributeNS('http://www.w3.org/1999/xlink', 'href', '#svg')
+ await analytics.trackLink(svg, 'foo')
+ const clickEvent = new Event('click')
+ svg.dispatchEvent(clickEvent)
+
+ await resolveWhen(() => window.location.href === '#svg')
+ expect(global.window.location.href).toBe('#svg')
+ })
+
+ it('should support xlink:href', async () => {
+ svg.setAttribute('xlink:href', '#svg')
+ await analytics.trackLink(svg, 'foo')
+ const clickEvent = new Event('click')
+ svg.dispatchEvent(clickEvent)
+
+ await resolveWhen(() => window.location.href === '#svg')
+ expect(global.window.location.href).toBe('#svg')
+ })
+
+ it('should not load an href for a link with a blank target', async () => {
+ link.href = '/base/test/support/mock.html'
+ link.target = '_blank'
+ await analytics.trackLink(link, 'foo')
+ link.click()
+
+ await sleep(300)
+ expect(global.window.location.href).not.toBe('#test')
+ })
+})
diff --git a/packages/browser/src/core/analytics/__tests__/integration.test.ts b/packages/browser/src/core/analytics/__tests__/analytics.test.ts
similarity index 100%
rename from packages/browser/src/core/analytics/__tests__/integration.test.ts
rename to packages/browser/src/core/analytics/__tests__/analytics.test.ts
diff --git a/packages/browser/src/core/analytics/__tests__/null-analytics.test.ts b/packages/browser/src/core/analytics/__tests__/null-analytics.test.ts
new file mode 100644
index 000000000..b2af82578
--- /dev/null
+++ b/packages/browser/src/core/analytics/__tests__/null-analytics.test.ts
@@ -0,0 +1,37 @@
+import { getAjsBrowserStorage } from '../../../test-helpers/browser-storage'
+import { Analytics, NullAnalytics } from '..'
+
+describe(NullAnalytics, () => {
+ it('should return an instance of Analytics / NullAnalytics', () => {
+ const analytics = new NullAnalytics()
+ expect(analytics).toBeInstanceOf(Analytics)
+ expect(analytics).toBeInstanceOf(NullAnalytics)
+ })
+
+ it('should have initialized set to true', () => {
+ const analytics = new NullAnalytics()
+ expect(analytics.initialized).toBe(true)
+ })
+
+ it('should have no plugins', async () => {
+ const analytics = new NullAnalytics()
+ expect(analytics.queue.plugins).toHaveLength(0)
+ })
+ it('should dispatch events', async () => {
+ const analytics = new NullAnalytics()
+ const ctx = await analytics.track('foo')
+ expect(ctx.event.event).toBe('foo')
+ })
+
+ it('should have disableClientPersistence set to true', () => {
+ const analytics = new NullAnalytics()
+ expect(analytics.options.disableClientPersistence).toBe(true)
+ })
+
+ it('integration: should not touch cookies or localStorage', async () => {
+ const analytics = new NullAnalytics()
+ await analytics.track('foo')
+ const storage = getAjsBrowserStorage()
+ expect(Object.values(storage).every((v) => !v)).toBe(true)
+ })
+})
diff --git a/packages/browser/src/core/analytics/index.ts b/packages/browser/src/core/analytics/index.ts
index fd3853d9f..78570a3ab 100644
--- a/packages/browser/src/core/analytics/index.ts
+++ b/packages/browser/src/core/analytics/index.ts
@@ -13,7 +13,8 @@ import {
import type { FormArgs, LinkArgs } from '../auto-track'
import { isOffline } from '../connection'
import { Context } from '../context'
-import { dispatch, Emitter } from '@segment/analytics-core'
+import { dispatch } from '@segment/analytics-core'
+import { Emitter } from '@segment/analytics-generic-utils'
import {
Callback,
EventFactory,
@@ -54,6 +55,7 @@ import {
} from '../storage'
import { PluginFactory } from '../../plugins/remote-loader'
import { setGlobalAnalytics } from '../../lib/global-analytics-helper'
+import { popPageContext } from '../buffer'
const deprecationWarning =
'This is being deprecated and will be not be available in future releases of Analytics JS'
@@ -67,7 +69,7 @@ function createDefaultQueue(
retryQueue = false,
disablePersistance = false
) {
- const maxAttempts = retryQueue ? 4 : 1
+ const maxAttempts = retryQueue ? 10 : 1
const priorityQueue = disablePersistance
? new PriorityQueue(maxAttempts, [])
: new PersistedPriorityQueue(maxAttempts, name)
@@ -128,6 +130,24 @@ export interface InitOptions {
* default: analytics
*/
globalAnalyticsKey?: string
+
+ /**
+ * Disable sending any data to Segment's servers. All emitted events and API calls (including .ready()), will be no-ops, and no cookies or localstorage will be used.
+ *
+ * @example
+ * ### Basic (Will not not fetch any CDN settings)
+ * ```ts
+ * disable: process.env.NODE_ENV === 'test'
+ * ```
+ *
+ * ### Advanced (Fetches CDN Settings. Do not use this unless you require CDN settings for some reason)
+ * ```ts
+ * disable: (cdnSettings) => cdnSettings.foo === 'bar'
+ * ```
+ */
+ disable?:
+ | boolean
+ | ((cdnSettings: LegacySettings) => boolean | Promise)
}
/* analytics-classic stubs */
@@ -203,7 +223,6 @@ export class Analytics
this.eventFactory = new EventFactory(this._user)
this.integrations = options?.integrations ?? {}
this.options = options ?? {}
-
autoBind(this)
}
@@ -253,13 +272,15 @@ export class Analytics
}
async track(...args: EventParams): Promise {
+ const pageCtx = popPageContext(args)
const [name, data, opts, cb] = resolveArguments(...args)
const segmentEvent = this.eventFactory.track(
name,
data as EventProperties,
opts,
- this.integrations
+ this.integrations,
+ pageCtx
)
return this._dispatch(segmentEvent, cb).then((ctx) => {
@@ -269,6 +290,7 @@ export class Analytics
}
async page(...args: PageParams): Promise {
+ const pageCtx = popPageContext(args)
const [category, page, properties, options, callback] =
resolvePageArguments(...args)
@@ -277,7 +299,8 @@ export class Analytics
page,
properties,
options,
- this.integrations
+ this.integrations,
+ pageCtx
)
return this._dispatch(segmentEvent, callback).then((ctx) => {
@@ -287,6 +310,7 @@ export class Analytics
}
async identify(...args: IdentifyParams): Promise {
+ const pageCtx = popPageContext(args)
const [id, _traits, options, callback] = resolveUserArguments(this._user)(
...args
)
@@ -296,7 +320,8 @@ export class Analytics
this._user.id(),
this._user.traits(),
options,
- this.integrations
+ this.integrations,
+ pageCtx
)
return this._dispatch(segmentEvent, callback).then((ctx) => {
@@ -313,6 +338,7 @@ export class Analytics
group(): Group
group(...args: GroupParams): Promise
group(...args: GroupParams): Promise | Group {
+ const pageCtx = popPageContext(args)
if (args.length === 0) {
return this._group
}
@@ -329,7 +355,8 @@ export class Analytics
groupId,
groupTraits,
options,
- this.integrations
+ this.integrations,
+ pageCtx
)
return this._dispatch(segmentEvent, callback).then((ctx) => {
@@ -339,12 +366,14 @@ export class Analytics
}
async alias(...args: AliasParams): Promise {
+ const pageCtx = popPageContext(args)
const [to, from, options, callback] = resolveAliasArguments(...args)
const segmentEvent = this.eventFactory.alias(
to,
from,
options,
- this.integrations
+ this.integrations,
+ pageCtx
)
return this._dispatch(segmentEvent, callback).then((ctx) => {
this.emit('alias', to, from, ctx.event.options)
@@ -353,6 +382,7 @@ export class Analytics
}
async screen(...args: PageParams): Promise {
+ const pageCtx = popPageContext(args)
const [category, page, properties, options, callback] =
resolvePageArguments(...args)
@@ -361,7 +391,8 @@ export class Analytics
page,
properties,
options,
- this.integrations
+ this.integrations,
+ pageCtx
)
return this._dispatch(segmentEvent, callback).then((ctx) => {
this.emit(
@@ -640,3 +671,13 @@ export class Analytics
an[method].apply(this, args)
}
}
+
+/**
+ * @returns a no-op analytics instance that does not create cookies or localstorage, or send any events to segment.
+ */
+export class NullAnalytics extends Analytics {
+ constructor() {
+ super({ writeKey: '' }, { disableClientPersistence: true })
+ this.initialized = true
+ }
+}
diff --git a/packages/browser/src/core/arguments-resolver/__tests__/index.test.ts b/packages/browser/src/core/arguments-resolver/__tests__/index.test.ts
index 7c56255d3..2bc6f6572 100644
--- a/packages/browser/src/core/arguments-resolver/__tests__/index.test.ts
+++ b/packages/browser/src/core/arguments-resolver/__tests__/index.test.ts
@@ -429,6 +429,31 @@ describe(resolveUserArguments, () => {
expect(traits).toEqual(userTraits)
expect(options).toEqual({})
})
+
+ it('should accept (undefined, traits)', () => {
+ user.reset()
+ const [id, traits, options] = resolver(undefined, userTraits)
+ expect(traits).toEqual(userTraits)
+ expect(options).toEqual({})
+ expect(id).toEqual(null)
+ })
+
+ it('should accept (null, traits) with unknown identity', () => {
+ user.reset()
+ const [id, traits, options] = resolver(null, userTraits)
+ expect(traits).toEqual(userTraits)
+ expect(options).toEqual({})
+ expect(id).toEqual(null)
+ })
+
+ it('should accept (null, traits) when identity is set', () => {
+ user.reset()
+ user.identify('something')
+ const [id, traits, options] = resolver(null, userTraits)
+ expect(traits).toEqual(userTraits)
+ expect(options).toEqual({})
+ expect(id).toEqual('something')
+ })
})
describe(resolveAliasArguments, () => {
diff --git a/packages/browser/src/core/arguments-resolver/index.ts b/packages/browser/src/core/arguments-resolver/index.ts
index 2274d6926..795381e9e 100644
--- a/packages/browser/src/core/arguments-resolver/index.ts
+++ b/packages/browser/src/core/arguments-resolver/index.ts
@@ -111,22 +111,56 @@ export const resolveUserArguments = (
user: U
): ResolveUser => {
return (...args): ReturnType> => {
- let id: string | ID | null = null
- id = args.find(isString) ?? args.find(isNumber)?.toString() ?? user.id()
-
- const objects = args.filter((obj) => {
- if (id === null) {
- return isPlainObject(obj)
+ const values: {
+ id?: ID
+ traits?: T | null
+ options?: Options
+ callback?: Callback
+ } = {}
+ // It's a stack so it's reversed so that we go through each of the expected arguments
+ const orderStack: Array = [
+ 'callback',
+ 'options',
+ 'traits',
+ 'id',
+ ]
+
+ // Read each argument and eval the possible values here
+ for (const arg of args) {
+ let current = orderStack.pop()
+ if (current === 'id') {
+ if (isString(arg) || isNumber(arg)) {
+ values.id = arg.toString()
+ continue
+ }
+ if (arg === null || arg === undefined) {
+ continue
+ }
+ // First argument should always be the id, if it is not a valid value we can skip it
+ current = orderStack.pop()
}
- return isPlainObject(obj) || obj === null
- }) as Array
- const traits = (objects[0] ?? {}) as T
- const opts = (objects[1] ?? {}) as Options
+ // Traits and Options
+ if (
+ (current === 'traits' || current === 'options') &&
+ (arg === null || arg === undefined || isPlainObject(arg))
+ ) {
+ values[current] = arg as T
+ }
- const resolvedCallback = args.find(isFunction) as Callback | undefined
+ // Callback
+ if (isFunction(arg)) {
+ values.callback = arg as Callback
+ break // This is always the last argument
+ }
+ }
- return [id, traits, opts, resolvedCallback]
+ return [
+ values.id ?? user.id(),
+ (values.traits ?? {}) as T,
+ values.options ?? {},
+ values.callback,
+ ]
}
}
diff --git a/packages/browser/src/core/buffer/__tests__/index.test.ts b/packages/browser/src/core/buffer/__tests__/index.test.ts
index 071a4b6c1..5ff1c0dbc 100644
--- a/packages/browser/src/core/buffer/__tests__/index.test.ts
+++ b/packages/browser/src/core/buffer/__tests__/index.test.ts
@@ -4,65 +4,146 @@ import {
PreInitMethodCall,
flushAnalyticsCallsInNewTask,
PreInitMethodCallBuffer,
+ PreInitMethodName,
} from '..'
import { Analytics } from '../../analytics'
import { Context } from '../../context'
import { sleep } from '../../../lib/sleep'
import { User } from '../../user'
+import { getBufferedPageCtxFixture } from '../../../test-helpers/fixtures'
+import * as GlobalAnalytics from '../../../lib/global-analytics-helper'
-describe('PreInitMethodCallBuffer', () => {
- describe('push', () => {
- it('should return this', async () => {
- const buffer = new PreInitMethodCallBuffer()
- const result = buffer.push({} as any)
- expect(result).toBeInstanceOf(PreInitMethodCallBuffer)
+describe(PreInitMethodCallBuffer, () => {
+ beforeEach(() => {
+ GlobalAnalytics.setGlobalAnalytics(undefined as any)
+ })
+
+ describe('toArray()', () => {
+ it('should convert the map back to an array', () => {
+ const call1 = new PreInitMethodCall('identify', [], jest.fn())
+ const call2 = new PreInitMethodCall('identify', [], jest.fn())
+ const call3 = new PreInitMethodCall('group', [], jest.fn())
+ const buffer = new PreInitMethodCallBuffer(call1, call2, call3)
+ expect(buffer.toArray()).toEqual([call1, call2, call3])
+ })
+
+ it('should also read from global analytics buffer', () => {
+ const call1 = new PreInitMethodCall('identify', ['foo'], jest.fn())
+ ;(window as any).analytics = [['track', 'snippet']]
+
+ const buffer = new PreInitMethodCallBuffer(call1)
+ const calls = buffer.toArray()
+ expect(calls.length).toBe(2)
+ expect(calls[0]).toEqual(
+ expect.objectContaining>({
+ method: 'track',
+ args: ['snippet', getBufferedPageCtxFixture()],
+ })
+ )
+ expect(calls[1]).toEqual(call1)
})
})
- describe('toArray should return an array', () => {
- it('toArray() should convert the map back to an array', async () => {
+
+ describe('push()', () => {
+ it('should add method calls', () => {
+ const call1 = new PreInitMethodCall('identify', [], jest.fn())
const buffer = new PreInitMethodCallBuffer()
- const method1 = { method: 'foo' } as any
- const method2 = { method: 'foo', args: [1] } as any
- const method3 = { method: 'bar' } as any
- buffer.push(method1, method2, method3)
- expect(buffer.toArray()).toEqual([method1, method2, method3])
+ buffer.push(call1)
+ expect(buffer.toArray()).toEqual([call1])
+ })
+
+ it('should work if the calls were added at different times or in different ways', () => {
+ const call1 = new PreInitMethodCall('identify', [], jest.fn())
+ const call2 = new PreInitMethodCall('identify', [], jest.fn())
+ const call3 = new PreInitMethodCall('group', [], jest.fn())
+ const call4 = new PreInitMethodCall('group', [], jest.fn())
+ const buffer = new PreInitMethodCallBuffer(call1)
+ buffer.push(call2, call3)
+ buffer.push(call4)
+ expect(buffer.toArray()).toEqual([call1, call2, call3, call4])
})
})
- describe('clear', () => {
- it('should return this', async () => {
+ describe('getCalls()', () => {
+ it('should fetch calls by name', async () => {
const buffer = new PreInitMethodCallBuffer()
- const result = buffer.push({} as any)
- expect(result).toBeInstanceOf(PreInitMethodCallBuffer)
+ const call1 = new PreInitMethodCall('identify', [], jest.fn())
+ const call2 = new PreInitMethodCall('identify', [], jest.fn())
+ const call3 = new PreInitMethodCall('group', [], jest.fn())
+ buffer.push(call1, call2, call3)
+ expect(buffer.getCalls('identify')).toEqual([call1, call2])
+ expect(buffer.getCalls('group')).toEqual([call3])
+ })
+ it('should read from Snippet Buffer', () => {
+ const call1 = new PreInitMethodCall('identify', ['foo'], jest.fn())
+ GlobalAnalytics.setGlobalAnalytics([['identify', 'snippet']] as any)
+
+ const buffer = new PreInitMethodCallBuffer(call1)
+ const calls = buffer.getCalls('identify')
+ expect(calls.length).toBe(2)
+ expect(calls[0]).toEqual(
+ expect.objectContaining>({
+ method: 'identify',
+ args: ['snippet', getBufferedPageCtxFixture()],
+ })
+ )
+ expect(calls[1]).toEqual(call1)
+ })
+ })
+ describe('clear()', () => {
+ it('should clear calls', () => {
+ const call1 = new PreInitMethodCall('identify', [], jest.fn())
+ const call2 = new PreInitMethodCall('identify', [], jest.fn())
+ const call3 = new PreInitMethodCall('group', [], jest.fn())
+ GlobalAnalytics.setGlobalAnalytics([['track', 'bar']] as any)
+ const buffer = new PreInitMethodCallBuffer(call1, call2, call3)
+ buffer.clear()
+ expect(buffer.toArray()).toEqual([])
+ expect(GlobalAnalytics.getGlobalAnalytics()).toEqual([])
})
})
- describe('getCalls', () => {
- it('should return calls', async () => {
- const buffer = new PreInitMethodCallBuffer()
-
- const fooCall1 = {
- method: 'foo',
- args: ['bar'],
- } as any
-
- const barCall = {
- method: 'bar',
- args: ['foobar'],
- } as any
- const fooCall2 = {
- method: 'foo',
- args: ['baz'],
- } as any
+ describe('Snippet buffer (method calls)', () => {
+ it('should be read from the global analytics instance', () => {
+ const getGlobalAnalyticsSpy = jest.spyOn(
+ GlobalAnalytics,
+ 'getGlobalAnalytics'
+ )
- const calls: PreInitMethodCall[] = [fooCall1, fooCall2, barCall]
- const result = buffer.push(...calls)
- expect(result.getCalls('foo' as any)).toEqual([fooCall1, fooCall2])
+ const buffer = new PreInitMethodCallBuffer()
+ expect(getGlobalAnalyticsSpy).not.toBeCalled()
+ buffer.toArray()
+ expect(getGlobalAnalyticsSpy).toBeCalled()
+ })
+ })
+ describe('BufferedPageContext', () => {
+ test.each([
+ 'track',
+ 'screen',
+ 'alias',
+ 'group',
+ 'page',
+ 'identify',
+ ] as PreInitMethodName[])('should be appended to %p calls.', (method) => {
+ const buffer = new PreInitMethodCallBuffer(
+ new PreInitMethodCall(method, ['foo'], jest.fn())
+ )
+ expect(buffer.getCalls(method)[0].args).toEqual([
+ 'foo',
+ getBufferedPageCtxFixture(),
+ ])
+ })
+ it('should not be appended for other method calls', () => {
+ const fn = jest.fn()
+ const onCall = new PreInitMethodCall('on', ['foo', fn])
+ expect(onCall.args).toEqual(['foo', fn])
+ const setAnonIdCall = new PreInitMethodCall('setAnonymousId', [])
+ expect(setAnonIdCall.args).toEqual([])
})
})
})
-describe('AnalyticsBuffered', () => {
+describe(AnalyticsBuffered, () => {
describe('Happy path', () => {
it('should return a promise-like object', async () => {
const ajs = new Analytics({ writeKey: 'foo' })
@@ -220,7 +301,7 @@ describe('AnalyticsBuffered', () => {
})
})
-describe('callAnalyticsMethod', () => {
+describe(callAnalyticsMethod, () => {
let ajs!: Analytics
let resolveSpy!: jest.Mock
let rejectSpy!: jest.Mock
@@ -228,14 +309,12 @@ describe('callAnalyticsMethod', () => {
beforeEach(() => {
resolveSpy = jest.fn().mockImplementation((el) => `resolved: ${el}`)
rejectSpy = jest.fn().mockImplementation((el) => `rejected: ${el}`)
- methodCall = {
- args: ['foo', {}],
- called: false,
- method: 'track',
- resolve: resolveSpy,
- reject: rejectSpy,
- } as PreInitMethodCall
-
+ methodCall = new PreInitMethodCall(
+ 'track',
+ ['foo', {}],
+ resolveSpy,
+ rejectSpy
+ )
ajs = new Analytics({
writeKey: 'abc',
})
@@ -297,7 +376,7 @@ describe('callAnalyticsMethod', () => {
})
})
-describe('flushAnalyticsCallsInNewTask', () => {
+describe(flushAnalyticsCallsInNewTask, () => {
test('should defer buffered method calls, regardless of whether or not they are async', async () => {
// @ts-ignore
Analytics.prototype['synchronousMethod'] = () => 123
@@ -305,27 +384,21 @@ describe('flushAnalyticsCallsInNewTask', () => {
// @ts-ignore
Analytics.prototype['asyncMethod'] = () => Promise.resolve(123)
- const synchronousMethod = {
- method: 'synchronousMethod' as any,
- args: ['foo'],
- called: false,
- resolve: jest.fn(),
- reject: jest.fn(),
- } as PreInitMethodCall
-
- const asyncMethod = {
- method: 'asyncMethod' as any,
- args: ['foo'],
- called: false,
- resolve: jest.fn(),
- reject: jest.fn(),
- } as PreInitMethodCall
-
- const buffer = new PreInitMethodCallBuffer().push(
- synchronousMethod,
- asyncMethod
+ const synchronousMethod = new PreInitMethodCall(
+ 'synchronousMethod' as any,
+ ['foo'],
+ jest.fn(),
+ jest.fn()
+ )
+
+ const asyncMethod = new PreInitMethodCall(
+ 'asyncMethod' as any,
+ ['foo'],
+ jest.fn(),
+ jest.fn()
)
+ const buffer = new PreInitMethodCallBuffer(synchronousMethod, asyncMethod)
flushAnalyticsCallsInNewTask(new Analytics({ writeKey: 'abc' }), buffer)
expect(synchronousMethod.resolve).not.toBeCalled()
expect(asyncMethod.resolve).not.toBeCalled()
@@ -338,15 +411,14 @@ describe('flushAnalyticsCallsInNewTask', () => {
// @ts-ignore
Analytics.prototype['asyncMethod'] = () => Promise.reject('oops!')
- const asyncMethod = {
- method: 'asyncMethod' as any,
- args: ['foo'],
- called: false,
- resolve: jest.fn(),
- reject: jest.fn(),
- } as PreInitMethodCall
+ const asyncMethod = new PreInitMethodCall(
+ 'asyncMethod' as any,
+ ['foo'],
+ jest.fn(),
+ jest.fn()
+ )
- const buffer = new PreInitMethodCallBuffer().push(asyncMethod)
+ const buffer = new PreInitMethodCallBuffer(asyncMethod)
flushAnalyticsCallsInNewTask(new Analytics({ writeKey: 'abc' }), buffer)
await sleep(0)
expect(asyncMethod.reject).toBeCalledWith('oops!')
@@ -361,29 +433,23 @@ describe('flushAnalyticsCallsInNewTask', () => {
throw new Error('Ooops!')
}
- const synchronousMethod = {
- method: 'synchronousMethod' as any,
- args: ['foo'],
- called: false,
- resolve: jest.fn(),
- reject: jest.fn(),
- } as PreInitMethodCall
-
- const asyncMethod = {
- method: 'asyncMethod' as any,
- args: ['foo'],
- called: false,
- resolve: jest.fn(),
- reject: jest.fn(),
- } as PreInitMethodCall
-
- const buffer = new PreInitMethodCallBuffer().push(
- synchronousMethod,
- asyncMethod
+ const synchronousMethod = new PreInitMethodCall(
+ 'synchronousMethod' as any,
+ ['foo'],
+ jest.fn(),
+ jest.fn()
+ )
+ const asyncMethod = new PreInitMethodCall(
+ 'asyncMethod' as any,
+ ['foo'],
+ jest.fn(),
+ jest.fn()
)
+
+ const buffer = new PreInitMethodCallBuffer(synchronousMethod, asyncMethod)
flushAnalyticsCallsInNewTask(new Analytics({ writeKey: 'abc' }), buffer)
await sleep(0)
- expect(synchronousMethod.reject).toBeCalled()
- expect(asyncMethod.resolve).toBeCalled()
+ expect(synchronousMethod.reject).toBeCalledTimes(1)
+ expect(asyncMethod.resolve).toBeCalledTimes(1)
})
})
diff --git a/packages/browser/src/core/buffer/index.ts b/packages/browser/src/core/buffer/index.ts
index d7b42edb0..e5604cad1 100644
--- a/packages/browser/src/core/buffer/index.ts
+++ b/packages/browser/src/core/buffer/index.ts
@@ -3,6 +3,14 @@ import { Context } from '../context'
import { isThenable } from '../../lib/is-thenable'
import { AnalyticsBrowserCore } from '../analytics/interfaces'
import { version } from '../../generated/version'
+import { getGlobalAnalytics } from '../../lib/global-analytics-helper'
+import {
+ isBufferedPageContext,
+ BufferedPageContext,
+ getDefaultBufferedPageContext,
+ createPageContext,
+ PageContext,
+} from '../page'
/**
* The names of any AnalyticsBrowser methods that also exist on Analytics
@@ -80,10 +88,24 @@ export const flushAnalyticsCallsInNewTask = (
})
}
+export const popPageContext = (args: unknown[]): PageContext | undefined => {
+ if (hasBufferedPageContextAsLastArg(args)) {
+ const ctx = args.pop() as BufferedPageContext
+ return createPageContext(ctx)
+ }
+}
+
+export const hasBufferedPageContextAsLastArg = (
+ args: unknown[]
+): args is [...unknown[], BufferedPageContext] | [BufferedPageContext] => {
+ const lastArg = args[args.length - 1]
+ return isBufferedPageContext(lastArg)
+}
+
/**
* Represents a buffered method call that occurred before initialization.
*/
-export interface PreInitMethodCall<
+export class PreInitMethodCall<
MethodName extends PreInitMethodName = PreInitMethodName
> {
method: MethodName
@@ -91,10 +113,23 @@ export interface PreInitMethodCall<
called: boolean
resolve: (v: ReturnType) => void
reject: (reason: any) => void
+ constructor(
+ method: PreInitMethodCall['method'],
+ args: PreInitMethodParams,
+ resolve: PreInitMethodCall['resolve'] = () => {},
+ reject: PreInitMethodCall['reject'] = console.error
+ ) {
+ this.method = method
+ this.resolve = resolve
+ this.reject = reject
+ this.called = false
+ this.args = args
+ }
}
export type PreInitMethodParams =
- Parameters
+ | [...Parameters, BufferedPageContext]
+ | Parameters
/**
* Infer return type; if return type is promise, unwrap it.
@@ -107,34 +142,91 @@ type ReturnTypeUnwrap = Fn extends (...args: any[]) => infer ReturnT
type MethodCallMap = Partial>
+type SnippetWindowBufferedMethodCall<
+ MethodName extends PreInitMethodName = PreInitMethodName
+> = [MethodName, ...PreInitMethodParams]
+
+/**
+ * A list of the method calls before initialization for snippet users
+ * For example, [["track", "foo", {bar: 123}], ["page"], ["on", "ready", function(){..}]
+ */
+type SnippetBuffer = SnippetWindowBufferedMethodCall[]
+
/**
* Represents any and all the buffered method calls that occurred before initialization.
*/
export class PreInitMethodCallBuffer {
- private _value = {} as MethodCallMap
+ private _callMap: MethodCallMap = {}
- toArray(): PreInitMethodCall[] {
- return ([] as PreInitMethodCall[]).concat(...Object.values(this._value))
+ constructor(...calls: PreInitMethodCall[]) {
+ this.push(...calls)
+ }
+
+ /**
+ * Pull any buffered method calls from the window object, and use them to hydrate the instance buffer.
+ */
+ private get calls() {
+ this._pushSnippetWindowBuffer()
+ return this._callMap
+ }
+
+ private set calls(calls: MethodCallMap) {
+ this._callMap = calls
}
getCalls(methodName: T): PreInitMethodCall[] {
- return (this._value[methodName] ?? []) as PreInitMethodCall[]
+ return (this.calls[methodName] ?? []) as PreInitMethodCall[]
}
- push(...calls: PreInitMethodCall[]): PreInitMethodCallBuffer {
+ push(...calls: PreInitMethodCall[]): void {
calls.forEach((call) => {
- if (this._value[call.method]) {
- this._value[call.method]!.push(call)
+ const eventsExpectingPageContext: PreInitMethodName[] = [
+ 'track',
+ 'screen',
+ 'alias',
+ 'group',
+ 'page',
+ 'identify',
+ ]
+ if (
+ eventsExpectingPageContext.includes(call.method) &&
+ !hasBufferedPageContextAsLastArg(call.args)
+ ) {
+ call.args = [...call.args, getDefaultBufferedPageContext()]
+ }
+
+ if (this.calls[call.method]) {
+ this.calls[call.method]!.push(call)
} else {
- this._value[call.method] = [call]
+ this.calls[call.method] = [call]
}
})
- return this
}
- clear(): PreInitMethodCallBuffer {
- this._value = {} as MethodCallMap
- return this
+ clear(): void {
+ // clear calls in the global snippet buffered array.
+ this._pushSnippetWindowBuffer()
+ // clear calls in this instance
+ this.calls = {}
+ }
+
+ toArray(): PreInitMethodCall[] {
+ return ([] as PreInitMethodCall[]).concat(...Object.values(this.calls))
+ }
+
+ /**
+ * Fetch the buffered method calls from the window object,
+ * normalize them, and use them to hydrate the buffer.
+ * This removes existing buffered calls from the window object.
+ */
+ private _pushSnippetWindowBuffer(): void {
+ const wa = getGlobalAnalytics()
+ if (!Array.isArray(wa)) return undefined
+ const buffered: SnippetBuffer = wa.splice(0, wa.length)
+ const calls = buffered.map(
+ ([methodName, ...args]) => new PreInitMethodCall(methodName, args)
+ )
+ this.push(...calls)
}
}
@@ -176,9 +268,10 @@ export class AnalyticsBuffered
{
instance?: Analytics
ctx?: Context
- private _preInitBuffer = new PreInitMethodCallBuffer()
+ private _preInitBuffer: PreInitMethodCallBuffer
private _promise: Promise<[Analytics, Context]>
constructor(loader: AnalyticsLoader) {
+ this._preInitBuffer = new PreInitMethodCallBuffer()
this._promise = loader(this._preInitBuffer)
this._promise
.then(([ajs, ctx]) => {
@@ -251,15 +344,10 @@ export class AnalyticsBuffered
const result = (this.instance[methodName] as Function)(...args)
return Promise.resolve(result)
}
-
return new Promise((resolve, reject) => {
- this._preInitBuffer.push({
- method: methodName,
- args,
- resolve: resolve,
- reject: reject,
- called: false,
- } as PreInitMethodCall)
+ this._preInitBuffer.push(
+ new PreInitMethodCall(methodName, args, resolve as any, reject)
+ )
})
}
}
@@ -274,13 +362,7 @@ export class AnalyticsBuffered
void (this.instance[methodName] as Function)(...args)
return this
} else {
- this._preInitBuffer.push({
- method: methodName,
- args,
- resolve: () => {},
- reject: console.error,
- called: false,
- } as PreInitMethodCall)
+ this._preInitBuffer.push(new PreInitMethodCall(methodName, args))
}
return this
diff --git a/packages/browser/src/core/buffer/snippet.ts b/packages/browser/src/core/buffer/snippet.ts
deleted file mode 100644
index 73612b3f6..000000000
--- a/packages/browser/src/core/buffer/snippet.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import type {
- PreInitMethodCall,
- PreInitMethodName,
- PreInitMethodParams,
-} from '.'
-import { getGlobalAnalytics } from '../../lib/global-analytics-helper'
-
-export function transformSnippetCall([
- methodName,
- ...args
-]: SnippetWindowBufferedMethodCall): PreInitMethodCall {
- return {
- method: methodName,
- resolve: () => {},
- reject: console.error,
- args,
- called: false,
- }
-}
-
-const normalizeSnippetBuffer = (buffer: SnippetBuffer): PreInitMethodCall[] => {
- return buffer.map(transformSnippetCall)
-}
-
-type SnippetWindowBufferedMethodCall<
- MethodName extends PreInitMethodName = PreInitMethodName
-> = [MethodName, ...PreInitMethodParams]
-
-/**
- * A list of the method calls before initialization for snippet users
- * For example, [["track", "foo", {bar: 123}], ["page"], ["on", "ready", function(){..}]
- */
-export type SnippetBuffer = SnippetWindowBufferedMethodCall[]
-
-/**
- * Fetch the buffered method calls from the window object and normalize them.
- * This removes existing buffered calls from the window object.
- */
-export const popSnippetWindowBuffer = (
- buffer: unknown = getGlobalAnalytics()
-): PreInitMethodCall[] => {
- const wa = buffer
- if (!Array.isArray(wa)) return []
- const buffered = wa.splice(0, wa.length)
- return normalizeSnippetBuffer(buffered)
-}
diff --git a/packages/browser/src/core/events/__tests__/index.test.ts b/packages/browser/src/core/events/__tests__/index.test.ts
index 1b939011f..d9b53250a 100644
--- a/packages/browser/src/core/events/__tests__/index.test.ts
+++ b/packages/browser/src/core/events/__tests__/index.test.ts
@@ -1,6 +1,7 @@
import uuid from '@lukeed/uuid'
import { range, uniq } from 'lodash'
import { EventFactory } from '..'
+import { getDefaultPageContext } from '../../page'
import { User } from '../../user'
import { SegmentEvent, Options } from '../interfaces'
@@ -11,10 +12,15 @@ describe('Event Factory', () => {
const shoes = { product: 'shoes', total: '$35', category: 'category' }
const shopper = { totalSpent: 100 }
+ const defaultContext = {
+ page: getDefaultPageContext(),
+ }
+
beforeEach(() => {
user = new User()
user.reset()
factory = new EventFactory(user)
+ defaultContext.page = getDefaultPageContext()
})
describe('alias', () => {
@@ -67,7 +73,7 @@ describe('Event Factory', () => {
it('accepts properties', () => {
const page = factory.page('category', 'name', shoes)
- expect(page.properties).toEqual(shoes)
+ expect(page.properties).toEqual(expect.objectContaining(shoes))
})
it('ignores category and page if not passed in', () => {
@@ -153,7 +159,7 @@ describe('Event Factory', () => {
const track = factory.track('Order Completed', shoes, {
opt1: true,
})
- expect(track.context).toEqual({ opt1: true })
+ expect(track.context).toEqual({ ...defaultContext, opt1: true })
})
test('sets context correctly if property arg is undefined', () => {
@@ -161,7 +167,10 @@ describe('Event Factory', () => {
context: { page: { path: '/custom' } },
})
- expect(track.context).toEqual({ page: { path: '/custom' } })
+ expect(track.context?.page).toEqual({
+ ...defaultContext.page,
+ path: '/custom',
+ })
})
test('sets integrations', () => {
@@ -243,7 +252,11 @@ describe('Event Factory', () => {
},
})
- expect(track.context).toEqual({ opt1: true, opt2: 'π₯' })
+ expect(track.context).toEqual({
+ ...defaultContext,
+ opt1: true,
+ opt2: 'π₯',
+ })
})
test('should not move known options into `context`', () => {
@@ -257,7 +270,11 @@ describe('Event Factory', () => {
timestamp: new Date(),
})
- expect(track.context).toEqual({ opt1: true, opt2: 'π₯' })
+ expect(track.context).toEqual({
+ ...defaultContext,
+ opt1: true,
+ opt2: 'π₯',
+ })
})
test('accepts an anonymous id', () => {
@@ -265,7 +282,7 @@ describe('Event Factory', () => {
anonymousId: 'anon-1',
})
- expect(track.context).toEqual({})
+ expect(track.context).toEqual(defaultContext)
expect(track.anonymousId).toEqual('anon-1')
})
@@ -275,7 +292,7 @@ describe('Event Factory', () => {
timestamp,
})
- expect(track.context).toEqual({})
+ expect(track.context).toEqual(defaultContext)
expect(track.timestamp).toEqual(timestamp)
})
@@ -302,6 +319,7 @@ describe('Event Factory', () => {
})
expect(track.context).toEqual({
+ ...defaultContext,
library: {
name: 'ajs-next',
version: '0.1.0',
@@ -322,6 +340,7 @@ describe('Event Factory', () => {
})
expect(track.context).toEqual({
+ ...defaultContext,
library: {
name: 'ajs-next',
version: '0.1.0',
@@ -395,9 +414,37 @@ describe('Event Factory', () => {
integrations: { Segment: true },
type: 'track',
userId: 'user-id',
- context: {},
+ context: defaultContext,
})
})
})
})
+
+ describe('Page context augmentation', () => {
+ // minimal tests -- more tests should be specifically around addPageContext
+ factory = new EventFactory(new User())
+ it('adds a default pageContext if pageContext is not defined', () => {
+ const event = factory.identify('foo')
+ expect(event.context?.page).toEqual(defaultContext.page)
+ })
+
+ const pageCtx = { ...defaultContext.page, title: 'foo test' }
+ test.each([
+ factory.identify('foo', undefined, undefined, undefined, {
+ ...pageCtx,
+ }),
+ factory.track('foo', undefined, undefined, undefined, {
+ ...pageCtx,
+ }),
+ factory.group('foo', undefined, undefined, undefined, {
+ ...pageCtx,
+ }),
+ factory.page('foo', 'bar', undefined, undefined, undefined, {
+ ...pageCtx,
+ }),
+ factory.alias('foo', 'bar', undefined, undefined, { ...pageCtx }),
+ ])(`$type: Event has the expected page properties`, async (event) => {
+ expect(event.context?.page).toEqual(pageCtx)
+ })
+ })
})
diff --git a/packages/browser/src/core/events/index.ts b/packages/browser/src/core/events/index.ts
index 8af4820f9..b213cc55b 100644
--- a/packages/browser/src/core/events/index.ts
+++ b/packages/browser/src/core/events/index.ts
@@ -9,30 +9,31 @@ import {
SegmentEvent,
} from './interfaces'
import md5 from 'spark-md5'
+import { addPageContext, PageContext } from '../page'
export * from './interfaces'
export class EventFactory {
- user: User
-
- constructor(user: User) {
- this.user = user
- }
+ constructor(public user: User) {}
track(
event: string,
properties?: EventProperties,
options?: Options,
- globalIntegrations?: Integrations
+ globalIntegrations?: Integrations,
+ pageCtx?: PageContext
): SegmentEvent {
- return this.normalize({
- ...this.baseEvent(),
- event,
- type: 'track' as const,
- properties,
- options: { ...options },
- integrations: { ...globalIntegrations },
- })
+ return this.normalize(
+ {
+ ...this.baseEvent(),
+ event,
+ type: 'track' as const,
+ properties,
+ options: { ...options },
+ integrations: { ...globalIntegrations },
+ },
+ pageCtx
+ )
}
page(
@@ -40,7 +41,8 @@ export class EventFactory {
page: string | null,
properties?: EventProperties,
options?: Options,
- globalIntegrations?: Integrations
+ globalIntegrations?: Integrations,
+ pageCtx?: PageContext
): SegmentEvent {
const event: Partial = {
type: 'page' as const,
@@ -59,10 +61,13 @@ export class EventFactory {
event.name = page
}
- return this.normalize({
- ...this.baseEvent(),
- ...event,
- } as SegmentEvent)
+ return this.normalize(
+ {
+ ...this.baseEvent(),
+ ...event,
+ } as SegmentEvent,
+ pageCtx
+ )
}
screen(
@@ -70,7 +75,8 @@ export class EventFactory {
screen: string | null,
properties?: EventProperties,
options?: Options,
- globalIntegrations?: Integrations
+ globalIntegrations?: Integrations,
+ pageCtx?: PageContext
): SegmentEvent {
const event: Partial = {
type: 'screen' as const,
@@ -86,50 +92,61 @@ export class EventFactory {
if (screen !== null) {
event.name = screen
}
-
- return this.normalize({
- ...this.baseEvent(),
- ...event,
- } as SegmentEvent)
+ return this.normalize(
+ {
+ ...this.baseEvent(),
+ ...event,
+ } as SegmentEvent,
+ pageCtx
+ )
}
identify(
userId: ID,
traits?: Traits,
options?: Options,
- globalIntegrations?: Integrations
+ globalIntegrations?: Integrations,
+ pageCtx?: PageContext
): SegmentEvent {
- return this.normalize({
- ...this.baseEvent(),
- type: 'identify' as const,
- userId,
- traits,
- options: { ...options },
- integrations: { ...globalIntegrations },
- })
+ return this.normalize(
+ {
+ ...this.baseEvent(),
+ type: 'identify' as const,
+ userId,
+ traits,
+ options: { ...options },
+ integrations: { ...globalIntegrations },
+ },
+ pageCtx
+ )
}
group(
groupId: ID,
traits?: Traits,
options?: Options,
- globalIntegrations?: Integrations
+ globalIntegrations?: Integrations,
+ pageCtx?: PageContext
): SegmentEvent {
- return this.normalize({
- ...this.baseEvent(),
- type: 'group' as const,
- traits,
- options: { ...options },
- integrations: { ...globalIntegrations },
- groupId,
- })
+ return this.normalize(
+ {
+ ...this.baseEvent(),
+ type: 'group' as const,
+ traits,
+ options: { ...options },
+ integrations: { ...globalIntegrations },
+ groupId,
+ },
+ pageCtx
+ )
}
alias(
to: string,
from: string | null,
options?: Options,
- globalIntegrations?: Integrations
+ globalIntegrations?: Integrations,
+ pageCtx?: PageContext
): SegmentEvent {
const base: Partial = {
userId: to,
@@ -149,10 +166,13 @@ export class EventFactory {
} as SegmentEvent)
}
- return this.normalize({
- ...this.baseEvent(),
- ...base,
- } as SegmentEvent)
+ return this.normalize(
+ {
+ ...this.baseEvent(),
+ ...base,
+ } as SegmentEvent,
+ pageCtx
+ )
}
private baseEvent(): Partial {
@@ -204,7 +224,7 @@ export class EventFactory {
return [context, overrides]
}
- public normalize(event: SegmentEvent): SegmentEvent {
+ public normalize(event: SegmentEvent, pageCtx?: PageContext): SegmentEvent {
// set anonymousId globally if we encounter an override
//segment.com/docs/connections/sources/catalog/libraries/website/javascript/identity/#override-the-anonymous-id-using-the-options-object
event.options?.anonymousId &&
@@ -235,21 +255,16 @@ export class EventFactory {
const [context, overrides] = this.context(event)
const { options, ...rest } = event
- const body = {
+ const newEvent: SegmentEvent = {
timestamp: new Date(),
...rest,
context,
integrations: allIntegrations,
...overrides,
+ messageId: 'ajs-next-' + md5.hash(JSON.stringify(event) + uuid()),
}
+ addPageContext(newEvent, pageCtx)
- const messageId = 'ajs-next-' + md5.hash(JSON.stringify(body) + uuid())
-
- const evt: SegmentEvent = {
- ...body,
- messageId,
- }
-
- return evt
+ return newEvent
}
}
diff --git a/packages/browser/src/core/page/__tests__/index.test.ts b/packages/browser/src/core/page/__tests__/index.test.ts
new file mode 100644
index 000000000..b1f166cff
--- /dev/null
+++ b/packages/browser/src/core/page/__tests__/index.test.ts
@@ -0,0 +1,130 @@
+import {
+ BufferedPageContextDiscriminant,
+ getDefaultBufferedPageContext,
+ getDefaultPageContext,
+ isBufferedPageContext,
+} from '../'
+import { pickBy } from 'lodash'
+
+const originalLocation = window.location
+beforeEach(() => {
+ Object.defineProperty(window, 'location', {
+ value: {
+ ...originalLocation,
+ },
+ writable: true,
+ })
+})
+
+describe(isBufferedPageContext, () => {
+ it('should return true if object is page context', () => {
+ expect(isBufferedPageContext(getDefaultBufferedPageContext())).toBe(true)
+ })
+ it('should return false if object is not page context', () => {
+ expect(isBufferedPageContext(undefined)).toBe(false)
+ expect(isBufferedPageContext({})).toBe(false)
+ expect(isBufferedPageContext('')).toBe(false)
+ expect(isBufferedPageContext({ foo: false })).toBe(false)
+ expect(isBufferedPageContext({ u: 'hello' })).toBe(false)
+ expect(isBufferedPageContext(null)).toBe(false)
+
+ expect(
+ isBufferedPageContext({
+ ...getDefaultBufferedPageContext(),
+ some_unknown_key: 123,
+ })
+ ).toBe(false)
+
+ const missingDiscriminant = pickBy(
+ getDefaultBufferedPageContext(),
+ (v) => v !== BufferedPageContextDiscriminant
+ )
+ // should not be missing the dscriminant
+ expect(isBufferedPageContext(missingDiscriminant)).toBe(false)
+ })
+})
+
+describe(getDefaultPageContext, () => {
+ describe('hash', () => {
+ it('strips the hash from the URL', () => {
+ window.location.href = 'http://www.segment.local#test'
+ const defs = getDefaultPageContext()
+ expect(defs.url).toBe('http://www.segment.local')
+
+ window.location.href = 'http://www.segment.local/#test'
+ const defs2 = getDefaultPageContext()
+ expect(defs2.url).toBe('http://www.segment.local/')
+ })
+ })
+
+ describe('canonical URL', () => {
+ const el = document.createElement('link')
+ beforeEach(() => {
+ el.setAttribute('rel', 'canonical')
+ el.setAttribute('href', '')
+ document.clear()
+ })
+
+ it('returns location.href if canonical URL does not exist', () => {
+ el.setAttribute('rel', 'nope')
+ document.body.appendChild(el)
+ const defs = getDefaultPageContext()
+ expect(defs.url).toEqual(window.location.href)
+ })
+
+ it('does not throw an error if canonical URL is not a valid URL', () => {
+ el.setAttribute('href', 'foo.com/bar')
+ document.body.appendChild(el)
+ const defs = getDefaultPageContext()
+ expect(defs.url).toEqual('foo.com/bar') // this is analytics.js classic behavior
+ expect(defs.path).toEqual('/foo.com/bar') // this is analytics.js classic behavior
+ })
+
+ it('handles a leading slash', () => {
+ el.setAttribute('href', 'foo')
+ document.body.appendChild(el)
+ const defs = getDefaultPageContext()
+ expect(defs.url).toEqual('foo')
+ expect(defs.path).toEqual('/foo') // this is analytics.js classic behavior
+ })
+
+ it('handles canonical links', () => {
+ el.setAttribute('href', 'http://www.segment.local')
+ document.body.appendChild(el)
+ const defs = getDefaultPageContext()
+ expect(defs.url).toEqual('http://www.segment.local')
+ })
+
+ it('favors canonical path over location.pathname', () => {
+ window.location.pathname = '/nope'
+ el.setAttribute('href', 'http://www.segment.local/test')
+ document.body.appendChild(el)
+ const defs = getDefaultPageContext()
+ expect(defs.path).toEqual('/test')
+ })
+
+ it('handles canonical links with a path', () => {
+ el.setAttribute('href', 'http://www.segment.local/test')
+ document.body.appendChild(el)
+ const defs = getDefaultPageContext()
+ expect(defs.url).toEqual('http://www.segment.local/test')
+ expect(defs.path).toEqual('/test')
+ })
+
+ it('handles canonical links with search params in the url', () => {
+ el.setAttribute('href', 'http://www.segment.local?test=true')
+ document.body.appendChild(el)
+ const defs = getDefaultPageContext()
+ expect(defs.url).toEqual('http://www.segment.local?test=true')
+ })
+
+ it('will add search params from the document to the canonical path if it does not have search params', () => {
+ // This seems like weird behavior to me, but I found it in the codebase so adding a test for it.
+ window.location.search = '?foo=123'
+ el.setAttribute('href', 'http://www.segment.local')
+ document.body.appendChild(el)
+ const defs = getDefaultPageContext()
+ expect(defs.url).toEqual('http://www.segment.local?foo=123')
+ })
+ })
+})
diff --git a/packages/browser/src/core/page/add-page-context.ts b/packages/browser/src/core/page/add-page-context.ts
new file mode 100644
index 000000000..9d2c61148
--- /dev/null
+++ b/packages/browser/src/core/page/add-page-context.ts
@@ -0,0 +1,33 @@
+import { pick } from '../../lib/pick'
+import { EventProperties, SegmentEvent } from '../events'
+import { getDefaultPageContext } from './get-page-context'
+
+/**
+ * Augments a segment event with information about the current page.
+ * Page information like URL changes frequently, so this is meant to be captured as close to the event call as possible.
+ * Things like `userAgent` do not change, so they can be added later in the flow.
+ * We prefer not to add this information to this function, as it increases the main bundle size.
+ */
+export const addPageContext = (
+ event: SegmentEvent,
+ pageCtx = getDefaultPageContext()
+): void => {
+ const evtCtx = event.context! // Context should be set earlier in the flow
+ let pageContextFromEventProps: Pick | undefined
+ if (event.type === 'page') {
+ pageContextFromEventProps =
+ event.properties && pick(event.properties, Object.keys(pageCtx))
+
+ event.properties = {
+ ...pageCtx,
+ ...event.properties,
+ ...(event.name ? { name: event.name } : {}),
+ }
+ }
+
+ evtCtx.page = {
+ ...pageCtx,
+ ...pageContextFromEventProps,
+ ...evtCtx.page,
+ }
+}
diff --git a/packages/browser/src/core/page/get-page-context.ts b/packages/browser/src/core/page/get-page-context.ts
new file mode 100644
index 000000000..3a56bd783
--- /dev/null
+++ b/packages/browser/src/core/page/get-page-context.ts
@@ -0,0 +1,140 @@
+import { isPlainObject } from '@segment/analytics-core'
+
+/**
+ * Final Page Context object expected in the Segment Event context
+ */
+export interface PageContext {
+ path: string
+ referrer: string
+ search: string
+ title: string
+ url: string
+}
+
+type CanonicalUrl = string | undefined
+
+export const BufferedPageContextDiscriminant = 'bpc' as const
+/**
+ * Page Context expected to be built by the snippet.
+ * Note: The key names are super short because we want to keep the strings in the html snippet short to save bytes.
+ */
+export interface BufferedPageContext {
+ __t: typeof BufferedPageContextDiscriminant // for extra uniqeness
+ c: CanonicalUrl
+ p: PageContext['path']
+ u: PageContext['url']
+ s: PageContext['search']
+ t: PageContext['title']
+ r: PageContext['referrer']
+}
+
+/**
+ * `BufferedPageContext` object builder
+ */
+export const createBufferedPageContext = (
+ url: string,
+ canonicalUrl: CanonicalUrl,
+ search: string,
+ path: string,
+ title: string,
+ referrer: string
+): BufferedPageContext => ({
+ __t: BufferedPageContextDiscriminant,
+ c: canonicalUrl,
+ p: path,
+ u: url,
+ s: search,
+ t: title,
+ r: referrer,
+})
+
+// my clever/dubious way of making sure this type guard does not get out sync with the type definition
+const BUFFERED_PAGE_CONTEXT_KEYS = Object.keys(
+ createBufferedPageContext('', '', '', '', '', '')
+) as (keyof BufferedPageContext)[]
+
+export function isBufferedPageContext(
+ bufferedPageCtx: unknown
+): bufferedPageCtx is BufferedPageContext {
+ if (!isPlainObject(bufferedPageCtx)) return false
+ if (bufferedPageCtx.__t !== BufferedPageContextDiscriminant) return false
+
+ // ensure obj has all the keys we expect, and none we don't.
+ for (const k in bufferedPageCtx) {
+ if (!BUFFERED_PAGE_CONTEXT_KEYS.includes(k as keyof BufferedPageContext)) {
+ return false
+ }
+ }
+ return true
+}
+
+// Legacy logic: we are we appending search parameters to the canonical URL -- I guess the canonical URL is "not canonical enough" (lol)
+const createCanonicalURL = (canonicalUrl: string, searchParams: string) => {
+ return canonicalUrl.indexOf('?') > -1
+ ? canonicalUrl
+ : canonicalUrl + searchParams
+}
+
+/**
+ * Strips hash from URL.
+ * http://www.segment.local#test -> http://www.segment.local
+ */
+const removeHash = (href: string) => {
+ const hashIdx = href.indexOf('#')
+ return hashIdx === -1 ? href : href.slice(0, hashIdx)
+}
+
+const parseCanonicalPath = (canonicalUrl: string): string => {
+ try {
+ return new URL(canonicalUrl).pathname
+ } catch (_e) {
+ // this is classic behavior -- we assume that if the canonical URL is invalid, it's a raw path.
+ return canonicalUrl[0] === '/' ? canonicalUrl : '/' + canonicalUrl
+ }
+}
+
+/**
+ * Create a `PageContext` from a `BufferedPageContext`.
+ * `BufferedPageContext` keys are minified to save bytes in the snippet.
+ */
+export const createPageContext = ({
+ c: canonicalUrl,
+ p: pathname,
+ s: search,
+ u: url,
+ r: referrer,
+ t: title,
+}: BufferedPageContext): PageContext => {
+ const newPath = canonicalUrl ? parseCanonicalPath(canonicalUrl) : pathname
+ const newUrl = canonicalUrl
+ ? createCanonicalURL(canonicalUrl, search)
+ : removeHash(url)
+ return {
+ path: newPath,
+ referrer,
+ search,
+ title,
+ url: newUrl,
+ }
+}
+
+/**
+ * Get page properties from the browser window/document.
+ */
+export const getDefaultBufferedPageContext = (): BufferedPageContext => {
+ const c = document.querySelector("link[rel='canonical']")
+ return createBufferedPageContext(
+ location.href,
+ (c && c.getAttribute('href')) || undefined,
+ location.search,
+ location.pathname,
+ document.title,
+ document.referrer
+ )
+}
+
+/**
+ * Get page properties from the browser window/document.
+ */
+export const getDefaultPageContext = (): PageContext =>
+ createPageContext(getDefaultBufferedPageContext())
diff --git a/packages/browser/src/core/page/index.ts b/packages/browser/src/core/page/index.ts
new file mode 100644
index 000000000..c6ff6b3cf
--- /dev/null
+++ b/packages/browser/src/core/page/index.ts
@@ -0,0 +1,2 @@
+export * from './get-page-context'
+export * from './add-page-context'
diff --git a/packages/browser/src/core/stats/__tests__/remote-metrics.test.ts b/packages/browser/src/core/stats/__tests__/remote-metrics.test.ts
index 354a9cc3e..5decef366 100644
--- a/packages/browser/src/core/stats/__tests__/remote-metrics.test.ts
+++ b/packages/browser/src/core/stats/__tests__/remote-metrics.test.ts
@@ -32,7 +32,7 @@ describe('remote metrics', () => {
})
remote.increment('analytics_js.banana', ['phone:1'])
- expect(remote.queue).toMatchInlineSnapshot(`Array []`)
+ expect(remote.queue).toMatchInlineSnapshot(`[]`)
})
test('ignores messages after reaching threshold', () => {
@@ -80,9 +80,9 @@ describe('remote metrics', () => {
expect(request).toMatchInlineSnapshot(
{ body: expect.anything() },
`
- Object {
+ {
"body": Anything,
- "headers": Object {
+ "headers": {
"Content-Type": "text/plain",
},
"method": "POST",
@@ -101,11 +101,11 @@ describe('remote metrics', () => {
],
},
`
- Object {
- "series": Array [
- Object {
+ {
+ "series": [
+ {
"metric": "analytics_js.banana",
- "tags": Object {
+ "tags": {
"library": "analytics.js",
"library_version": Any,
"phone": "1",
diff --git a/packages/browser/src/core/stats/metric-helpers.ts b/packages/browser/src/core/stats/metric-helpers.ts
new file mode 100644
index 000000000..f6f88eb33
--- /dev/null
+++ b/packages/browser/src/core/stats/metric-helpers.ts
@@ -0,0 +1,28 @@
+import { Context } from '../context'
+
+export interface RecordIntegrationMetricProps {
+ integrationName: string
+ methodName: string
+ didError?: boolean
+ type: 'classic' | 'action'
+}
+
+export function recordIntegrationMetric(
+ ctx: Context,
+ {
+ methodName,
+ integrationName,
+ type,
+ didError = false,
+ }: RecordIntegrationMetricProps
+): void {
+ ctx.stats.increment(
+ `analytics_js.integration.invoke${didError ? '.error' : ''}`,
+ 1,
+ [
+ `method:${methodName}`,
+ `integration_name:${integrationName}`,
+ `type:${type}`,
+ ]
+ )
+}
diff --git a/packages/browser/src/core/stats/remote-metrics.ts b/packages/browser/src/core/stats/remote-metrics.ts
index 65c23aa1a..82f5bfacf 100644
--- a/packages/browser/src/core/stats/remote-metrics.ts
+++ b/packages/browser/src/core/stats/remote-metrics.ts
@@ -8,6 +8,7 @@ export interface MetricsOptions {
sampleRate?: number
flushTimer?: number
maxQueueSize?: number
+ protocol?: 'http' | 'https'
}
/**
@@ -56,6 +57,7 @@ export class RemoteMetrics {
private host: string
private flushTimer: number
private maxQueueSize: number
+ private protocol: string
sampleRate: number
queue: RemoteMetric[]
@@ -65,6 +67,7 @@ export class RemoteMetrics {
this.sampleRate = options?.sampleRate ?? 1
this.flushTimer = options?.flushTimer ?? 30 * 1000 /* 30s */
this.maxQueueSize = options?.maxQueueSize ?? 20
+ this.protocol = options?.protocol ?? 'https'
this.queue = []
@@ -130,7 +133,7 @@ export class RemoteMetrics {
this.queue = []
const headers = { 'Content-Type': 'text/plain' }
- const url = `https://${this.host}/m`
+ const url = `${this.protocol}://${this.host}/m`
return fetch(url, {
headers,
diff --git a/packages/browser/src/core/storage/__tests__/test-helpers.ts b/packages/browser/src/core/storage/__tests__/test-helpers.ts
index 20faf069a..0971bdc57 100644
--- a/packages/browser/src/core/storage/__tests__/test-helpers.ts
+++ b/packages/browser/src/core/storage/__tests__/test-helpers.ts
@@ -1,16 +1,15 @@
import jar from 'js-cookie'
+const throwDisabledError = () => {
+ throw new Error('__sorry brah, this storage is disabled__')
+}
/**
* Disables Cookies
* @returns jest spy
*/
export function disableCookies(): void {
jest.spyOn(window.navigator, 'cookieEnabled', 'get').mockReturnValue(false)
- jest.spyOn(jar, 'set').mockImplementation(() => {
- throw new Error()
- })
- jest.spyOn(jar, 'get').mockImplementation(() => {
- throw new Error()
- })
+ jest.spyOn(jar, 'set').mockImplementation(throwDisabledError)
+ jest.spyOn(jar, 'get').mockImplementation(throwDisabledError)
}
/**
@@ -18,10 +17,10 @@ export function disableCookies(): void {
* @returns jest spy
*/
export function disableLocalStorage(): void {
- jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
- throw new Error()
- })
- jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
- throw new Error()
- })
+ jest
+ .spyOn(Storage.prototype, 'setItem')
+ .mockImplementation(throwDisabledError)
+ jest
+ .spyOn(Storage.prototype, 'getItem')
+ .mockImplementation(throwDisabledError)
}
diff --git a/packages/browser/src/core/storage/__tests__/universalStorage.test.ts b/packages/browser/src/core/storage/__tests__/universalStorage.test.ts
index 3c813ebb9..e872fa98d 100644
--- a/packages/browser/src/core/storage/__tests__/universalStorage.test.ts
+++ b/packages/browser/src/core/storage/__tests__/universalStorage.test.ts
@@ -12,12 +12,14 @@ describe('UniversalStorage', function () {
new MemoryStorage(),
]
const getFromLS = (key: string) => JSON.parse(localStorage.getItem(key) ?? '')
- beforeEach(function () {
- clear()
+ jest.spyOn(console, 'warn').mockImplementation(() => {
+ // avoid accidental noise in console
+ throw new Error('console.warn should be mocked!')
})
- afterEach(() => {
+ beforeEach(function () {
jest.restoreAllMocks()
+ clear()
})
function clear(): void {
@@ -104,6 +106,10 @@ describe('UniversalStorage', function () {
})
it('handles cookie errors gracefully', function () {
+ const consoleWarnSpy = jest
+ .spyOn(console, 'warn')
+ .mockImplementation(() => {})
+
disableCookies() // Cookies is going to throw exceptions now
const us = new UniversalStorage([
new LocalStorage(),
@@ -113,9 +119,17 @@ describe('UniversalStorage', function () {
us.set('ajs_test_key', 'π°')
expect(getFromLS('ajs_test_key')).toEqual('π°')
expect(us.get('ajs_test_key')).toEqual('π°')
+ expect(consoleWarnSpy.mock.calls.length).toEqual(1)
+ expect(consoleWarnSpy.mock.lastCall![0]).toContain(
+ "CookieStorage: Can't set key"
+ )
})
it('does not write to LS when LS is not available', function () {
+ const consoleWarnSpy = jest
+ .spyOn(console, 'warn')
+ .mockImplementation(() => {})
+
disableLocalStorage() // Localstorage will throw exceptions
const us = new UniversalStorage([
new LocalStorage(),
@@ -125,9 +139,14 @@ describe('UniversalStorage', function () {
us.set('ajs_test_key', 'π°')
expect(jar.get('ajs_test_key')).toEqual('π°')
expect(us.get('ajs_test_key')).toEqual('π°')
+ expect(consoleWarnSpy.mock.lastCall![0]).toContain('localStorage')
})
it('handles cookie getter overrides gracefully', function () {
+ const consoleWarnSpy = jest
+ .spyOn(console, 'warn')
+ .mockImplementation(() => {})
+
;(document as any).__defineGetter__('cookie', function () {
return ''
})
@@ -139,6 +158,10 @@ describe('UniversalStorage', function () {
us.set('ajs_test_key', 'π°')
expect(getFromLS('ajs_test_key')).toEqual('π°')
expect(us.get('ajs_test_key')).toEqual('π°')
+ expect(consoleWarnSpy.mock.lastCall![0]).toContain(
+ "CookieStorage: Can't set key"
+ )
+ expect(consoleWarnSpy.mock.lastCall![0]).toContain('TypeError')
})
})
})
diff --git a/packages/browser/src/core/storage/universalStorage.ts b/packages/browser/src/core/storage/universalStorage.ts
index 41cebf926..23dfefa3c 100644
--- a/packages/browser/src/core/storage/universalStorage.ts
+++ b/packages/browser/src/core/storage/universalStorage.ts
@@ -1,5 +1,17 @@
import { Store, StorageObject } from './types'
+// not adding to private method because those method names do not get minified atm, and does not use 'this'
+const _logStoreKeyError = (
+ store: Store,
+ action: 'set' | 'get' | 'remove',
+ key: string,
+ err: unknown
+) => {
+ console.warn(
+ `${store.constructor.name}: Can't ${action} key "${key}" | Err: ${err}`
+ )
+}
+
/**
* Uses multiple storages in a priority list to get/set values in the order they are specified.
*/
@@ -20,28 +32,28 @@ export class UniversalStorage {
return val
}
} catch (e) {
- console.warn(`Can't access ${key}: ${e}`)
+ _logStoreKeyError(store, 'get', key, e)
}
}
return null
}
set(key: K, value: Data[K] | null): void {
- this.stores.forEach((s) => {
+ this.stores.forEach((store) => {
try {
- s.set(key, value)
+ store.set(key, value)
} catch (e) {
- console.warn(`Can't set ${key}: ${e}`)
+ _logStoreKeyError(store, 'set', key, e)
}
})
}
clear(key: K): void {
- this.stores.forEach((s) => {
+ this.stores.forEach((store) => {
try {
- s.remove(key)
+ store.remove(key)
} catch (e) {
- console.warn(`Can't remove ${key}: ${e}`)
+ _logStoreKeyError(store, 'remove', key, e)
}
})
}
diff --git a/packages/browser/src/generated/version.ts b/packages/browser/src/generated/version.ts
index 3e0d2d55c..b78db69d5 100644
--- a/packages/browser/src/generated/version.ts
+++ b/packages/browser/src/generated/version.ts
@@ -1,2 +1,2 @@
// This file is generated.
-export const version = '1.56.0'
+export const version = '1.66.0'
diff --git a/packages/browser/src/lib/__tests__/merged-options.test.ts b/packages/browser/src/lib/__tests__/merged-options.test.ts
index 4d384516d..6f1a96f0d 100644
--- a/packages/browser/src/lib/__tests__/merged-options.test.ts
+++ b/packages/browser/src/lib/__tests__/merged-options.test.ts
@@ -21,11 +21,11 @@ describe(mergedOptions, () => {
)
expect(merged).toMatchInlineSnapshot(`
- Object {
- "Amplitude": Object {
+ {
+ "Amplitude": {
"apiKey": "π",
},
- "CustomerIO": Object {
+ "CustomerIO": {
"ghost": "π»",
},
}
@@ -52,8 +52,8 @@ describe(mergedOptions, () => {
)
expect(merged).toMatchInlineSnapshot(`
- Object {
- "Amplitude": Object {
+ {
+ "Amplitude": {
"apiKey": "π",
},
}
@@ -81,11 +81,11 @@ describe(mergedOptions, () => {
)
expect(merged).toMatchInlineSnapshot(`
- Object {
- "Amplitude": Object {
+ {
+ "Amplitude": {
"apiKey": "π",
},
- "CustomerIO": Object {
+ "CustomerIO": {
"ghost": "π»",
},
}
@@ -110,11 +110,11 @@ describe(mergedOptions, () => {
}
expect(mergedOptions(cdn, overrides)).toMatchInlineSnapshot(`
- Object {
- "Google Tag Manager": Object {
+ {
+ "Google Tag Manager": {
"ghost": "π»",
},
- "Segment.io": Object {
+ "Segment.io": {
"apiHost": "mgs.instacart.com/v2",
},
}
diff --git a/packages/browser/src/lib/__tests__/pick.test.ts b/packages/browser/src/lib/__tests__/pick.test.ts
index 103dd5c27..2729ab492 100644
--- a/packages/browser/src/lib/__tests__/pick.test.ts
+++ b/packages/browser/src/lib/__tests__/pick.test.ts
@@ -24,7 +24,7 @@ describe(pick, () => {
it('does not mutate object reference', () => {
const e = {
obj: { a: 1, '0': false, c: 3 },
- keys: ['a', '0'] as const,
+ keys: ['a', '0'],
expected: { a: 1, '0': false },
}
const ogObj = { ...e.obj }
diff --git a/packages/browser/src/lib/__tests__/pick.typedef.ts b/packages/browser/src/lib/__tests__/pick.typedef.ts
new file mode 100644
index 000000000..82182aab0
--- /dev/null
+++ b/packages/browser/src/lib/__tests__/pick.typedef.ts
@@ -0,0 +1,39 @@
+import { assertNotAny, assertIs } from '../../test-helpers/type-assertions'
+import { pick } from '../pick'
+
+{
+ // should work with literals
+ const res = pick({ id: 123 }, ['id'])
+
+ assertIs<{ id: number }>(res)
+ assertNotAny(res)
+}
+{
+ // should work if only keys are read-only
+ const obj: { id?: number } = {}
+ const res = pick(obj, ['id'] as const)
+ assertNotAny(res)
+ assertIs<{ id?: number }>(res)
+
+ // @ts-expect-error
+ assertIs<{ id: number }>(res)
+}
+
+{
+ // should work with keys as string
+ const res = pick({ id: 123 }, [] as string[])
+ assertNotAny(res)
+
+ assertIs>(res)
+ // @ts-expect-error - should be partial
+ assertIs<{ id: number }>(res)
+}
+
+{
+ // should work with object type
+ const res = pick({} as object, ['id'])
+ assertNotAny(res)
+ assertIs