From ea3ee2e5a3b99639acc32b5be714b515dbd783d8 Mon Sep 17 00:00:00 2001 From: Ethan Marsh Date: Fri, 28 Feb 2020 11:34:19 -0800 Subject: [PATCH] add api version, language, and ua to data --- .size-snapshot.json | 20 +++--- integration/fixtures/config.js | 2 +- integration/fixtures/streamsql.min.js | 2 +- integration/index.umd.test.js | 13 +++- package.json | 6 +- src/Page.ts | 22 ++++++- src/StreamSQL.ts | 16 ++++- src/config.ts | 3 +- src/test/page.incompat.test.ts | 5 ++ src/test/page.test.ts | 12 ++++ src/test/streamsql.test.ts | 89 ++++++++++++++------------- 11 files changed, 126 insertions(+), 64 deletions(-) diff --git a/.size-snapshot.json b/.size-snapshot.json index 6f6aa6a..679b06a 100644 --- a/.size-snapshot.json +++ b/.size-snapshot.json @@ -1,26 +1,26 @@ { "dist/streamsql.esm.js": { - "bundled": 8092, - "minified": 3922, - "gzipped": 1610, + "bundled": 8662, + "minified": 4273, + "gzipped": 1705, "treeshaked": { "rollup": { "code": 75, "import_statements": 0 }, "webpack": { - "code": 4536 + "code": 4866 } } }, "dist/streamsql.cjs.js": { - "bundled": 9528, - "minified": 4442, - "gzipped": 1682 + "bundled": 10213, + "minified": 4843, + "gzipped": 1782 }, "dist/streamsql.min.js": { - "bundled": 10419, - "minified": 4269, - "gzipped": 1653 + "bundled": 11154, + "minified": 4647, + "gzipped": 1748 } } diff --git a/integration/fixtures/config.js b/integration/fixtures/config.js index 6ab337b..47765d6 100644 --- a/integration/fixtures/config.js +++ b/integration/fixtures/config.js @@ -1,4 +1,4 @@ -module.exports.apiEndpoint = '/api/send/single' +module.exports.apiEndpoint = '/api/send/pixel' module.exports.apiEndpointBatch = '/api/send/batch' module.exports.port = 9000 module.exports.httpsPort = 9001 diff --git a/integration/fixtures/streamsql.min.js b/integration/fixtures/streamsql.min.js index f99583e..e0d506a 100644 --- a/integration/fixtures/streamsql.min.js +++ b/integration/fixtures/streamsql.min.js @@ -1 +1 @@ -!function(t){"function"==typeof define&&define.amd?define("streamsql",t):t()}((function(){"use strict";var t,e=function(t){return new Error("[StreamSQL] Error: "+t)},i=function(t){return t&&t.length?t[0].toUpperCase()+t.slice(1).toLowerCase():t},n=function(){function t(t,e){this.url=t,this.apiKey=e,this.xhr=void 0,this.xhr=this.buildRequest(this.url,this.apiKey)}var i=t.prototype;return i.send=function(t,i){var n=this;return this.xhr=this.buildRequest(this.url,this.apiKey),new Promise((function(r,s){n.xhr.onreadystatechange=function(){if(this.readyState===this.DONE)if(i&&i(this.status,this.responseText),this.status>=200&&this.status<400)try{r(this.response)}catch(t){s(t)}else if(this.status)try{s(this.response)}catch(t){s(t)}else s(e("something went wrong sending XMLHttpRequest"))};try{n.xhr.send(JSON.stringify(t))}catch(t){s(t)}}))},i.buildRequest=function(t,e){var i=new XMLHttpRequest;return i.open("POST",t,!0),i.setRequestHeader("Content-Type","application/json; charset=UTF-8"),i.setRequestHeader("x-streamsql-key",e),i.withCredentials=!0,i.timeout=3e3,i},t}();!function(t){t.None="None",t.Lax="Lax",t.Strict="Strict"}(t||(t={}));var r=function(){function e(){this.simpleCookies=new Map}var n=e.prototype;return n.setCookie=function(e){if(!e.maxAge&&!e.expires){var i=new Date;i.setFullYear(i.getFullYear()+1),e.expires=i.toUTCString()}e.secure=!0,e.sameSite=t.None,this.simpleCookies.set(e.name,e.value),document.cookie=this.toCookieString(e)},n.getCookie=function(t){if(this.simpleCookies.has(t))return this.simpleCookies.get(t);var e="";try{for(var i=t+"=",n=document.cookie.split(";"),r=0;r=200&&this.status<400)try{r(this.response)}catch(t){s(t)}else if(this.status)try{s(this.response)}catch(t){s(t)}else s(e("something went wrong sending XMLHttpRequest"))};try{n.xhr.send(JSON.stringify(t))}catch(t){s(t)}}))},i.buildRequest=function(t,e){var i=new XMLHttpRequest;return i.open("POST",t,!0),i.setRequestHeader("Content-Type","application/json; charset=UTF-8"),i.setRequestHeader("x-streamsql-key",e),i.withCredentials=!0,i.timeout=3e3,i},t}();!function(t){t.None="None",t.Lax="Lax",t.Strict="Strict"}(t||(t={}));var r=function(){function e(){this.simpleCookies=new Map}var n=e.prototype;return n.setCookie=function(e){if(!e.maxAge&&!e.expires){var i=new Date;i.setFullYear(i.getFullYear()+1),e.expires=i.toUTCString()}e.secure=!0,e.sameSite=t.None,this.simpleCookies.set(e.name,e.value),document.cookie=this.toCookieString(e)},n.getCookie=function(t){if(this.simpleCookies.has(t))return this.simpleCookies.get(t);var e="";try{for(var i=t+"=",n=document.cookie.split(";"),r=0;r { expect(isResponseOK).toBe(true) }) - it('sends streamname and timestamp', async () => { + it('sends streamname, timestamp, and apiVersion', async () => { let postData await page.goto(home) page.on('request', req => { @@ -32,13 +32,16 @@ describe('integration', () => { } expect(postData.stream).toMatch('clickstream') expect(postData.data.timestamp).toBeGreaterThan(15e9) + expect(postData.data.apiVersion).toMatch(/v[0-9]+/) }, 10000) - it('sends page context with url, title, and referrer', async () => { + it('sends page context with url, title, ref, lang, ua', async () => { let postData page.on('request', req => { if (req.method() === 'POST') postData = req.postData() }) + const ua = "Iceweasel/001" + await page.setUserAgent(ua) await page.goto(home) await page.click('button#count-button') postData = postData && JSON.parse(postData) @@ -46,6 +49,8 @@ describe('integration', () => { url: await page.url(), title: await page.title(), referrer: '', + userAgent: ua, + language: expect.stringMatching('en'), }) // visit the other page await Promise.all([page.waitForNavigation(), page.click('a')]) @@ -55,6 +60,8 @@ describe('integration', () => { url: await page.url(), title: await page.title(), referrer: expect.stringMatching(SERVER_URL), + userAgent: ua, + language: expect.stringMatching('en'), }) }, 15000) @@ -80,7 +87,7 @@ describe('integration', () => { for (let i = 1; i <= 5; i += 1) { await page.click('button#count-button') postData = postData && JSON.parse(postData) - expect(postData.data.count).toEqual(i) + expect(postData.data.event.count).toEqual(i) } }, 12000) diff --git a/package.json b/package.json index 58c78da..ead1394 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,9 @@ "name": "@streamsql/streamsql-js", "author": "StreamSQL ", "version": "1.0.0-beta.0", + "config": { + "apiVersion": "v1-beta" + }, "license": "MIT", "description": "StreamSQL's javascript ingestion API", "main": "streamsql.cjs.js", @@ -13,7 +16,7 @@ "prebuild": "rimraf dist", "rollup": "rollup -c", "rollup:test": "rollup --config rollup-test.config.js", - "copy": "copyfiles -f package.json readme.md LICENSE streamsql.d.ts dist && json -I -f dist/package.json -e \"this.private=false; this.devDependencies=undefined; this.optionalDependencies=undefined; this.scripts=undefined; this.prettier=undefined; this.jest=undefined; this.babel=undefined; this.np=undefined; this.publishConfig=undefined;\"", + "copy": "copyfiles -f package.json readme.md LICENSE streamsql.d.ts dist && json -I -f dist/package.json -e \"this.private=false; this.devDependencies=undefined; this.optionalDependencies=undefined; this.scripts=undefined; this.prettier=undefined; this.jest=undefined; this.babel=undefined; this.config=undefined; this.np=undefined; this.publishConfig=undefined;\"", "build": "npm-run-all --parallel rollup copy", "prepare": "npm run build", "serve:test": "cross-env NODE_ENV=test node integration/fixtures/server.js", @@ -28,6 +31,7 @@ "format": "npm run prettier -- --write", "check-format": "npm run prettier --list-different" }, + "devDependencies": { "@babel/core": "^7.8.4", "@babel/plugin-proposal-class-properties": "^7.8.3", diff --git a/src/Page.ts b/src/Page.ts index 66ea6f0..3f676f0 100644 --- a/src/Page.ts +++ b/src/Page.ts @@ -1,7 +1,9 @@ export interface PageContextProvider { - location(): string referrer(): string title(): string + language(): string + userAgent(): string + location(): string // url } export default class PageContext implements PageContextProvider { @@ -26,6 +28,20 @@ export default class PageContext implements PageContextProvider { return '' } + public language(): string { + if (this.isNavigator()) { + return navigator.language || '' + } + return '' + } + + public userAgent(): string { + if (this.isNavigator()) { + return navigator.userAgent || '' + } + return '' + } + protected isWindow(prop: string): boolean { return ( typeof window !== 'undefined' @@ -36,4 +52,8 @@ export default class PageContext implements PageContextProvider { protected isDocument(): boolean { return this.isWindow('document') && typeof document !== 'undefined' } + + protected isNavigator(): boolean { + return this.isWindow('navigator') && typeof navigator !== 'undefined' + } } diff --git a/src/StreamSQL.ts b/src/StreamSQL.ts index 118bd3f..85cc800 100644 --- a/src/StreamSQL.ts +++ b/src/StreamSQL.ts @@ -2,7 +2,7 @@ import XHR, { Fetcher, RequestCallback } from './XHR' import UserIdentifier, { Identifier } from './Identify' import PageContext, { PageContextProvider } from './Page' import { streamsqlErr, isValidAPIKeyFormat } from './utils' -import { apiEndpoint } from './config' +import { apiEndpoint, apiVersion } from './config' export interface CoreAPI { init(apiKey: string): CoreAPI @@ -61,17 +61,22 @@ export default class StreamSQLClient implements CoreAPI { } const streamsqlData = { timestamp: new Date().getTime(), + apiVersion: this.version(), context: { - url: this.pageCtx.location(), + language: this.pageCtx.language(), referrer: this.pageCtx.referrer(), title: this.pageCtx.title(), + userAgent: this.pageCtx.userAgent(), + url: this.pageCtx.location(), }, user: { id: this.identifier.getUser(), // FUTURE: ability to add other user props }, } - const _data = data ? Object.assign({}, streamsqlData, data) : streamsqlData + const _data = data + ? Object.assign({}, streamsqlData, { event: data }) + : streamsqlData return { stream: streamName.toLowerCase(), data: _data @@ -82,6 +87,11 @@ export default class StreamSQLClient implements CoreAPI { throw streamsqlErr(`api key must be set first: streamsql.init(apiKey)`) } + // For pixel + private version(): string { + return apiVersion + } + // Template functions. Originally built to allow pixel to listen for events. // @ts-ignore public onIdentify(userId: string) {} diff --git a/src/config.ts b/src/config.ts index 6edeef3..8190edf 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1 +1,2 @@ -export const apiEndpoint = `__APIHOST__` + `/api/send/single` +export const apiEndpoint = `__APIHOST__` + `/api/send/pixel` +export const apiVersion = `v1-beta` // FIXME: hardcoded diff --git a/src/test/page.incompat.test.ts b/src/test/page.incompat.test.ts index 7e92340..59ad5f9 100644 --- a/src/test/page.incompat.test.ts +++ b/src/test/page.incompat.test.ts @@ -15,4 +15,9 @@ describe('Page Context - No Browser', () => { expect(new PageContext().referrer()).toBe('') expect(new PageContext().title()).toBe('') }) + + it('returns empty strings if navigator is not available', () => { + expect(new PageContext().language()).toBe('') + expect(new PageContext().userAgent()).toBe('') + }) }) \ No newline at end of file diff --git a/src/test/page.test.ts b/src/test/page.test.ts index bc68a9a..92cdf1a 100644 --- a/src/test/page.test.ts +++ b/src/test/page.test.ts @@ -22,4 +22,16 @@ describe('Page Context', () => { expect(pageCtx.title()).toBe('Home Page') window.document.title = '' }) + + it('returns the user agent', () => { + const ua = "Mozilla/5.0" // beginning of jsDom default + pageCtx = new PageContext() + expect(pageCtx.userAgent()).toMatch(ua) + }) + + it('returns the user agent', () => { + const lang = 'en-US' // jsDom default + pageCtx = new PageContext() + expect(pageCtx.language()).toMatch(lang) + }) }) diff --git a/src/test/streamsql.test.ts b/src/test/streamsql.test.ts index aed9d15..546cd7e 100644 --- a/src/test/streamsql.test.ts +++ b/src/test/streamsql.test.ts @@ -37,30 +37,35 @@ describe('StreamSQL Core', () => { expect(streamsql.init(apiKey)).toBeInstanceOf(StreamSQL) }) - it('sends default fields streamName and timestamp', () => { - const streamName = 'mystream' + it('sends normalized stream, numeric timestamp, and apiVersion', () => { + const streamName = 'MyStream' streamsql.init(apiKey) - xhrMock.post(apiEndpoint, (req, res) => { - expect(req.body()).toMatchObject({ - streamName, - eventTimestamp: expect.any(Number), - }) - return res.status(200) - }) + // @ts-ignore + const dataBuilder = jest.spyOn(streamsql, 'buildData') streamsql.sendEvent(streamName) + expect(dataBuilder).toHaveReturnedWith(expect.objectContaining({ + stream: expect.stringMatching(streamName.toLowerCase()), + data: expect.objectContaining({ + timestamp: expect.any(Number), + apiVersion: expect.stringMatching(process.env.npm_package_config_apiVersion!) + }) + })) }) it('sends the set user id field after identifying', () => { const userId = 'user-123' streamsql.init(apiKey) streamsql.identify(userId) - xhrMock.post(apiEndpoint, (req, res) => { - expect(req.body()).toMatchObject({ - user: { id: userId }, - }) - return res.status(200) - }) + // @ts-ignore + const dataBuilder = jest.spyOn(streamsql, 'buildData') streamsql.sendEvent('mystream') + expect(dataBuilder).toHaveReturnedWith(expect.objectContaining({ + data: expect.objectContaining({ + user: expect.objectContaining({ + id: userId + }) + }) + })) }) it('does not send a user id after unidentifying', () => { @@ -68,57 +73,55 @@ describe('StreamSQL Core', () => { streamsql.init(apiKey) streamsql.identify(userId) streamsql.unidentify() - xhrMock.post(apiEndpoint, (req, res) => { - expect(req.body()).toMatchObject({ - user: { id: '' } - }) - return res.status(200) - }) streamsql.sendEvent('mystream') + // @ts-ignore + const dataBuilder = jest.spyOn(streamsql, 'buildData') + streamsql.sendEvent('mystream') + expect(dataBuilder).toHaveReturnedWith(expect.objectContaining({ + data: expect.objectContaining({ + user: expect.objectContaining({ + id: "" + }) + }) + })) }) it('includes page context with requests', () => { streamsql.init(apiKey) - xhrMock.post(apiEndpoint, (req, res) => { - expect(req.body()).toMatchObject({ + // @ts-ignore + const dataBuilder = jest.spyOn(streamsql, 'buildData') + streamsql.sendEvent('mystream') + expect(dataBuilder).toHaveReturnedWith(expect.objectContaining({ + data: expect.objectContaining({ context: expect.objectContaining({ url: expect.any(String), - location: expect.any(String), + title: expect.any(String), referrer: expect.any(String), + language: expect.any(String), + userAgent: expect.any(String), }) }) - return res.status(200) - }) - streamsql.sendEvent('mystream') + })) }) it('includes the users custom data sending events', () => { const customData = { x: 0, y: [1,2,3], z: `z` } streamsql.init(apiKey) - xhrMock.post(apiEndpoint, (req, res) => { - expect(req.body()).toMatchObject({ - data: customData, - }) - return res.status(200) - }) + // @ts-ignore + const dataBuilder = jest.spyOn(streamsql, 'buildData') streamsql.sendEvent('mystream', customData) - }) - - it('sends x-streamsql-key with clients apiKey request headers', () => { - streamsql.init(apiKey) - xhrMock.post(apiEndpoint, (req, res) => { - expect(req.headers()).toMatchObject({ - 'x-streamsql-key': apiKey, + expect(dataBuilder).toHaveReturnedWith(expect.objectContaining({ + data: expect.objectContaining({ + event: customData, }) - return res.status(200) - }) - streamsql.sendEvent('mystream', {}) + })) }) it('calls the onSent callback after sending xhr', () => { const onSent = jest.fn() streamsql.init(apiKey) xhrMock.post(apiEndpoint, (_, res) => { + // FIXME: not called expect(onSent).toHaveBeenCalledTimes(1) return res.status(200) })