From 433914b00737dc6dd845cd1a0a1d3c8b3b6144dc Mon Sep 17 00:00:00 2001 From: Sylvain Utard Date: Mon, 29 Dec 2025 16:38:11 +0100 Subject: [PATCH 1/2] [Altertable] - New Destination Added destination for sending track, page, screen & identify to https://altertable.ai --- .../__snapshots__/snapshot.test.ts.snap | 49 ++++++ .../altertable/__tests__/snapshot.test.ts | 77 +++++++++ .../__snapshots__/snapshot.test.ts.snap | 14 ++ .../altertable/event/__tests__/index.test.ts | 161 +++++++++++++++++ .../event/__tests__/snapshot.test.ts | 42 +++++ .../altertable/event/generated-types.ts | 36 ++++ .../destinations/altertable/event/index.ts | 119 +++++++++++++ .../altertable/generated-types.ts | 16 ++ .../__snapshots__/snapshot.test.ts.snap | 13 ++ .../identify/__tests__/index.test.ts | 162 ++++++++++++++++++ .../identify/__tests__/snapshot.test.ts | 42 +++++ .../altertable/identify/generated-types.ts | 28 +++ .../destinations/altertable/identify/index.ts | 79 +++++++++ .../src/destinations/altertable/index.ts | 90 ++++++++++ .../src/destinations/altertable/utils.ts | 80 +++++++++ 15 files changed, 1008 insertions(+) create mode 100644 packages/destination-actions/src/destinations/altertable/__tests__/__snapshots__/snapshot.test.ts.snap create mode 100644 packages/destination-actions/src/destinations/altertable/__tests__/snapshot.test.ts create mode 100644 packages/destination-actions/src/destinations/altertable/event/__tests__/__snapshots__/snapshot.test.ts.snap create mode 100644 packages/destination-actions/src/destinations/altertable/event/__tests__/index.test.ts create mode 100644 packages/destination-actions/src/destinations/altertable/event/__tests__/snapshot.test.ts create mode 100644 packages/destination-actions/src/destinations/altertable/event/generated-types.ts create mode 100644 packages/destination-actions/src/destinations/altertable/event/index.ts create mode 100644 packages/destination-actions/src/destinations/altertable/generated-types.ts create mode 100644 packages/destination-actions/src/destinations/altertable/identify/__tests__/__snapshots__/snapshot.test.ts.snap create mode 100644 packages/destination-actions/src/destinations/altertable/identify/__tests__/index.test.ts create mode 100644 packages/destination-actions/src/destinations/altertable/identify/__tests__/snapshot.test.ts create mode 100644 packages/destination-actions/src/destinations/altertable/identify/generated-types.ts create mode 100644 packages/destination-actions/src/destinations/altertable/identify/index.ts create mode 100644 packages/destination-actions/src/destinations/altertable/index.ts create mode 100644 packages/destination-actions/src/destinations/altertable/utils.ts diff --git a/packages/destination-actions/src/destinations/altertable/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/altertable/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..ab01bdc0ff --- /dev/null +++ b/packages/destination-actions/src/destinations/altertable/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,49 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for actions-altertable destination: event action - all fields 1`] = ` +Object { + "distinct_id": "UM[e12E", + "environment": "UM[e12E", + "event": "UM[e12E", + "properties": Object { + "$lib": "altertable-segment", + "testType": "UM[e12E", + }, + "timestamp": "2021-02-01T00:00:00.000Z", +} +`; + +exports[`Testing snapshot for actions-altertable destination: event action - required fields 1`] = ` +Object { + "distinct_id": "UM[e12E", + "environment": "UM[e12E", + "event": "UM[e12E", + "properties": Object { + "testType": "UM[e12E", + }, + "timestamp": "2021-02-01T00:00:00.000Z", +} +`; + +exports[`Testing snapshot for actions-altertable destination: identify action - all fields 1`] = ` +Object { + "distinct_id": "&03lz0I0LT8Hl)", + "environment": "&03lz0I0LT8Hl)", + "timestamp": "2021-02-01T00:00:00.000Z", + "traits": Object { + "$lib": "altertable-segment", + "testType": "&03lz0I0LT8Hl)", + }, +} +`; + +exports[`Testing snapshot for actions-altertable destination: identify action - required fields 1`] = ` +Object { + "distinct_id": "&03lz0I0LT8Hl)", + "environment": "&03lz0I0LT8Hl)", + "timestamp": "2021-02-01T00:00:00.000Z", + "traits": Object { + "testType": "&03lz0I0LT8Hl)", + }, +} +`; diff --git a/packages/destination-actions/src/destinations/altertable/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/altertable/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..7f41919970 --- /dev/null +++ b/packages/destination-actions/src/destinations/altertable/__tests__/snapshot.test.ts @@ -0,0 +1,77 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../lib/test-data' +import destination from '../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const destinationSlug = 'actions-altertable' + +describe(`Testing snapshot for ${destinationSlug} destination:`, () => { + for (const actionSlug in destination.actions) { + it(`${actionSlug} action - required fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it(`${actionSlug} action - all fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) + } +}) diff --git a/packages/destination-actions/src/destinations/altertable/event/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/altertable/event/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..ea1f22c4f2 --- /dev/null +++ b/packages/destination-actions/src/destinations/altertable/event/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,14 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Altertable's event destination action: all fields 1`] = ` +Object { + "distinct_id": "CHXbRcxq]U", + "environment": "CHXbRcxq]U", + "event": "CHXbRcxq]U", + "properties": Object { + "$lib": "altertable-segment", + "testType": "CHXbRcxq]U", + }, + "timestamp": "2021-02-01T00:00:00.000Z", +} +`; diff --git a/packages/destination-actions/src/destinations/altertable/event/__tests__/index.test.ts b/packages/destination-actions/src/destinations/altertable/event/__tests__/index.test.ts new file mode 100644 index 0000000000..40eacaa723 --- /dev/null +++ b/packages/destination-actions/src/destinations/altertable/event/__tests__/index.test.ts @@ -0,0 +1,161 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration, SegmentEvent } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +describe('Altertable.event', () => { + const endpoint = 'https://api.altertable.ai' + const apiKey = 'test-api-key' + const environment = 'test-environment' + + beforeEach(() => nock.cleanAll()) + + it('should send event to Altertable', async () => { + const event = createTestEvent({ + properties: { + testProperty: 'test-value' + }, + context: { + device: { + id: 'test-device-id' + } + } + }) + + nock(endpoint).post('/track').reply(200, {}) + + const responses = await testDestination.testAction('event', { + event, + useDefaultMappings: true, + settings: { + apiKey: apiKey, + endpoint: endpoint, + environment: environment + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + const payload = JSON.parse(responses[0].options.body as string) + expect(payload).toMatchObject({ + environment: environment, + properties: expect.objectContaining({ + testProperty: 'test-value' + }), + distinct_id: event.userId, + anonymous_id: event.anonymousId, + timestamp: event.timestamp, + device_id: event.context?.device?.id + }) + }) + + it('should throw error if required fields are missing', async () => { + const event = createTestEvent({ + properties: { + testProperty: 'test-value' + } + }) + + await expect( + testDestination.testAction('event', { + event, + mapping: { + // Only provide properties, missing required event, userId, and timestamp + properties: { + '@path': '$.properties' + } + }, + settings: { + apiKey: apiKey, + endpoint: endpoint, + environment: environment + } + }) + ).rejects.toThrow() + }) + + it('should correctly map Segment context properties to Altertable format', async () => { + const event: SegmentEvent = { + type: 'track', + properties: { + customProp: 'custom-value' + }, + context: { + ip: '192.168.1.1', + page: { + url: 'https://example.com/page', + referrer: 'https://google.com' + }, + os: { + name: 'iOS' + }, + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0)', + screen: { + width: 375, + height: 667 + }, + campaign: { + name: 'summer-sale', + source: 'google', + medium: 'cpc', + term: 'shoes', + content: 'ad-variant-a' + }, + library: { + name: 'analytics.js', + version: '3.0.0' + }, + device: { + id: 'device-123' + } + } + } + + nock(endpoint).post('/track').reply(200, {}) + + const responses = await testDestination.testAction('event', { + event, + useDefaultMappings: true, + settings: { + apiKey: apiKey, + endpoint: endpoint, + environment: environment + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + const payload = JSON.parse(responses[0].options.body as string) + + // Verify context mappings + expect(payload.properties).toMatchObject({ + // Direct context mappings + $ip: '192.168.1.1', + $url: 'https://example.com/page', + $referer: 'https://google.com', + $os: 'iOS', + $user_agent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0)', + + // Screen dimensions -> viewport + $viewport: '375x667', + + // Campaign mappings + $utm_campaign: 'summer-sale', + $utm_source: 'google', + $utm_medium: 'cpc', + $utm_term: 'shoes', + $utm_content: 'ad-variant-a', + + // Library mappings + $lib: 'analytics.js', + $lib_version: '3.0.0', + + // Event properties should still be present + customProp: 'custom-value' + }) + + // Verify device_id is extracted correctly + expect(payload.device_id).toBe('device-123') + }) +}) diff --git a/packages/destination-actions/src/destinations/altertable/event/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/altertable/event/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..7649ac3180 --- /dev/null +++ b/packages/destination-actions/src/destinations/altertable/event/__tests__/snapshot.test.ts @@ -0,0 +1,42 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'event' +const destinationSlug = 'Altertable' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/altertable/event/generated-types.ts b/packages/destination-actions/src/destinations/altertable/event/generated-types.ts new file mode 100644 index 0000000000..73a14f6ae4 --- /dev/null +++ b/packages/destination-actions/src/destinations/altertable/event/generated-types.ts @@ -0,0 +1,36 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * For regular analytics events, use `track`. For page views, use `page`. For mobile screens, use `screen`. + */ + eventType: string + /** + * The name of the event to track + */ + event: string + /** + * The ID of the user + */ + userId: string + /** + * The anonymous ID of the user + */ + anonymousId: string + /** + * The context properties to send with the event + */ + context: { + [k: string]: unknown + } + /** + * The properties of the event + */ + properties: { + [k: string]: unknown + } + /** + * The timestamp of the event + */ + timestamp: string | number +} diff --git a/packages/destination-actions/src/destinations/altertable/event/index.ts b/packages/destination-actions/src/destinations/altertable/event/index.ts new file mode 100644 index 0000000000..3fddc0e6fa --- /dev/null +++ b/packages/destination-actions/src/destinations/altertable/event/index.ts @@ -0,0 +1,119 @@ +import type { ActionDefinition, RequestClient } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { getNestedValue, parseContext } from '../utils' + +const action: ActionDefinition = { + title: 'Track Event', + description: 'Track a single event in Altertable.', + defaultSubscription: 'type = "track"', + fields: { + eventType: { + label: 'Event Type', + description: + 'For regular analytics events, use `track`. For page views, use `page`. For mobile screens, use `screen`.', + type: 'string', + choices: [ + { label: 'track', value: 'track' }, + { label: 'page', value: 'page' }, + { label: 'screen', value: 'screen' } + ], + required: true, + default: 'track', + unsafe_hidden: true + }, + event: { + label: 'Event Name', + description: 'The name of the event to track', + type: 'string', + default: { + '@path': '$.event' + }, + required: true + }, + userId: { + label: 'User ID', + description: 'The ID of the user', + type: 'string', + default: { + '@path': '$.userId' + }, + required: true + }, + anonymousId: { + label: 'Anonymous ID', + description: 'The anonymous ID of the user', + type: 'string', + default: { + '@path': '$.anonymousId' + }, + required: false + }, + context: { + label: 'Context', + description: 'The context properties to send with the event', + type: 'object', + default: { + '@path': '$.context' + }, + required: false + }, + properties: { + label: 'Properties', + description: 'The properties of the event', + type: 'object', + required: true, + default: { + '@path': '$.properties' + } + }, + timestamp: { + label: 'Timestamp', + description: 'The timestamp of the event', + type: 'datetime', + default: { + '@path': '$.timestamp' + }, + required: true + } + }, + perform: (request, { settings, payload }) => { + return send(request, settings, payload) + } +} + +function send(request: RequestClient, settings: Settings, payload: Payload) { + const contextProps = parseContext(payload.context) + + const distinctId = payload.userId || payload.anonymousId + const anonymousId = payload.userId && payload.userId !== payload.anonymousId ? payload.anonymousId : undefined + + let event + if (payload.eventType === 'page') { + event = '$pageview' + } else if (payload.eventType === 'screen') { + event = '$screenview' + } else { + event = payload.event + } + + const body = { + environment: settings.environment, + event, + properties: { + ...contextProps, + ...payload.properties + }, + timestamp: payload.timestamp, + distinct_id: distinctId, + anonymous_id: anonymousId, + device_id: getNestedValue(payload.context, 'device.id') + } + + return request(`${settings.endpoint}/track`, { + method: 'post', + json: body + }) +} + +export default action diff --git a/packages/destination-actions/src/destinations/altertable/generated-types.ts b/packages/destination-actions/src/destinations/altertable/generated-types.ts new file mode 100644 index 0000000000..076348627d --- /dev/null +++ b/packages/destination-actions/src/destinations/altertable/generated-types.ts @@ -0,0 +1,16 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Your Altertable API key + */ + apiKey: string + /** + * The environment to send events to + */ + environment: string + /** + * The endpoint to send events to + */ + endpoint: string +} diff --git a/packages/destination-actions/src/destinations/altertable/identify/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/altertable/identify/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..dabae3b32d --- /dev/null +++ b/packages/destination-actions/src/destinations/altertable/identify/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Altertable's identify destination action: all fields 1`] = ` +Object { + "distinct_id": "p*Ezt^", + "environment": "p*Ezt^", + "timestamp": "2021-02-01T00:00:00.000Z", + "traits": Object { + "$lib": "altertable-segment", + "testType": "p*Ezt^", + }, +} +`; diff --git a/packages/destination-actions/src/destinations/altertable/identify/__tests__/index.test.ts b/packages/destination-actions/src/destinations/altertable/identify/__tests__/index.test.ts new file mode 100644 index 0000000000..bf0e0d0095 --- /dev/null +++ b/packages/destination-actions/src/destinations/altertable/identify/__tests__/index.test.ts @@ -0,0 +1,162 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration, SegmentEvent } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +describe('Altertable.identify', () => { + const endpoint = 'https://api.altertable.ai' + const apiKey = 'test-api-key' + const environment = 'test-environment' + + beforeEach(() => nock.cleanAll()) + + it('should send identify event to Altertable', async () => { + const event = createTestEvent({ + userId: 'test-user-id', + traits: { + name: 'Test User', + email: 'test@example.com', + plan: 'premium' + }, + timestamp: '2024-01-01T00:00:00.000Z' + }) + + nock(endpoint).post('/identify').reply(200, {}) + + const responses = await testDestination.testAction('identify', { + event, + useDefaultMappings: true, + settings: { + apiKey: apiKey, + endpoint: endpoint, + environment: environment + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + const payload = JSON.parse(responses[0].options.body as string) + expect(payload).toMatchObject({ + environment: environment, + distinct_id: event.userId, + traits: expect.objectContaining(event.traits), + timestamp: event.timestamp + }) + }) + + it('should throw error if required fields are missing', async () => { + const event = createTestEvent({ + traits: { + name: 'Test User' + } + }) + + await expect( + testDestination.testAction('identify', { + event, + mapping: { + // Only provide traits, missing required userId and timestamp + traits: { + '@path': '$.traits' + } + }, + settings: { + apiKey: apiKey, + endpoint: endpoint, + environment: environment + } + }) + ).rejects.toThrow() + }) + + it('should correctly map Segment context properties to Altertable format', async () => { + const event: SegmentEvent = { + type: 'identify', + userId: 'test-user-id', + traits: { + name: 'Test User', + email: 'test@example.com', + customTrait: 'custom-value' + }, + context: { + ip: '192.168.1.1', + page: { + url: 'https://example.com/page', + referrer: 'https://google.com' + }, + os: { + name: 'iOS' + }, + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0)', + screen: { + width: 375, + height: 667 + }, + campaign: { + name: 'summer-sale', + source: 'google', + medium: 'cpc', + term: 'shoes', + content: 'ad-variant-a' + }, + library: { + name: 'analytics.js', + version: '3.0.0' + }, + device: { + id: 'device-123' + } + } + } + + nock(endpoint).post('/identify').reply(200, {}) + + const responses = await testDestination.testAction('identify', { + event, + useDefaultMappings: true, + settings: { + apiKey: apiKey, + endpoint: endpoint, + environment: environment + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + const payload = JSON.parse(responses[0].options.body as string) + + // Verify context mappings are merged into traits + expect(payload.traits).toMatchObject({ + // Direct context mappings + $ip: '192.168.1.1', + $url: 'https://example.com/page', + $referer: 'https://google.com', + $os: 'iOS', + $user_agent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0)', + + // Screen dimensions -> viewport + $viewport: '375x667', + + // Campaign mappings + $utm_campaign: 'summer-sale', + $utm_source: 'google', + $utm_medium: 'cpc', + $utm_term: 'shoes', + $utm_content: 'ad-variant-a', + + // Library mappings + $lib: 'analytics.js', + $lib_version: '3.0.0', + + // User traits should still be present + name: 'Test User', + email: 'test@example.com', + customTrait: 'custom-value' + }) + + // Verify device_id is extracted correctly + expect(payload.device_id).toBe('device-123') + expect(payload.distinct_id).toBe('test-user-id') + }) +}) diff --git a/packages/destination-actions/src/destinations/altertable/identify/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/altertable/identify/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..e31c84b7c4 --- /dev/null +++ b/packages/destination-actions/src/destinations/altertable/identify/__tests__/snapshot.test.ts @@ -0,0 +1,42 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'identify' +const destinationSlug = 'Altertable' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/altertable/identify/generated-types.ts b/packages/destination-actions/src/destinations/altertable/identify/generated-types.ts new file mode 100644 index 0000000000..d23cbf2b22 --- /dev/null +++ b/packages/destination-actions/src/destinations/altertable/identify/generated-types.ts @@ -0,0 +1,28 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The ID of the user + */ + userId: string + /** + * The anonymous ID of the user + */ + anonymousId: string + /** + * The context properties to send with the identify + */ + context: { + [k: string]: unknown + } + /** + * The traits of the user + */ + traits: { + [k: string]: unknown + } + /** + * The timestamp of the identification + */ + timestamp: string | number +} diff --git a/packages/destination-actions/src/destinations/altertable/identify/index.ts b/packages/destination-actions/src/destinations/altertable/identify/index.ts new file mode 100644 index 0000000000..5e98747ba9 --- /dev/null +++ b/packages/destination-actions/src/destinations/altertable/identify/index.ts @@ -0,0 +1,79 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { getNestedValue, parseContext } from '../utils' + +const action: ActionDefinition = { + title: 'Identify', + description: 'Identify a user in Altertable.', + fields: { + userId: { + label: 'User ID', + description: 'The ID of the user', + type: 'string', + default: { + '@path': '$.userId' + }, + required: true + }, + anonymousId: { + label: 'Anonymous ID', + description: 'The anonymous ID of the user', + type: 'string', + default: { + '@path': '$.anonymousId' + }, + required: false + }, + context: { + label: 'Context', + description: 'The context properties to send with the identify', + type: 'object', + default: { + '@path': '$.context' + }, + required: false + }, + traits: { + label: 'Traits', + description: 'The traits of the user', + type: 'object', + default: { + '@path': '$.traits' + }, + required: true + }, + timestamp: { + label: 'Timestamp', + description: 'The timestamp of the identification', + type: 'datetime', + default: { + '@path': '$.timestamp' + }, + required: true + } + }, + perform: (request, { payload, settings }) => { + const distinctId = payload.userId || payload.anonymousId + const anonymousId = payload.userId && payload.userId !== payload.anonymousId ? payload.anonymousId : undefined + + const body = { + environment: settings.environment, + traits: { + ...parseContext(payload.context), + ...payload.traits + }, + timestamp: payload.timestamp, + distinct_id: distinctId, + anonymous_id: anonymousId, + device_id: getNestedValue(payload.context, 'device.id') + } + + return request(`${settings.endpoint}/identify`, { + method: 'post', + json: body + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/altertable/index.ts b/packages/destination-actions/src/destinations/altertable/index.ts new file mode 100644 index 0000000000..64cf6f9cc9 --- /dev/null +++ b/packages/destination-actions/src/destinations/altertable/index.ts @@ -0,0 +1,90 @@ +import { DestinationDefinition, defaultValues } from '@segment/actions-core' +import type { Settings } from './generated-types' +import event from './event' +import identify from './identify' + +const destination: DestinationDefinition = { + name: 'Altertable', + slug: 'actions-altertable', + mode: 'cloud', + description: + 'Send events server-side to the [Altertable REST API](https://altertable.ai/docs/product-analytics/reference/api).', + authentication: { + scheme: 'custom', + fields: { + apiKey: { + label: 'API Key', + description: 'Your Altertable API key', + type: 'password', + required: true + }, + environment: { + label: 'Environment', + description: 'The environment to send events to', + type: 'string', + required: true, + default: 'production' + }, + endpoint: { + label: 'Endpoint', + description: 'The endpoint to send events to', + type: 'string', + format: 'uri', + required: true, + default: 'https://api.altertable.ai' + } + } + }, + extendRequest: ({ settings }) => { + return { + headers: { + 'X-API-Key': settings.apiKey, + 'Content-Type': 'application/json' + } + } + }, + actions: { + event, + identify + }, + presets: [ + { + name: 'Track', + subscribe: 'type = "track"', + partnerAction: 'event', + mapping: defaultValues(event.fields), + type: 'automatic' + }, + { + name: 'Page View', + subscribe: 'type = "page"', + partnerAction: 'event', + mapping: { + ...defaultValues(event.fields), + event_type: 'page', + event_name: '$pageview' + }, + type: 'automatic' + }, + { + name: 'Screen View', + subscribe: 'type = "screen"', + partnerAction: 'event', + mapping: { + ...defaultValues(event.fields), + event_type: 'screen', + event_name: '$screenview' + }, + type: 'automatic' + }, + { + name: 'Identify', + subscribe: 'type = "identify"', + partnerAction: 'identify', + mapping: defaultValues(identify.fields), + type: 'automatic' + } + ] +} + +export default destination diff --git a/packages/destination-actions/src/destinations/altertable/utils.ts b/packages/destination-actions/src/destinations/altertable/utils.ts new file mode 100644 index 0000000000..437ce97a3c --- /dev/null +++ b/packages/destination-actions/src/destinations/altertable/utils.ts @@ -0,0 +1,80 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/** + * Context mapping from Segment to Altertable format + */ +const contextMapping: Record = { + ip: '$ip', + 'page.url': '$url', + 'page.referrer': '$referer', + 'os.name': '$os', + userAgent: '$user_agent' +} + +/** + * Helper to get nested property value + */ +export function getNestedValue(obj: any, path: string): any { + return path + .split('.') + .reduce( + (current, key) => (typeof current === 'object' && current !== null && key in current ? current[key] : undefined), + obj + ) +} + +/** + * Parse and transform Segment context to Altertable properties + */ +export function parseContext(context?: object): Record { + if (!context) { + return {} + } + + const result: Record = {} + + // Special handling for campaign data + if ('campaign' in context && context.campaign !== null && typeof context.campaign === 'object') { + Object.entries(context.campaign).forEach(([key, value]) => { + const utmKey = ((k) => { + switch (k) { + case 'name': + case 'campaign': + return '$utm_campaign' + case 'source': + case 'term': + case 'content': + case 'medium': + return `$utm_${k}` + default: + return `utm_${k}` + } + })(key) + result[utmKey] = value + }) + } + + // Map known context properties to Altertable properties + Object.entries(contextMapping).forEach(([segmentPath, altertableProp]) => { + const value = getNestedValue(context, segmentPath) + if (value !== undefined) { + result[altertableProp] = value + } + }) + + // Special handling for screen size + const screenWidth = getNestedValue(context, 'screen.width') + const screenHeight = getNestedValue(context, 'screen.height') + if (screenWidth && screenHeight) { + result['$viewport'] = `${screenWidth}x${screenHeight}` + } + + // Special handling for library data + result['$lib'] = getNestedValue(context, 'library.name') || 'altertable-segment' + const libraryVersion = getNestedValue(context, 'library.version') + if (libraryVersion) { + result['$lib_version'] = libraryVersion + } + + return result +} From 7bc938c1c40397004dc9f3d8f091e3c79a59852f Mon Sep 17 00:00:00 2001 From: Sylvain Utard Date: Sat, 3 Jan 2026 17:02:16 +0100 Subject: [PATCH 2/2] Add expected types --- .../src/destinations/altertable/event/index.ts | 12 +++++++++++- .../src/destinations/altertable/identify/index.ts | 11 ++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/destination-actions/src/destinations/altertable/event/index.ts b/packages/destination-actions/src/destinations/altertable/event/index.ts index 3fddc0e6fa..eeadc4f3df 100644 --- a/packages/destination-actions/src/destinations/altertable/event/index.ts +++ b/packages/destination-actions/src/destinations/altertable/event/index.ts @@ -3,6 +3,16 @@ import type { Settings } from '../generated-types' import type { Payload } from './generated-types' import { getNestedValue, parseContext } from '../utils' +type EventRequestPayload = { + environment: string + event: string + properties: Record + timestamp: string | number + distinct_id: string | null | undefined + anonymous_id: string | null | undefined + device_id: string | null | undefined +} + const action: ActionDefinition = { title: 'Track Event', description: 'Track a single event in Altertable.', @@ -97,7 +107,7 @@ function send(request: RequestClient, settings: Settings, payload: Payload) { event = payload.event } - const body = { + const body: EventRequestPayload = { environment: settings.environment, event, properties: { diff --git a/packages/destination-actions/src/destinations/altertable/identify/index.ts b/packages/destination-actions/src/destinations/altertable/identify/index.ts index 5e98747ba9..d38546233c 100644 --- a/packages/destination-actions/src/destinations/altertable/identify/index.ts +++ b/packages/destination-actions/src/destinations/altertable/identify/index.ts @@ -3,6 +3,15 @@ import type { Settings } from '../generated-types' import type { Payload } from './generated-types' import { getNestedValue, parseContext } from '../utils' +type IdentifyRequestPayload = { + environment: string + traits: Record + timestamp: string | number + distinct_id: string | null | undefined + anonymous_id: string | null | undefined + device_id: string | null | undefined +} + const action: ActionDefinition = { title: 'Identify', description: 'Identify a user in Altertable.', @@ -57,7 +66,7 @@ const action: ActionDefinition = { const distinctId = payload.userId || payload.anonymousId const anonymousId = payload.userId && payload.userId !== payload.anonymousId ? payload.anonymousId : undefined - const body = { + const body: IdentifyRequestPayload = { environment: settings.environment, traits: { ...parseContext(payload.context),