diff --git a/.changeset/hungry-bears-march.md b/.changeset/hungry-bears-march.md new file mode 100644 index 000000000..6aba01153 --- /dev/null +++ b/.changeset/hungry-bears-march.md @@ -0,0 +1,5 @@ +--- +'@segment/analytics-core': patch +--- + +Improve types for context and traits and fix SegmentEvent context type (node only ATM). Refactor context eventFactory to clarify. diff --git a/packages/core/src/events/index.ts b/packages/core/src/events/index.ts index 11ef064c3..d01ee7f9f 100644 --- a/packages/core/src/events/index.ts +++ b/packages/core/src/events/index.ts @@ -4,12 +4,14 @@ import { ID, User } from '../user' import { Integrations, EventProperties, - Traits, + CoreAnalyticsTraits, CoreSegmentEvent, CoreOptions, + CoreExtraContext, } from './interfaces' import { pickBy } from '../utils/pick' import { validateEvent } from '../validation/assertions' +import type { RemoveIndexSignature } from '../utils/ts-helpers' interface EventFactorySettings { createMessageId: () => string @@ -101,7 +103,7 @@ export class EventFactory { identify( userId: ID, - traits?: Traits, + traits?: CoreAnalyticsTraits, options?: CoreOptions, globalIntegrations?: Integrations ): CoreSegmentEvent { @@ -117,7 +119,7 @@ export class EventFactory { group( groupId: ID, - traits?: Traits, + traits?: CoreAnalyticsTraits, options?: CoreOptions, globalIntegrations?: Integrations ): CoreSegmentEvent { @@ -186,30 +188,42 @@ export class EventFactory { * Builds the context part of an event based on "foreign" keys that * are provided in the `Options` parameter for an Event */ - private context(event: CoreSegmentEvent): [object, object] { - const options = event.options ?? {} - delete options['integrations'] + private context( + options: CoreOptions + ): [CoreExtraContext, Partial] { + type CoreOptionKeys = keyof RemoveIndexSignature + /** + * If the event options are known keys from this list, we move them to the top level of the event. + * Any other options are moved to context. + */ + const eventOverrideKeys: CoreOptionKeys[] = [ + 'userId', + 'anonymousId', + 'timestamp', + ] - const providedOptionsKeys = Object.keys(options) + delete options['integrations'] + const providedOptionsKeys = Object.keys(options) as Exclude< + CoreOptionKeys, + 'integrations' + >[] - const context = event.options?.context ?? {} - const overrides = {} + const context = options.context ?? {} + const eventOverrides = {} providedOptionsKeys.forEach((key) => { if (key === 'context') { return } - if ( - ['integrations', 'anonymousId', 'timestamp', 'userId'].includes(key) - ) { - dset(overrides, key, options[key]) + if (eventOverrideKeys.includes(key)) { + dset(eventOverrides, key, options[key]) } else { dset(context, key, options[key]) } }) - return [context, overrides] + return [context, eventOverrides] } public normalize(event: CoreSegmentEvent): CoreSegmentEvent { @@ -240,14 +254,17 @@ export class EventFactory { ...event.options?.integrations, } - const [context, overrides] = this.context(event) + const [context, overrides] = event.options + ? this.context(event.options) + : [] + const { options, ...rest } = event const body = { timestamp: new Date(), ...rest, - context, integrations: allIntegrations, + context, ...overrides, } diff --git a/packages/core/src/events/interfaces.ts b/packages/core/src/events/interfaces.ts index e2af6e7e9..0d8f0c7a1 100644 --- a/packages/core/src/events/interfaces.ts +++ b/packages/core/src/events/interfaces.ts @@ -16,8 +16,6 @@ export type JSONValue = JSONPrimitive | JSONObject | JSONArray export type JSONObject = { [member: string]: JSONValue } export type JSONArray = JSONValue[] -export type Traits = Record - export type EventProperties = Record export type Integrations = { @@ -28,64 +26,126 @@ export type Integrations = { // renamed export interface CoreOptions { integrations?: Integrations - timestamp?: Date | string - context?: CoreAnalyticsContext + timestamp?: Timestamp + context?: CoreExtraContext anonymousId?: string userId?: string - traits?: Traits + traits?: CoreAnalyticsTraits // ugh, this is ugly, but we allow literally any property to be passed to options (which get spread onto the event) // we may want to remove this... [key: string]: any } -interface CoreAnalyticsContext { +/** + * Context is a dictionary of extra information that provides useful context about a datapoint, for example the user’s ip address or locale. You should only use Context fields for their intended meaning. + * @link https://segment.com/docs/connections/spec/common/#context + */ +export interface CoreExtraContext { + /** + * This is usually used to flag an .identify() call to just update the trait, rather than "last seen". + */ + active?: boolean + + /** + * Current user's IP address. + */ + ip?: string + + /** + * Locale string for the current user, for example en-US. + * @example en-US + */ + locale?: string + /** + * Dictionary of information about the user’s current location. + */ + location?: { + /** + * @example San Francisco + */ + city?: string + /** + * @example United States + */ + country?: string + /** + * @example 40.2964197 + */ + latitude?: string + /** + * @example -76.9411617 + */ + longitude?: string + /** + * @example CA + */ + region?: string + /** + * @example 100 + */ + speed?: number + } + + /** + * Dictionary of information about the current web page. + */ page?: { /** - * {@link https://github.com/segmentio/analytics.js-integrations/blob/2d5c637c022d2661c23449aed237d0d546bf062d/integrations/segmentio/lib/index.js#L151} + * @example /academy/ */ path?: string + /** + * @example https://www.foo.com/ + */ referrer?: string + /** + * @example projectId=123 + */ search?: string + /** + * @example Analytics Academy + */ title?: string + /** + * @example https://segment.com/academy/ + */ url?: string } /** - * {@link https://github.com/segmentio/analytics.js-integrations/blob/2d5c637c022d2661c23449aed237d0d546bf062d/integrations/segmentio/lib/index.js#L285} + * User agent of the device making the request. */ userAgent?: string /** - * {@link https://github.com/segmentio/analytics.js-integrations/blob/2d5c637c022d2661c23449aed237d0d546bf062d/integrations/segmentio/lib/index.js#L286-L289} - */ - locale?: string - - /** - * {@link https://github.com/segmentio/analytics.js-integrations/blob/2d5c637c022d2661c23449aed237d0d546bf062d/integrations/segmentio/lib/index.js#L290-L291} + * Information about the current library. + * + * **Automatically filled out by the library.** + * + * This type should probably be "never" */ library?: { + /** + * @example analytics-node-next/latest + */ name: string + /** + * @example "1.43.1" + */ version: string } /** - * {@link https://github.com/segmentio/analytics.js-integrations/blob/2d5c637c022d2661c23449aed237d0d546bf062d/integrations/segmentio/lib/index.js#L292-L301} + * This is useful in cases where you need to track an event, + * but also associate information from a previous identify call. + * You should fill this object the same way you would fill traits in an identify call. */ - traits?: { - crossDomainId?: string - } + traits?: CoreAnalyticsTraits /** - * utm params - * {@link https://github.com/segmentio/analytics.js-integrations/blob/2d5c637c022d2661c23449aed237d0d546bf062d/integrations/segmentio/lib/index.js#L303-L305} - * {@link https://github.com/segmentio/utm-params/blob/master/lib/index.js#L49} + * 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?: { - /** - * This can also come from the "utm_campaign" param - * - * {@link https://github.com/segmentio/utm-params/blob/master/lib/index.js#L40} - */ name: string term: string source: string @@ -94,21 +154,23 @@ interface CoreAnalyticsContext { } /** - * {@link https://github.com/segmentio/analytics.js-integrations/blob/2d5c637c022d2661c23449aed237d0d546bf062d/integrations/segmentio/lib/index.js#L415} + * Dictionary of information about the way the user was referred to the website or app. */ referrer?: { - btid?: string - urid?: string + type?: string + name?: string + url?: string + link?: string + + btid?: string // undocumented? + urid?: string // undocumented? } - /** - * {@link https://github.com/segmentio/analytics.js-integrations/blob/2d5c637c022d2661c23449aed237d0d546bf062d/integrations/segmentio/lib/index.js#L322} - */ amp?: { + // undocumented? id: string } - // eslint-disable-next-line @typescript-eslint/no-explicit-any [key: string]: any } @@ -122,10 +184,10 @@ export interface CoreSegmentEvent { properties?: EventProperties - traits?: Traits // Traits is only defined in 'identify' and 'group', even if it can be passed in other calls. + traits?: CoreAnalyticsTraits // Traits is only defined in 'identify' and 'group', even if it can be passed in other calls. integrations?: Integrations - context?: CoreAnalyticsContext | CoreOptions + context?: CoreExtraContext options?: CoreOptions userId?: ID @@ -141,7 +203,7 @@ export interface CoreSegmentEvent { _metadata?: SegmentEventMetadata - timestamp?: Date | string + timestamp?: Timestamp } export interface SegmentEventMetadata { @@ -154,7 +216,7 @@ export interface SegmentEventMetadata { bundledIds?: string[] } -export type SegmentEventTimestamp = Date | string +export type Timestamp = Date | string export interface Plan { track?: TrackPlan @@ -179,34 +241,127 @@ interface PlanEvent { } } -export interface ReservedTraits { - address: Partial<{ - city: string - country: string - postalCode: string - state: string - street: string - }> - age: number - avatar: string - birthday: Date - company: Partial<{ - name: string - id: string | number - industry: string - employee_count: number - }> - plan: string - createdAt: Date - description: string - email: string - firstName: string - gender: string - id: string - lastName: string - name: string - phone: string - title: string - username: string - website: string +/** + * Traits are pieces of information you know about a user. + * This interface represents reserved traits that Segment has standardized. + * @link https://segment.com/docs/connections/spec/identify/#traits + * @link https://segment.com/docs/connections/spec/group/#traits + */ +export interface CoreAnalyticsTraits { + /** + * Unique ID in your database for a user/group. + */ + id?: string + + /** + * Industry a user works in, or a group is part of. + */ + industry?: string + + /** + * First name of a user. + */ + firstName?: string + + /** + * Last name of a user. + */ + lastName?: string + + /** + * Full name of a user/group. If you only pass a first and last name Segment automatically fills in the full name for you. + */ + name?: string + + /** + * Phone number of a user/group. + */ + phone?: string + + /** + * Title of a user, usually related to their position at a specific company. + * @example VP of Engineering + */ + title?: string + + /** + * User’s username. This should be unique to each user, like the usernames of Twitter or GitHub. + */ + username?: string + + /** + * Website of a user/group. + */ + website?: string + + /** + * Street address of a user/group. + */ + address?: { + city?: string + country?: string + postalCode?: string + state?: string + street?: string + } + + /** + * Age of a user. + */ + age?: number + + /** + * URL to an avatar image for the user/group. + */ + avatar?: string + + /** + * User's birthday. + */ + birthday?: Timestamp + + /** + * User's company. + */ + company?: { + name?: string + id?: string | number + industry?: CoreAnalyticsTraits['industry'] + employee_count?: CoreAnalyticsTraits['employees'] + plan?: CoreAnalyticsTraits['plan'] + } + + /** + * Number of employees of a group, typically used for companies. + */ + employees?: number + + /** + Plan that a user/group is in. + + * @example enterprise + */ + plan?: string + + /** + * Date the user/group's account was first created. Segment recommends using ISO-8601 date strings. + */ + createdAt?: Timestamp + + /** + * Description of user/group, such as bio. + */ + description?: string + + /** + * Email address of a user/group. + */ + email?: string + + /** + * @example female + */ + gender?: string + + [customTrait: string]: any } diff --git a/packages/core/src/utils/ts-helpers.ts b/packages/core/src/utils/ts-helpers.ts new file mode 100644 index 000000000..e13cb8280 --- /dev/null +++ b/packages/core/src/utils/ts-helpers.ts @@ -0,0 +1,6 @@ +/** + * Remove Index Signature + */ +export type RemoveIndexSignature = { + [K in keyof T as {} extends Record ? never : K]: T[K] +} diff --git a/packages/node/src/app/analytics-node.ts b/packages/node/src/app/analytics-node.ts index d76efc027..eb9283348 100644 --- a/packages/node/src/app/analytics-node.ts +++ b/packages/node/src/app/analytics-node.ts @@ -1,6 +1,5 @@ import { EventProperties, - Traits, CoreAnalytics, CoreContext, CorePlugin, @@ -11,6 +10,8 @@ import { PriorityQueue, pTimeout, Integrations, + CoreExtraContext, + CoreAnalyticsTraits, } from '@segment/analytics-core' import { AnalyticsSettings, validateSettings } from './settings' import { version } from '../../package.json' @@ -36,7 +37,17 @@ type IdentityOptions = * A dictionary of extra context to attach to the call. * Note: context differs from traits because it is not attributes of the user itself. */ -type AdditionalContext = Record +export interface ExtraContext extends CoreExtraContext {} + +/** + * Traits are pieces of information you know about a user that are included in an identify call. These could be demographics like age or gender, account-specific like plan, or even things like whether a user has seen a particular A/B test variation. Up to you! + * Segment has reserved some traits that have semantic meanings for users, and we handle them in special ways. For example, Segment always expects email to be a string of the user’s email address. + * + * We’ll send this on to destinations like Mailchimp that require an email address for their tracking. + * + * You should only use reserved traits for their intended meaning. + */ +export interface Traits extends CoreAnalyticsTraits {} class NodePriorityQueue extends PriorityQueue { constructor() { @@ -161,7 +172,7 @@ export class Analytics extends NodeEmitter implements CoreAnalytics { userId: string /* The previous id that the user was recognized by (this can be either a userId or an anonymousId). */ previousId: string - context?: AdditionalContext + context?: ExtraContext timestamp?: Timestamp integrations?: Integrations }, @@ -191,7 +202,7 @@ export class Analytics extends NodeEmitter implements CoreAnalytics { }: IdentityOptions & { groupId: string traits?: Traits - context?: AdditionalContext + context?: ExtraContext timestamp?: Timestamp integrations?: Integrations }, @@ -221,7 +232,7 @@ export class Analytics extends NodeEmitter implements CoreAnalytics { integrations, }: IdentityOptions & { traits?: Traits - context?: AdditionalContext + context?: ExtraContext integrations?: Integrations }, callback?: Callback @@ -257,7 +268,7 @@ export class Analytics extends NodeEmitter implements CoreAnalytics { /* A dictionary of properties of the page. */ properties?: EventProperties timestamp?: Timestamp - context?: AdditionalContext + context?: ExtraContext integrations?: Integrations }, callback?: Callback @@ -316,7 +327,7 @@ export class Analytics extends NodeEmitter implements CoreAnalytics { }: IdentityOptions & { event: string properties?: EventProperties - context?: AdditionalContext + context?: ExtraContext timestamp?: Timestamp integrations?: Integrations }, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 6cc6f30d4..64abd94f6 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -1,4 +1,10 @@ -export { Analytics, Context, Plugin } from './app/analytics-node' +export { + Analytics, + Context, + Plugin, + ExtraContext, + Traits, +} from './app/analytics-node' export type { AnalyticsSettings } from './app/settings' // export Analytics as both a named export and a default export (for backwards-compat. reasons)