diff --git a/.gitignore b/.gitignore index c7af315..591eaad 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,5 @@ www/ $RECYCLE.BIN/ .npmrc + +manifold-init-types/types/ \ No newline at end of file diff --git a/manifold-init-types/package-lock.json b/manifold-init-types/package-lock.json new file mode 100644 index 0000000..9aba54f --- /dev/null +++ b/manifold-init-types/package-lock.json @@ -0,0 +1,5 @@ +{ + "name": "@manifoldco/manifold-init-types", + "version": "0.0.1-next.26", + "lockfileVersion": 1 +} diff --git a/manifold-init-types/package.json b/manifold-init-types/package.json index e982f25..db597fd 100644 --- a/manifold-init-types/package.json +++ b/manifold-init-types/package.json @@ -1,6 +1,6 @@ { "name": "@manifoldco/manifold-init-types", - "version": "0.0.1-next.26", + "version": "0.4.0-beta.0", "private": false, "description": "TypeScript types for manifold-init", "author": "manifoldco", diff --git a/package-lock.json b/package-lock.json index 6bf271f..ea073c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { - "name": "@manifoldco/mui-core", - "version": "0.0.1-next.26", + "name": "@manifoldco/manifold-init", + "version": "0.4.0-beta.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index a475732..cada920 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "loader/", "src/" ], - "version": "0.0.1-next.26", + "version": "0.4.0-beta.0", "description": "Manifold UI Initialization", "main": "dist/index.js", "module": "dist/index.mjs", diff --git a/src/v0/__tests__/analytics.spec.ts b/src/v0/__tests__/analytics.spec.ts new file mode 100644 index 0000000..0714622 --- /dev/null +++ b/src/v0/__tests__/analytics.spec.ts @@ -0,0 +1,135 @@ +import fetchMock from 'fetch-mock'; +import createAnalytics, { AnalyticsEvent, ErrorEvent } from '../analytics'; + +const local = 'begin:http://analytics.arigato.tools'; +const stage = 'begin:https://analytics.stage.manifold.co'; +const prod = 'begin:https://analytics.manifold.co'; + +const metric: AnalyticsEvent = { + type: 'metric', + name: 'rtt_graphql', + properties: { + duration: 123, + }, +}; +const error: ErrorEvent = { + type: 'error', + name: 'mui-pricing-matrix_error', + properties: { + code: 'code', + version: '1.2.3', + message: 'message', + clientId: '123', + }, +}; +const track: AnalyticsEvent = { + type: 'component-analytics', + name: 'click', + properties: { + planId: '1234', + }, +}; + +describe('analytics', () => { + describe('env', () => { + afterEach(fetchMock.restore); + + it('local', async () => { + const analytics = createAnalytics({ + env: 'local', + element: document.createElement('manifold-product'), + componentVersion: '1.2.3', + }); + fetchMock.mock(local, {}); + await analytics.track(error); + expect(fetchMock.called(local)).toBe(true); + }); + + it('stage', async () => { + const analytics = createAnalytics({ + env: 'stage', + element: document.createElement('manifold-product'), + componentVersion: '1.2.3', + }); + fetchMock.mock(stage, {}); + await analytics.track(error); + expect(fetchMock.called(stage)).toBe(true); + }); + + it('prod', async () => { + const analytics = createAnalytics({ + env: 'prod', + element: document.createElement('manifold-product'), + componentVersion: '1.2.3', + }); + fetchMock.mock(prod, {}); + await analytics.track(error); + expect(fetchMock.called(prod)).toBe(true); + }); + }); + + describe('type', () => { + beforeEach(() => fetchMock.mock(prod, {})); + afterEach(fetchMock.restore); + + it('error', async () => { + const analytics = createAnalytics({ + env: 'prod', + element: document.createElement('manifold-product'), + componentVersion: '1.2.3', + }); + await analytics.track(error); + const res = fetchMock.calls()[0][1]; + expect(res && res.body && JSON.parse(res.body.toString())).toEqual({ + ...error, + source: 'MANIFOLD-PRODUCT', + properties: { + ...error.properties, + componentName: 'MANIFOLD-PRODUCT', + }, + }); + }); + + it('metric', async () => { + const componentVersion = '1.2.3'; + const analytics = createAnalytics({ + env: 'prod', + element: document.createElement('manifold-product'), + componentVersion, + }); + await analytics.track(metric); + const res = fetchMock.calls()[0][1]; + expect(res && res.body && JSON.parse(res.body.toString())).toEqual({ + ...metric, + source: 'MANIFOLD-PRODUCT', + properties: { + ...metric.properties, + componentName: 'MANIFOLD-PRODUCT', + version: componentVersion, + duration: '123', // numbers should submit as strings + }, + }); + }); + + it('track', async () => { + const componentVersion = '1.2.3'; + const analytics = createAnalytics({ + env: 'prod', + element: document.createElement('manifold-product'), + componentVersion, + }); + await analytics.track(track); + const res = fetchMock.calls()[0][1]; + expect(res && res.body && JSON.parse(res.body.toString())).toEqual({ + ...track, + source: 'MANIFOLD-PRODUCT', + properties: { + ...track.properties, + componentName: 'MANIFOLD-PRODUCT', + version: componentVersion, + planId: '1234', // numbers should submit as strings + }, + }); + }); + }); +}); diff --git a/src/v0/analytics.ts b/src/v0/analytics.ts new file mode 100644 index 0000000..3ecf510 --- /dev/null +++ b/src/v0/analytics.ts @@ -0,0 +1,144 @@ +/** + * Properties that should be found in every analytics event + */ +interface SharedProperties { + description?: string; +} + +/** + * Based on `name`, what data should be sent? + */ +export type EventTypes = + | { + name: 'load'; + properties: { + duration: number; + }; + } + | { + name: 'first_render'; + properties: { + duration: number; + }; + } + | { + name: 'rtt_graphql'; + properties: { + duration: number; + }; + } + | { + name: 'token_received'; + properties: { + duration: number; + }; + } + | { + name: 'first_render_with_data'; + properties: { + duration: number; + rttGraphql: number; + load: number; + }; + } + | { + name: 'click'; + properties: { + planId: string; + }; + }; + +export type EventEvent = { + type: 'metric' | 'component-analytics'; +} & SharedProperties & + EventTypes; + +/** + * Error analytics event + */ +export interface ErrorEvent extends SharedProperties { + type: 'error'; + name: string; + properties: { + code: string; + message: string; + version: string; + clientId?: string; + }; +} + +export interface ErrorDetail { + name: string; + code?: string; + message?: string; +} + +export type AnalyticsEvent = ErrorEvent | EventEvent; + +export const endpoint = { + local: 'http://analytics.arigato.tools/v1/events', + stage: 'https://analytics.stage.manifold.co/v1/events', + prod: 'https://analytics.manifold.co/v1/events', +}; + +export interface CreateAnalytics { + env: 'prod' | 'stage' | 'local'; + element: HTMLElement; + componentVersion: string; + clientId?: string; +} + +export default function createAnalytics(args: CreateAnalytics) { + function stringifyProperties(evt: AnalyticsEvent) { + return { + ...evt, + source: args.element.tagName, + properties: Object.entries(evt.properties).reduce( + (properties, [key, value]) => ({ ...properties, [key]: `${value}` }), + { + componentName: args.element.tagName, + version: args.componentVersion, + clientId: args.clientId, + } + ), + }; + } + + /** + * Report an error or analytics event to Manifold + * @param {Object} eventData Event data to send to Manifold + * @param {string} eventData.type 'event' or 'error' + * @param {string} eventData.name name_of_event (lowercase with underscores) + * @param {string} [eventData.description] User-readable description of this event + * @param {Object} eventData.properties Free-form object of event properties (different names will require different properties) + * @param {Object} [options] Analytics options + * @param {string} [options.env] 'prod' (default) or 'stage' + */ + function track(evt: AnalyticsEvent) { + const url = endpoint[args.env]; + + return fetch(url, { + method: 'POST', + body: JSON.stringify(stringifyProperties(evt)), + }); + } + + function report(detail: ErrorDetail) { + track({ + type: 'error', + name: detail.name, + properties: { + code: detail.code || '', + message: detail.message || '', + version: args.componentVersion, + clientId: args.clientId || '', + }, + }); + + console.error(detail); // report error (Rollbar, Datadog, etc.) + const evt = new CustomEvent('manifold-error', { bubbles: true, detail }); + args.element.dispatchEvent(evt); + } + + return { track, report }; +} diff --git a/src/v0/index.ts b/src/v0/index.ts index d7b44db..f19e3a4 100644 --- a/src/v0/index.ts +++ b/src/v0/index.ts @@ -1,20 +1,18 @@ +import createAnalytics, { AnalyticsEvent, ErrorDetail } from './analytics'; import { createGraphqlFetch, GraphqlFetch } from './graphqlFetch'; import { createGateway, Gateway } from './gateway'; -// TODO add real tracking -const track = data => { - // eslint-disable-next-line no-console - console.log(data); -}; - export interface Connection { graphqlFetch: GraphqlFetch; gateway: Gateway; - track: (event: string) => void; + analytics: { + track: (e: AnalyticsEvent) => Promise; + report: (detail: ErrorDetail) => void; + }; } const connection = (options: { - env: 'stage' | 'prod'; + env: 'stage' | 'prod' | 'local'; element: HTMLElement; componentVersion: string; clientId?: string; @@ -23,19 +21,33 @@ const connection = (options: { return { gateway: createGateway({ - baseUrl: () => - env === 'stage' ? 'https://api.stage.manifold.co/v1' : 'https://api.manifold.co/v1', + baseUrl: () => { + switch (env) { + case 'stage': + return 'https://api.stage.manifold.co/v1'; + case 'local': + return 'https://api.arigato.tools/v1'; + default: + return 'https://api.manifold.co/v1'; + } + }, }), graphqlFetch: createGraphqlFetch({ element, version: componentVersion, clientId, - endpoint: () => - env === 'stage' - ? 'https://api.stage.manifold.co/graphql' - : 'https://api.manifold.co/graphql', + endpoint: () => { + switch (env) { + case 'stage': + return 'https://api.stage.manifold.co/graphql'; + case 'local': + return 'https://api.arigato.tools/graphql'; + default: + return 'https://api.manifold.co/graphql'; + } + }, }), - track, + analytics: createAnalytics({ env, element, componentVersion, clientId }), }; };