-
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 2 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 |
---|---|---|
|
@@ -8,7 +8,42 @@ | |
|
||
import type { ShipperName } from '../analytics_client'; | ||
|
||
/** | ||
* Definition of the context that can be appended to the events through the {@link IAnalyticsClient.registerContextProvider}. | ||
*/ | ||
export interface EventContext { | ||
/** | ||
* The unique user ID. | ||
*/ | ||
userId?: string; | ||
/** | ||
* The user's organization ID. | ||
*/ | ||
esOrgId?: string; | ||
/** | ||
* The product's version. | ||
*/ | ||
version?: string; | ||
/** | ||
* The name of the current page. | ||
* @remarks We need to keep this for backwards compatibility because it was provided by previous implementations of FullStory. | ||
*/ | ||
pageName?: string; | ||
/** | ||
* The current page. | ||
* @remarks We need to keep this for backwards compatibility because it was provided by previous implementations of FullStory. | ||
*/ | ||
page?: string; | ||
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. Outside of the scope of this PR, but this makes me wonder: for the v3 telemetry shipper, will we have a specific field to dissociate events sent from the client and from the server? 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. That's a good question! I haven't thought about it. If we want to, we can define different |
||
/** | ||
* The current application ID. | ||
* @remarks We need to keep this for backwards compatibility because it was provided by previous implementations of FullStory. | ||
*/ | ||
app_id?: string; | ||
/** | ||
* The current entity ID. | ||
* @remarks We need to keep this for backwards compatibility because it was provided by previous implementations of FullStory. | ||
*/ | ||
ent_id?: string; | ||
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. Should we define a good set of names for these fields and add a backwards-compatible layer in the FullStory shipper? 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. I'd say so, yes. shipper-specific mappings aren't an issue, but I'd put that in the shipper implementation. 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. Updated types to full names and shipper to maintain bwc |
||
// TODO: Extend with known keys | ||
[key: string]: unknown; | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,148 @@ | ||
/* | ||
* 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'; | ||
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('appends `_date` to moment values', () => { | ||
const payload = { | ||
foo: moment(), | ||
baz: [moment()], | ||
}; | ||
|
||
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,88 @@ | ||
/* | ||
* 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 = ''; | ||
pgayvallet marked this conversation as resolved.
Show resolved
Hide resolved
|
||
switch (typeof v) { | ||
case 'string': | ||
if (moment(v, moment.ISO_8601, true).isValid()) { | ||
type = 'date'; | ||
break; | ||
} | ||
type = 'str'; | ||
break; | ||
pgayvallet marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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 || moment.isMoment(value); | ||
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. This is a bit scary to know that we may have 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. I can remove this. Technically, anything is possible. We are accepting |
||
} |
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.