-
Notifications
You must be signed in to change notification settings - Fork 8.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[EBT] Add Shipper "FullStory" #129927
[EBT] Add Shipper "FullStory" #129927
Changes from 8 commits
9202cbe
76786bb
8b22fb7
92d4f92
aeedc22
34288a7
22999ec
81f9fc8
4d6229e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,135 @@ | ||
/* | ||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
* or more contributor license agreements. Licensed under the Elastic License | ||
* 2.0 and the Server Side Public License, v 1; you may not use this file except | ||
* in compliance with, at your election, the Elastic License 2.0 or the Server | ||
* Side Public License, v 1. | ||
*/ | ||
|
||
import { formatPayload } from './format_payload'; | ||
|
||
describe('formatPayload', () => { | ||
test('appends `_str` to string values', () => { | ||
const payload = { | ||
foo: 'bar', | ||
baz: ['qux'], | ||
}; | ||
|
||
expect(formatPayload(payload)).toEqual({ | ||
foo_str: payload.foo, | ||
baz_strs: payload.baz, | ||
}); | ||
}); | ||
|
||
test('appends `_int` to integer values', () => { | ||
const payload = { | ||
foo: 1, | ||
baz: [100000], | ||
}; | ||
|
||
expect(formatPayload(payload)).toEqual({ | ||
foo_int: payload.foo, | ||
baz_ints: payload.baz, | ||
}); | ||
}); | ||
|
||
test('appends `_real` to integer values', () => { | ||
const payload = { | ||
foo: 1.5, | ||
baz: [100000.5], | ||
}; | ||
|
||
expect(formatPayload(payload)).toEqual({ | ||
foo_real: payload.foo, | ||
baz_reals: payload.baz, | ||
}); | ||
}); | ||
|
||
test('appends `_bool` to booleans values', () => { | ||
const payload = { | ||
foo: true, | ||
baz: [false], | ||
}; | ||
|
||
expect(formatPayload(payload)).toEqual({ | ||
foo_bool: payload.foo, | ||
baz_bools: payload.baz, | ||
}); | ||
}); | ||
|
||
test('appends `_date` to Date values', () => { | ||
const payload = { | ||
foo: new Date(), | ||
baz: [new Date()], | ||
}; | ||
|
||
expect(formatPayload(payload)).toEqual({ | ||
foo_date: payload.foo, | ||
baz_dates: payload.baz, | ||
}); | ||
}); | ||
|
||
test('supports nested values', () => { | ||
const payload = { | ||
nested: { | ||
foo: 'bar', | ||
baz: ['qux'], | ||
}, | ||
}; | ||
|
||
expect(formatPayload(payload)).toEqual({ | ||
nested: { | ||
foo_str: payload.nested.foo, | ||
baz_strs: payload.nested.baz, | ||
}, | ||
}); | ||
}); | ||
|
||
test('does not mutate reserved keys', () => { | ||
const payload = { | ||
uid: 'uid', | ||
displayName: 'displayName', | ||
email: 'email', | ||
acctId: 'acctId', | ||
website: 'website', | ||
pageName: 'pageName', | ||
}; | ||
|
||
expect(formatPayload(payload)).toEqual(payload); | ||
}); | ||
|
||
test('removes undefined values', () => { | ||
const payload = { | ||
foo: undefined, | ||
baz: [undefined], | ||
}; | ||
|
||
expect(formatPayload(payload)).toEqual({}); | ||
}); | ||
|
||
describe('String to Date identification', () => { | ||
test('appends `_date` to ISO string values', () => { | ||
const payload = { | ||
foo: new Date().toISOString(), | ||
baz: [new Date().toISOString()], | ||
}; | ||
|
||
expect(formatPayload(payload)).toEqual({ | ||
foo_date: payload.foo, | ||
baz_dates: payload.baz, | ||
}); | ||
}); | ||
|
||
test('appends `_str` to random string values', () => { | ||
const payload = { | ||
foo: 'test-1', | ||
baz: ['test-1'], | ||
}; | ||
|
||
expect(formatPayload(payload)).toEqual({ | ||
foo_str: payload.foo, | ||
baz_strs: payload.baz, | ||
}); | ||
}); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,84 @@ | ||
/* | ||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
* or more contributor license agreements. Licensed under the Elastic License | ||
* 2.0 and the Server Side Public License, v 1; you may not use this file except | ||
* in compliance with, at your election, the Elastic License 2.0 or the Server | ||
* Side Public License, v 1. | ||
*/ | ||
|
||
import moment from 'moment'; | ||
|
||
// https://help.fullstory.com/hc/en-us/articles/360020623234#reserved-properties | ||
const FULLSTORY_RESERVED_PROPERTIES = [ | ||
'uid', | ||
'displayName', | ||
'email', | ||
'acctId', | ||
'website', | ||
// https://developer.fullstory.com/page-variables | ||
'pageName', | ||
]; | ||
|
||
export function formatPayload(context: Record<string, unknown>): Record<string, unknown> { | ||
// format context keys as required for env vars, see docs: https://help.fullstory.com/hc/en-us/articles/360020623234 | ||
return Object.fromEntries( | ||
Object.entries(context) | ||
// Discard any undefined values | ||
.map<[string, unknown]>(([key, value]) => { | ||
return Array.isArray(value) | ||
? [key, value.filter((v) => typeof v !== 'undefined')] | ||
: [key, value]; | ||
}) | ||
.filter( | ||
([, value]) => typeof value !== 'undefined' && (!Array.isArray(value) || value.length > 0) | ||
) | ||
// Transform key names according to the FullStory needs | ||
.map(([key, value]) => { | ||
if (FULLSTORY_RESERVED_PROPERTIES.includes(key)) { | ||
return [key, value]; | ||
} | ||
if (isRecord(value)) { | ||
return [key, formatPayload(value)]; | ||
} | ||
const valueType = getFullStoryType(value); | ||
const formattedKey = valueType ? `${key}_${valueType}` : key; | ||
return [formattedKey, value]; | ||
}) | ||
); | ||
} | ||
|
||
function getFullStoryType(value: unknown) { | ||
// For arrays, make the decision based on the first element | ||
const isArray = Array.isArray(value); | ||
const v = isArray ? value[0] : value; | ||
let type: string; | ||
switch (typeof v) { | ||
case 'string': | ||
type = moment(v, moment.ISO_8601, true).isValid() ? 'date' : 'str'; | ||
break; | ||
case 'number': | ||
type = Number.isInteger(v) ? 'int' : 'real'; | ||
break; | ||
case 'boolean': | ||
type = 'bool'; | ||
break; | ||
case 'object': | ||
if (isDate(v)) { | ||
type = 'date'; | ||
break; | ||
} | ||
default: | ||
throw new Error(`Unsupported type: ${typeof v}`); | ||
} | ||
|
||
// convert to plural form for arrays | ||
return isArray ? `${type}s` : type; | ||
} | ||
|
||
function isRecord(value: unknown): value is Record<string, unknown> { | ||
return typeof value === 'object' && value !== null && !Array.isArray(value) && !isDate(value); | ||
} | ||
Comment on lines
+78
to
+80
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. NIT: could use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same as #129927 (comment): I wanted to avoid having another dependency in the package. If you think it's worth adding it, I don't mind adding it. :) |
||
|
||
function isDate(value: unknown): value is Date { | ||
return value instanceof Date; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
/* | ||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one | ||
* or more contributor license agreements. Licensed under the Elastic License | ||
* 2.0 and the Server Side Public License, v 1; you may not use this file except | ||
* in compliance with, at your election, the Elastic License 2.0 or the Server | ||
* Side Public License, v 1. | ||
*/ | ||
|
||
import type { FullStoryApi } from './types'; | ||
|
||
export const fullStoryApiMock: jest.Mocked<FullStoryApi> = { | ||
identify: jest.fn(), | ||
setUserVars: jest.fn(), | ||
setVars: jest.fn(), | ||
consent: jest.fn(), | ||
restart: jest.fn(), | ||
shutdown: jest.fn(), | ||
event: jest.fn(), | ||
}; | ||
jest.doMock('./load_snippet', () => { | ||
return { | ||
loadSnippet: () => fullStoryApiMock, | ||
}; | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1 for removing moment from the package and using plain old unix timestamps or Date objects
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we need
moment
to validate the ISO string. We could replace it with a regex, but I worry we might leave out some formats depending on the TZ.