From ee855bad751c393a40dcbde7ae861f27d2b4da26 Mon Sep 17 00:00:00 2001 From: Neek Sandhu Date: Thu, 31 Aug 2023 14:09:59 -0700 Subject: [PATCH] Move context augmentation to Page enrichment (#939) Part of segment.atlassian.net/browse/LIBWEB-1426 --- .changeset/beige-camels-study.md | 5 + .changeset/calm-donkeys-eat.md | 5 + packages/browser/src/browser/browser-umd.ts | 2 +- packages/browser/src/browser/standalone.ts | 2 +- .../browser/src/core/stats/remote-metrics.ts | 2 +- packages/browser/src/lib/version-type.ts | 10 + .../page-enrichment/__tests__/index.test.ts | 326 ++++++++++++++++++ .../src/plugins/page-enrichment/index.ts | 205 +++++++++-- .../segmentio/__tests__/normalize.test.ts | 278 --------------- .../src/plugins/segmentio/normalize.ts | 156 +-------- packages/core/src/events/interfaces.ts | 15 +- 11 files changed, 531 insertions(+), 475 deletions(-) create mode 100644 .changeset/beige-camels-study.md create mode 100644 .changeset/calm-donkeys-eat.md create mode 100644 packages/browser/src/lib/version-type.ts diff --git a/.changeset/beige-camels-study.md b/.changeset/beige-camels-study.md new file mode 100644 index 000000000..8663f21ea --- /dev/null +++ b/.changeset/beige-camels-study.md @@ -0,0 +1,5 @@ +--- +'@segment/analytics-next': minor +--- + +Move context augmentation to Page Enrichment plugin diff --git a/.changeset/calm-donkeys-eat.md b/.changeset/calm-donkeys-eat.md new file mode 100644 index 000000000..79f18b9f6 --- /dev/null +++ b/.changeset/calm-donkeys-eat.md @@ -0,0 +1,5 @@ +--- +'@segment/analytics-core': patch +--- + +Update Campaign type to be more relaxed diff --git a/packages/browser/src/browser/browser-umd.ts b/packages/browser/src/browser/browser-umd.ts index 07ea1107c..d4c9b9f87 100644 --- a/packages/browser/src/browser/browser-umd.ts +++ b/packages/browser/src/browser/browser-umd.ts @@ -1,5 +1,5 @@ import { getCDN, setGlobalCDNUrl } from '../lib/parse-cdn' -import { setVersionType } from '../plugins/segmentio/normalize' +import { setVersionType } from '../lib/version-type' if (process.env.ASSET_PATH) { if (process.env.ASSET_PATH === '/dist/umd/') { diff --git a/packages/browser/src/browser/standalone.ts b/packages/browser/src/browser/standalone.ts index c4f4fd4a2..69f86f6fe 100644 --- a/packages/browser/src/browser/standalone.ts +++ b/packages/browser/src/browser/standalone.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-floating-promises */ import { getCDN, setGlobalCDNUrl } from '../lib/parse-cdn' -import { setVersionType } from '../plugins/segmentio/normalize' +import { setVersionType } from '../lib/version-type' if (process.env.ASSET_PATH) { if (process.env.ASSET_PATH === '/dist/umd/') { diff --git a/packages/browser/src/core/stats/remote-metrics.ts b/packages/browser/src/core/stats/remote-metrics.ts index 9d362ae30..65c23aa1a 100644 --- a/packages/browser/src/core/stats/remote-metrics.ts +++ b/packages/browser/src/core/stats/remote-metrics.ts @@ -1,6 +1,6 @@ import { fetch } from '../../lib/fetch' import { version } from '../../generated/version' -import { getVersionType } from '../../plugins/segmentio/normalize' +import { getVersionType } from '../../lib/version-type' import { SEGMENT_API_HOST } from '../constants' export interface MetricsOptions { diff --git a/packages/browser/src/lib/version-type.ts b/packages/browser/src/lib/version-type.ts new file mode 100644 index 000000000..327ee0ac6 --- /dev/null +++ b/packages/browser/src/lib/version-type.ts @@ -0,0 +1,10 @@ +// Default value will be updated to 'web' in `bundle-umd.ts` for web build. +let _version: 'web' | 'npm' = 'npm' + +export function setVersionType(version: typeof _version) { + _version = version +} + +export function getVersionType(): typeof _version { + return _version +} diff --git a/packages/browser/src/plugins/page-enrichment/__tests__/index.test.ts b/packages/browser/src/plugins/page-enrichment/__tests__/index.test.ts index 21cc6bf23..90723cfcd 100644 --- a/packages/browser/src/plugins/page-enrichment/__tests__/index.test.ts +++ b/packages/browser/src/plugins/page-enrichment/__tests__/index.test.ts @@ -1,6 +1,11 @@ +import cookie from 'js-cookie' +import assert from 'assert' import { Analytics } from '../../../core/analytics' import { pageEnrichment, pageDefaults } from '..' import { pick } from '../../../lib/pick' +import { SegmentioSettings } from '../../segmentio' +import { version } from '../../../generated/version' +import { CoreExtraContext } from '@segment/analytics-core' let ajs: Analytics @@ -16,6 +21,20 @@ const helpers = { }, } +/** + * Filters out the calls made for probing cookie availability + */ +const ignoreProbeCookieWrites = ( + fn: jest.SpyInstance< + string | undefined, + [ + name: string, + value: string | object, + options?: cookie.CookieAttributes | undefined + ] + > +) => fn.mock.calls.filter((c) => c[0] !== 'ajs_cookies_check') + describe('Page Enrichment', () => { beforeEach(async () => { ajs = new Analytics({ @@ -239,3 +258,310 @@ describe('pageDefaults', () => { expect(defs.url).toEqual(window.location.href) }) }) + +describe('Other visitor metadata', () => { + let options: SegmentioSettings + let analytics: Analytics + + const amendSearchParams = (search?: any): CoreExtraContext => ({ + page: { search }, + }) + + beforeEach(async () => { + options = { apiKey: 'foo' } + analytics = new Analytics({ writeKey: options.apiKey }) + + await analytics.register(pageEnrichment) + }) + + afterEach(() => { + analytics.reset() + Object.keys(cookie.get()).map((k) => cookie.remove(k)) + + if (window.localStorage) { + window.localStorage.clear() + } + }) + + it('should add .library', async () => { + const ctx = await analytics.track('test') + assert(ctx.event.context?.library) + assert(ctx.event.context?.library.name === 'analytics.js') + assert(ctx.event.context?.library.version === `npm:next-${version}`) + }) + + it('should allow override of .library', async () => { + const customContext = { + library: { + name: 'analytics-wordpress', + version: '1.0.3', + }, + } + + const ctx = await analytics.track('test', {}, { context: customContext }) + + assert(ctx.event.context?.library) + assert(ctx.event.context?.library.name === 'analytics-wordpress') + assert(ctx.event.context?.library.version === '1.0.3') + }) + + it('should add .userAgent', async () => { + const ctx = await analytics.track('test') + const removeVersionNum = (agent: string) => agent.replace(/jsdom\/.*/, '') + const userAgent1 = removeVersionNum(ctx.event.context?.userAgent as string) + const userAgent2 = removeVersionNum(navigator.userAgent) + assert(userAgent1 === userAgent2) + }) + + it('should add .locale', async () => { + const ctx = await analytics.track('test') + assert(ctx.event.context?.locale === navigator.language) + }) + + it('should not replace .locale if provided', async () => { + const customContext = { + locale: 'foobar', + } + + const ctx = await analytics.track('test', {}, { context: customContext }) + assert(ctx.event.context?.locale === 'foobar') + }) + + it('should add .campaign', async () => { + const ctx = await analytics.track( + 'test', + {}, + { + context: amendSearchParams( + 'utm_source=source&utm_medium=medium&utm_term=term&utm_content=content&utm_campaign=name' + ), + } + ) + + assert(ctx.event) + assert(ctx.event.context) + assert(ctx.event.context.campaign) + assert(ctx.event.context.campaign.source === 'source') + assert(ctx.event.context.campaign.medium === 'medium') + assert(ctx.event.context.campaign.term === 'term') + assert(ctx.event.context.campaign.content === 'content') + assert(ctx.event.context.campaign.name === 'name') + }) + + it('should decode query params', async () => { + const ctx = await analytics.track( + 'test', + {}, + { + context: amendSearchParams('?utm_source=%5BFoo%5D'), + } + ) + + assert(ctx.event) + assert(ctx.event.context) + assert(ctx.event.context.campaign) + assert(ctx.event.context.campaign.source === '[Foo]') + }) + + it('should guard against undefined utm params', async () => { + const ctx = await analytics.track( + 'test', + {}, + { + context: amendSearchParams('?utm_source'), + } + ) + + assert(ctx.event) + assert(ctx.event.context) + assert(ctx.event.context.campaign) + assert(ctx.event.context.campaign.source === '') + }) + + it('should guard against empty utm params', async () => { + const ctx = await analytics.track( + 'test', + {}, + { + context: amendSearchParams('?utm_source='), + } + ) + + assert(ctx.event) + assert(ctx.event.context) + assert(ctx.event.context.campaign) + assert(ctx.event.context.campaign.source === '') + }) + + it('only parses utm params suffixed with _', async () => { + const ctx = await analytics.track( + 'test', + {}, + { + context: amendSearchParams('?utm'), + } + ) + + assert(ctx.event) + assert(ctx.event.context) + assert.deepStrictEqual(ctx.event.context.campaign, {}) + }) + + it('should guard against short utm params', async () => { + const ctx = await analytics.track( + 'test', + {}, + { + context: amendSearchParams('?utm_'), + } + ) + + assert(ctx.event) + assert(ctx.event.context) + assert.deepStrictEqual(ctx.event.context.campaign, {}) + }) + + it('should allow override of .campaign', async () => { + const ctx = await analytics.track( + 'test', + {}, + { + context: { + ...amendSearchParams( + '?utm_source=source&utm_medium=medium&utm_term=term&utm_content=content&utm_campaign=name' + ), + campaign: { + source: 'overrideSource', + medium: 'overrideMedium', + term: 'overrideTerm', + content: 'overrideContent', + name: 'overrideName', + }, + }, + } + ) + + assert(ctx.event) + assert(ctx.event.context) + assert(ctx.event.context.campaign) + assert(ctx.event.context.campaign.source === 'overrideSource') + assert(ctx.event.context.campaign.medium === 'overrideMedium') + assert(ctx.event.context.campaign.term === 'overrideTerm') + assert(ctx.event.context.campaign.content === 'overrideContent') + assert(ctx.event.context.campaign.name === 'overrideName') + }) + + it('should allow override of .search with object', async () => { + const ctx = await analytics.track( + 'test', + {}, + { + context: amendSearchParams({ + someObject: 'foo', + }), + } + ) + assert(ctx.event) + assert(ctx.event.context) + assert(ctx.event.context.campaign === undefined) + assert(ctx.event.context.referrer === undefined) + }) + + it('should add .referrer.id and .referrer.type (cookies)', async () => { + const ctx = await analytics.track( + 'test', + {}, + { + context: amendSearchParams('?utm_source=source&urid=medium'), + } + ) + + assert(ctx.event) + assert(ctx.event.context) + assert(ctx.event.context.referrer) + expect(ctx.event.context.referrer.id).toBe('medium') + assert(ctx.event.context.referrer.type === 'millennial-media') + expect(cookie.get('s:context.referrer')).toEqual( + JSON.stringify({ + id: 'medium', + type: 'millennial-media', + }) + ) + }) + + it('should add .referrer.id and .referrer.type (cookieless)', async () => { + const setCookieSpy = jest.spyOn(cookie, 'set') + analytics = new Analytics( + { writeKey: options.apiKey }, + { disableClientPersistence: true } + ) + + await analytics.register(pageEnrichment) + + const ctx = await analytics.track( + 'test', + {}, + { + context: amendSearchParams('utm_source=source&urid=medium'), + } + ) + + assert(ctx.event) + assert(ctx.event.context) + assert(ctx.event.context.referrer) + expect(ctx.event.context.referrer.id).toEqual('medium') + assert(ctx.event.context.referrer.type === 'millennial-media') + expect(cookie.get('s:context.referrer')).toBeUndefined() + expect(ignoreProbeCookieWrites(setCookieSpy).length).toBe(0) + }) + + it('should add .referrer.id and .referrer.type from cookie', async () => { + cookie.set('s:context.referrer', '{"id":"baz","type":"millennial-media"}') + const ctx = await analytics.track('test') + + assert(ctx.event) + assert(ctx.event.context) + assert(ctx.event.context.referrer) + assert(ctx.event.context.referrer.id === 'baz') + assert(ctx.event.context.referrer.type === 'millennial-media') + }) + + it('should add .referrer.id and .referrer.type from cookie when no query is given', async () => { + cookie.set( + 's:context.referrer', + '{"id":"medium","type":"millennial-media"}' + ) + const ctx = await analytics.track('test') + + assert(ctx.event) + assert(ctx.event.context) + assert(ctx.event.context.referrer) + assert(ctx.event.context.referrer.id === 'medium') + assert(ctx.event.context.referrer.type === 'millennial-media') + }) + + it('shouldnt add non amp ga cookie', async () => { + cookie.set('_ga', 'some-nonamp-id') + const ctx = await analytics.track('test') + assert(ctx.event) + assert(ctx.event.context) + assert(!ctx.event.context.amp) + }) + + it('should add .amp.id from store', async () => { + cookie.set('_ga', 'amp-foo') + const ctx = await analytics.track('test') + assert(ctx.event) + assert(ctx.event.context) + assert(ctx.event.context.amp) + assert(ctx.event.context.amp.id === 'amp-foo') + }) + + it('should not add .amp if theres no _ga', async () => { + cookie.remove('_ga') + const ctx = await analytics.track('test') + assert(ctx.event) + assert(ctx.event.context) + assert(!ctx.event.context.amp) + }) +}) diff --git a/packages/browser/src/plugins/page-enrichment/index.ts b/packages/browser/src/plugins/page-enrichment/index.ts index 7984d20a2..91cf1162f 100644 --- a/packages/browser/src/plugins/page-enrichment/index.ts +++ b/packages/browser/src/plugins/page-enrichment/index.ts @@ -1,7 +1,15 @@ +import jar from 'js-cookie' import { pick } from '../../lib/pick' import type { Context } from '../../core/context' import type { Plugin } from '../../core/plugin' -import { EventProperties } from '@segment/analytics-core' +import { version } from '../../generated/version' +import { SegmentEvent } from '../../core/events' +import { Campaign, EventProperties, PluginType } from '@segment/analytics-core' +import { getVersionType } from '../../lib/version-type' +import { tld } from '../../core/user/tld' +import { gracefulDecodeURIComponent } from '../../core/query-string/gracefulDecodeURIComponent' +import { CookieStorage, UniversalStorage } from '../../core/storage' +import { Analytics } from '../../core/analytics' interface PageDefault { [key: string]: unknown @@ -12,6 +20,50 @@ interface PageDefault { url: string } +let cookieOptions: jar.CookieAttributes | undefined +function getCookieOptions(): jar.CookieAttributes { + if (cookieOptions) { + return cookieOptions + } + + const domain = tld(window.location.href) + cookieOptions = { + expires: 31536000000, // 1 year + secure: false, + path: '/', + } + if (domain) { + cookieOptions.domain = domain + } + + return cookieOptions +} + +type Ad = { id: string; type: string } + +function ads(query: string): Ad | undefined { + const queryIds: Record = { + btid: 'dataxu', + urid: 'millennial-media', + } + + if (query.startsWith('?')) { + query = query.substring(1) + } + query = query.replace(/\?/g, '&') + const parts = query.split('&') + + for (const part of parts) { + const [k, v] = part.split('=') + if (queryIds[k]) { + return { + id: v, + type: queryIds[k], + } + } + } +} + /** * Get the current page's canonical URL. */ @@ -70,45 +122,134 @@ export function pageDefaults(): PageDefault { } } -function enrichPageContext(ctx: Context): Context { - const event = ctx.event - event.context = event.context || {} +export function utm(query: string): Campaign { + if (query.startsWith('?')) { + query = query.substring(1) + } + query = query.replace(/\?/g, '&') + + return query.split('&').reduce((acc, str) => { + const [k, v = ''] = str.split('=') + if (k.includes('utm_') && k.length > 4) { + let utmParam = k.substr(4) as keyof Campaign + if (utmParam === 'campaign') { + utmParam = 'name' + } + acc[utmParam] = gracefulDecodeURIComponent(v) + } + return acc + }, {} as Campaign) +} + +export function ampId(): string | undefined { + const ampId = jar.get('_ga') + if (ampId && ampId.startsWith('amp')) { + return ampId + } +} - const defaultPageContext = pageDefaults() +function referrerId( + query: string, + ctx: SegmentEvent['context'], + disablePersistance: boolean +): void { + const storage = new UniversalStorage<{ + 's:context.referrer': Ad + }>(disablePersistance ? [] : [new CookieStorage(getCookieOptions())]) - let pageContextFromEventProps: Pick | undefined = - undefined + const stored = storage.get('s:context.referrer') - if (event.type === 'page') { - pageContextFromEventProps = - event.properties && - pick(event.properties, Object.keys(defaultPageContext)) + const ad = ads(query) ?? stored - event.properties = { - ...defaultPageContext, - ...event.properties, - ...(event.name ? { name: event.name } : {}), - } + if (!ad) { + return } - event.context.page = { - ...defaultPageContext, - ...pageContextFromEventProps, - ...event.context.page, + if (ctx) { + ctx.referrer = { ...ctx.referrer, ...ad } } - return ctx + storage.set('s:context.referrer', ad) } -export const pageEnrichment: Plugin = { - name: 'Page Enrichment', - version: '0.1.0', - isLoaded: () => true, - load: () => Promise.resolve(), - type: 'before', - page: enrichPageContext, - alias: enrichPageContext, - track: enrichPageContext, - identify: enrichPageContext, - group: enrichPageContext, +class PageEnrichmentPlugin implements Plugin { + private instance!: Analytics + + name = 'Page Enrichment' + type: PluginType = 'before' + version = '0.1.0' + isLoaded = () => true + load = (_ctx: Context, instance: Analytics) => { + this.instance = instance + return Promise.resolve() + } + + private enrich = (ctx: Context): Context => { + const event = ctx.event + const evtCtx = (event.context ??= {}) + + const defaultPageContext = pageDefaults() + + let pageContextFromEventProps: Pick | undefined + + if (event.type === 'page') { + pageContextFromEventProps = + event.properties && + pick(event.properties, Object.keys(defaultPageContext)) + + event.properties = { + ...defaultPageContext, + ...event.properties, + ...(event.name ? { name: event.name } : {}), + } + } + + evtCtx.page = { + ...defaultPageContext, + ...pageContextFromEventProps, + ...evtCtx.page, + } + + const query: string = evtCtx.page.search || '' + + evtCtx.userAgent = navigator.userAgent + + // @ts-ignore + const locale = navigator.userLanguage || navigator.language + + if (typeof evtCtx.locale === 'undefined' && typeof locale !== 'undefined') { + evtCtx.locale = locale + } + + evtCtx.library ??= { + name: 'analytics.js', + version: `${getVersionType() === 'web' ? 'next' : 'npm:next'}-${version}`, + } + + if (query && !evtCtx.campaign) { + evtCtx.campaign = utm(query) + } + + const amp = ampId() + if (amp) { + evtCtx.amp = { id: amp } + } + + referrerId( + query, + evtCtx, + this.instance.options.disableClientPersistence ?? false + ) + + return ctx + } + + track = this.enrich + identify = this.enrich + page = this.enrich + group = this.enrich + alias = this.enrich + screen = this.enrich } + +export const pageEnrichment = new PageEnrichmentPlugin() diff --git a/packages/browser/src/plugins/segmentio/__tests__/normalize.test.ts b/packages/browser/src/plugins/segmentio/__tests__/normalize.test.ts index c808f3780..b6225c344 100644 --- a/packages/browser/src/plugins/segmentio/__tests__/normalize.test.ts +++ b/packages/browser/src/plugins/segmentio/__tests__/normalize.test.ts @@ -5,21 +5,6 @@ import { normalize } from '../normalize' import { Analytics } from '../../../core/analytics' import { SegmentEvent } from '../../../core/events' import { JSDOM } from 'jsdom' -import { version } from '../../../generated/version' - -/** - * Filters out the calls made for probing cookie availability - */ -const ignoreProbeCookieWrites = ( - fn: jest.SpyInstance< - string | undefined, - [ - name: string, - value: string | object, - options?: cookie.CookieAttributes | undefined - ] - > -) => fn.mock.calls.filter((c) => c[0] !== 'ajs_cookies_check') describe('before loading', () => { let jsdom: JSDOM @@ -69,9 +54,6 @@ describe('before loading', () => { describe('#normalize', () => { let object: SegmentEvent let defaultCtx: any - const withSearchParams = (search?: any) => { - object.context = { page: { search } } - } beforeEach(() => { cookie.remove('s:context.referrer') @@ -132,271 +114,11 @@ describe('before loading', () => { expect(object.anonymousId).toEqual('👻') }) - it('should add .context', () => { - normalize(analytics, object, options, {}) - assert(object.context) - }) - - it('should not rewrite context if provided', () => { - const ctx = defaultCtx - const obj = { ...object, context: ctx } - normalize(analytics, obj, options, {}) - expect(obj.context).toEqual(ctx) - }) - - it('should overwrite options with context if context does not exist', () => { - const opts = {} - const obj = { ...object, options: opts } - delete obj.context - normalize(analytics, obj, options, {}) - assert(obj.context === opts) - assert(obj.options == null) - }) - it('should add .writeKey', () => { normalize(analytics, object, options, {}) assert(object.writeKey === options.apiKey) }) - it('should add .library', () => { - normalize(analytics, object, options, {}) - assert(object.context?.library) - assert(object.context?.library.name === 'analytics.js') - assert(object.context?.library.version === `npm:next-${version}`) - }) - - it('should allow override of .library', () => { - const ctx = { - library: { - name: 'analytics-wordpress', - version: '1.0.3', - }, - } - const obj = { ...object, context: ctx } - - normalize(analytics, obj, options, {}) - - assert(obj.context?.library) - assert(obj.context?.library.name === 'analytics-wordpress') - assert(obj.context?.library.version === '1.0.3') - }) - - it('should add .userAgent', () => { - normalize(analytics, object, options, {}) - const removeVersionNum = (agent: string) => agent.replace(/jsdom\/.*/, '') - const userAgent1 = removeVersionNum(object.context?.userAgent as string) - const userAgent2 = removeVersionNum(navigator.userAgent) - assert(userAgent1 === userAgent2) - }) - - it('should add .locale', () => { - normalize(analytics, object, options, {}) - assert(object.context?.locale === navigator.language) - }) - - it('should not replace .locale if provided', () => { - const ctx = { - ...defaultCtx, - locale: 'foobar', - } - const obj = { ...object, context: ctx } - normalize(analytics, obj, options, {}) - assert(obj.context?.locale === 'foobar') - }) - - it('should add .campaign', () => { - withSearchParams( - 'utm_source=source&utm_medium=medium&utm_term=term&utm_content=content&utm_campaign=name' - ) - normalize(analytics, object, options, {}) - - assert(object) - assert(object.context) - assert(object.context.campaign) - assert(object.context.campaign.source === 'source') - assert(object.context.campaign.medium === 'medium') - assert(object.context.campaign.term === 'term') - assert(object.context.campaign.content === 'content') - assert(object.context.campaign.name === 'name') - }) - - it('should decode query params', () => { - withSearchParams('?utm_source=%5BFoo%5D') - normalize(analytics, object, options, {}) - - assert(object) - assert(object.context) - assert(object.context.campaign) - assert(object.context.campaign.source === '[Foo]') - }) - - it('should guard against undefined utm params', () => { - withSearchParams('?utm_source') - - normalize(analytics, object, options, {}) - - assert(object) - assert(object.context) - assert(object.context.campaign) - assert(object.context.campaign.source === '') - }) - - it('should guard against empty utm params', () => { - withSearchParams('?utm_source=') - normalize(analytics, object, options, {}) - - assert(object) - assert(object.context) - assert(object.context.campaign) - assert(object.context.campaign.source === '') - }) - - it('only parses utm params suffixed with _', () => { - withSearchParams('?utm') - normalize(analytics, object, options, {}) - - assert(object) - assert(object.context) - assert.deepStrictEqual(object.context.campaign, {}) - }) - - it('should guard against short utm params', () => { - withSearchParams('?utm_') - - normalize(analytics, object, options, {}) - - assert(object) - assert(object.context) - assert.deepStrictEqual(object.context.campaign, {}) - }) - - it('should allow override of .campaign', () => { - withSearchParams( - '?utm_source=source&utm_medium=medium&utm_term=term&utm_content=content&utm_campaign=name' - ) - - const obj = { - ...object, - context: { - ...defaultCtx, - campaign: { - source: 'overrideSource', - medium: 'overrideMedium', - term: 'overrideTerm', - content: 'overrideContent', - name: 'overrideName', - }, - }, - } - normalize(analytics, obj, options, {}) - assert(obj) - assert(obj.context) - assert(obj.context.campaign) - assert(obj.context.campaign.source === 'overrideSource') - assert(obj.context.campaign.medium === 'overrideMedium') - assert(obj.context.campaign.term === 'overrideTerm') - assert(obj.context.campaign.content === 'overrideContent') - assert(obj.context.campaign.name === 'overrideName') - }) - - it('should allow override of .search with object', () => { - withSearchParams({ - someObject: 'foo', - }) - - normalize(analytics, object, options, {}) - assert(object) - assert(object.context) - assert(object.context.campaign === undefined) - assert(object.context.referrer === undefined) - }) - - it('should add .referrer.id and .referrer.type (cookies)', () => { - withSearchParams('?utm_source=source&urid=medium') - - normalize(analytics, object, options, {}) - assert(object) - assert(object.context) - assert(object.context.referrer) - expect(object.context.referrer.id).toBe('medium') - assert(object.context.referrer.type === 'millennial-media') - expect(cookie.get('s:context.referrer')).toEqual( - JSON.stringify({ - id: 'medium', - type: 'millennial-media', - }) - ) - }) - - it('should add .referrer.id and .referrer.type (cookieless)', () => { - withSearchParams('utm_source=source&urid=medium') - const setCookieSpy = jest.spyOn(cookie, 'set') - analytics = new Analytics( - { writeKey: options.apiKey }, - { disableClientPersistence: true } - ) - - normalize(analytics, object, options, {}) - assert(object) - assert(object.context) - assert(object.context.referrer) - expect(object.context.referrer.id).toEqual('medium') - assert(object.context.referrer.type === 'millennial-media') - expect(cookie.get('s:context.referrer')).toBeUndefined() - expect(ignoreProbeCookieWrites(setCookieSpy).length).toBe(0) - }) - - it('should add .referrer.id and .referrer.type from cookie', () => { - cookie.set('s:context.referrer', '{"id":"baz","type":"millennial-media"}') - - normalize(analytics, object, options, {}) - - assert(object) - assert(object.context) - assert(object.context.referrer) - assert(object.context.referrer.id === 'baz') - assert(object.context.referrer.type === 'millennial-media') - }) - - it('should add .referrer.id and .referrer.type from cookie when no query is given', () => { - cookie.set( - 's:context.referrer', - '{"id":"medium","type":"millennial-media"}' - ) - - normalize(analytics, object, options, {}) - assert(object) - assert(object.context) - assert(object.context.referrer) - assert(object.context.referrer.id === 'medium') - assert(object.context.referrer.type === 'millennial-media') - }) - - it('shouldnt add non amp ga cookie', () => { - cookie.set('_ga', 'some-nonamp-id') - normalize(analytics, object, options, {}) - assert(object) - assert(object.context) - assert(!object.context.amp) - }) - - it('should add .amp.id from store', () => { - cookie.set('_ga', 'amp-foo') - normalize(analytics, object, options, {}) - assert(object) - assert(object.context) - assert(object.context.amp) - assert(object.context.amp.id === 'amp-foo') - }) - - it('should not add .amp if theres no _ga', () => { - cookie.remove('_ga') - normalize(analytics, object, options, {}) - assert(object) - assert(object.context) - assert(!object.context.amp) - }) - describe('unbundling', () => { it('should add a list of bundled integrations', () => { normalize(analytics, object, options, { diff --git a/packages/browser/src/plugins/segmentio/normalize.ts b/packages/browser/src/plugins/segmentio/normalize.ts index 5f2df7f55..afb22cf3d 100644 --- a/packages/browser/src/plugins/segmentio/normalize.ts +++ b/packages/browser/src/plugins/segmentio/normalize.ts @@ -1,119 +1,7 @@ -import jar from 'js-cookie' import { Analytics } from '../../core/analytics' import { LegacySettings } from '../../browser' -import { SegmentEvent } from '../../core/events' -import { gracefulDecodeURIComponent } from '../../core/query-string/gracefulDecodeURIComponent' -import { tld } from '../../core/user/tld' import { SegmentFacade } from '../../lib/to-facade' import { SegmentioSettings } from './index' -import { version } from '../../generated/version' -import { CookieStorage, UniversalStorage } from '../../core/storage' - -let cookieOptions: jar.CookieAttributes | undefined -function getCookieOptions(): jar.CookieAttributes { - if (cookieOptions) { - return cookieOptions - } - - const domain = tld(window.location.href) - cookieOptions = { - expires: 31536000000, // 1 year - secure: false, - path: '/', - } - if (domain) { - cookieOptions.domain = domain - } - - return cookieOptions -} - -// Default value will be updated to 'web' in `bundle-umd.ts` for web build. -let _version: 'web' | 'npm' = 'npm' - -export function setVersionType(version: typeof _version) { - _version = version -} - -export function getVersionType(): typeof _version { - return _version -} - -type Ad = { id: string; type: string } - -export function ampId(): string | undefined { - const ampId = jar.get('_ga') - if (ampId && ampId.startsWith('amp')) { - return ampId - } -} - -export function utm(query: string): Record { - if (query.startsWith('?')) { - query = query.substring(1) - } - query = query.replace(/\?/g, '&') - - return query.split('&').reduce((acc, str) => { - const [k, v = ''] = str.split('=') - if (k.includes('utm_') && k.length > 4) { - let utmParam = k.substr(4) - if (utmParam === 'campaign') { - utmParam = 'name' - } - acc[utmParam] = gracefulDecodeURIComponent(v) - } - return acc - }, {} as Record) -} - -function ads(query: string): Ad | undefined { - const queryIds: Record = { - btid: 'dataxu', - urid: 'millennial-media', - } - - if (query.startsWith('?')) { - query = query.substring(1) - } - query = query.replace(/\?/g, '&') - const parts = query.split('&') - - for (const part of parts) { - const [k, v] = part.split('=') - if (queryIds[k]) { - return { - id: v, - type: queryIds[k], - } - } - } -} - -function referrerId( - query: string, - ctx: SegmentEvent['context'], - disablePersistance: boolean -): void { - const storage = new UniversalStorage<{ - 's:context.referrer': Ad - }>(disablePersistance ? [] : [new CookieStorage(getCookieOptions())]) - - const stored = storage.get('s:context.referrer') - let ad: Ad | undefined | null = ads(query) - - ad = ad ?? stored - - if (!ad) { - return - } - - if (ctx) { - ctx.referrer = { ...ctx.referrer, ...ad } - } - - storage.set('s:context.referrer', ad) -} export function normalize( analytics: Analytics, @@ -123,46 +11,9 @@ export function normalize( ): object { const user = analytics.user() - // context should always exist here (see page enrichment)? ... and why would we default to json.options? todo: delete this - json.context = json.context ?? json.options ?? {} - const ctx = json.context - - // This guard against missing ctx.page should not be neccessary, since context.page is always defined - const query: string = - typeof ctx.page?.search === 'string' ? ctx.page?.search : '' - delete json.options - json.writeKey = settings?.apiKey - - ctx.userAgent = navigator.userAgent - // @ts-ignore - const locale = navigator.userLanguage || navigator.language - - if (typeof ctx.locale === 'undefined' && typeof locale !== 'undefined') { - ctx.locale = locale - } - - if (!ctx.library) { - const type = getVersionType() - if (type === 'web') { - ctx.library = { - name: 'analytics.js', - version: `next-${version}`, - } - } else { - ctx.library = { - name: 'analytics.js', - version: `npm:next-${version}`, - } - } - } - - if (query && !ctx.campaign) { - ctx.campaign = utm(query) - } - - referrerId(query, ctx, analytics.options.disableClientPersistence ?? false) + json.writeKey = settings?.apiKey json.userId = json.userId || user.id() json.anonymousId = json.anonymousId || user.anonymousId() @@ -216,10 +67,5 @@ export function normalize( } } - const amp = ampId() - if (amp) { - ctx.amp = { id: amp } - } - return json } diff --git a/packages/core/src/events/interfaces.ts b/packages/core/src/events/interfaces.ts index c718c019d..0bc59bf5c 100644 --- a/packages/core/src/events/interfaces.ts +++ b/packages/core/src/events/interfaces.ts @@ -169,13 +169,7 @@ export interface CoreExtraContext { /** * Dictionary of information about the campaign that resulted in the API call, containing name, source, medium, term, content, and any other custom UTM parameter. */ - campaign?: { - name: string - term: string - source: string - medium: string - content: string - } + campaign?: Campaign /** * Dictionary of information about the way the user was referred to the website or app. @@ -477,3 +471,10 @@ export type UserTraits = BaseUserTraits & { * Traits are pieces of information you know about a user or group. */ export type Traits = UserTraits | GroupTraits + +export type Campaign = { + name: string + source: string + medium: string + [key: string]: string +}