diff --git a/packages/destination-actions/src/destinations/kochava/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/kochava/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..ad245caf8f --- /dev/null +++ b/packages/destination-actions/src/destinations/kochava/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,95 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for actions-kochava destination: event action - all fields 1`] = ` +Object { + "action": "event", + "data": Object { + "app_tracking_transparency": Object { + "att": false, + "att_detail": "c)30knD9jX&cI", + "att_duration": 1955441609277.44, + "att_time": 1955441609277.44, + }, + "app_version": "c)30knD9jX&cI", + "currency": "AMD", + "device_ids": Object { + "adid": "c)30knD9jX&cI", + "android_id": "c)30knD9jX&cI", + "idfa": "c)30knD9jX&cI", + "idfv": "c)30knD9jX&cI", + }, + "device_limit_tracking": true, + "device_ua": "c)30knD9jX%26cI", + "device_ver": "c)30knD9jX&cI", + "event_data": Object { + "testType": "c)30knD9jX&cI", + }, + "event_name": "c)30knD9jX&cI", + "origination_ip": "c)30knD9jX&cI", + "usertime": 1612137600, + }, + "kochava_app_id": "c)30knD9jX&cI", + "kochava_device_id": "c)30knD9jX&cI", +} +`; + +exports[`Testing snapshot for actions-kochava destination: event action - required fields 1`] = ` +Object { + "action": "event", + "data": Object { + "device_ids": Object { + "adid": "c)30knD9jX&cI", + "android_id": "c)30knD9jX&cI", + "idfa": "c)30knD9jX&cI", + "idfv": "c)30knD9jX&cI", + }, + "event_name": "c)30knD9jX&cI", + }, + "kochava_app_id": "c)30knD9jX&cI", + "kochava_device_id": "c)30knD9jX&cI", +} +`; + +exports[`Testing snapshot for actions-kochava destination: install action - all fields 1`] = ` +Object { + "action": "install", + "data": Object { + "ad_services_token": "2m*qGXENfE", + "app_tracking_transparency": Object { + "att": true, + "att_detail": "2m*qGXENfE", + "att_duration": -32716306922864.64, + "att_time": -32716306922864.64, + }, + "app_version": "2m*qGXENfE", + "device_ids": Object { + "adid": "2m*qGXENfE", + "android_id": "2m*qGXENfE", + "idfa": "2m*qGXENfE", + "idfv": "2m*qGXENfE", + }, + "device_ua": "2m*qGXENfE", + "device_ver": "2m*qGXENfE", + "origination_ip": "2m*qGXENfE", + "usertime": 1612137600, + }, + "kochava_app_id": "2m*qGXENfE", + "kochava_device_id": "2m*qGXENfE", +} +`; + +exports[`Testing snapshot for actions-kochava destination: install action - required fields 1`] = ` +Object { + "action": "install", + "data": Object { + "device_ids": Object { + "adid": "2m*qGXENfE", + "android_id": "2m*qGXENfE", + "idfa": "2m*qGXENfE", + "idfv": "2m*qGXENfE", + }, + }, + "kochava_app_id": "2m*qGXENfE", + "kochava_device_id": "2m*qGXENfE", +} +`; diff --git a/packages/destination-actions/src/destinations/kochava/__tests__/e2e.test.ts b/packages/destination-actions/src/destinations/kochava/__tests__/e2e.test.ts new file mode 100644 index 0000000000..bd667a5222 --- /dev/null +++ b/packages/destination-actions/src/destinations/kochava/__tests__/e2e.test.ts @@ -0,0 +1,244 @@ +/** + * E2E tests for Kochava (Actions) + * + * These tests make real HTTP calls to a running local serve server + * (./bin/run serve), which in turn makes real outbound calls to Kochava's + * https://control.kochava.com/track/json endpoint. They are NOT run in CI. + * + * Prerequisites: + * 1. Start the serve server (in a separate terminal): + * ./bin/run serve kochava --noUI + * + * 2. Set environment variables (or create a .env file): + * export BASE_URL=http://localhost:3000 + * export KOCHAVA_APP_ID= + * + * 3. Run the tests: + * yarn cloud jest --testPathPattern="kochava/__tests__/e2e" + */ + +import http from 'http' + +// Jest's node test environment does not expose undici's global `fetch`, and +// serve listens on IPv4 only, so use the built-in http module against 127.0.0.1. +const BASE_URL = process.env.BASE_URL ?? 'http://127.0.0.1:3000' +const KOCHAVA_APP_ID = process.env.KOCHAVA_APP_ID ?? 'e2e-dummy-app-guid' + +jest.setTimeout(30000) + +function request( + method: 'GET' | 'POST', + path: string, + body?: Record +): Promise<{ status: number; body: any }> { + return new Promise((resolve, reject) => { + const url = new URL(`${BASE_URL}${path}`) + const payload = body === undefined ? undefined : JSON.stringify(body) + const req = http.request( + { + hostname: url.hostname, + port: url.port, + path: url.pathname, + method, + headers: + payload === undefined + ? undefined + : { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } + }, + (res) => { + let data = '' + res.on('data', (chunk) => (data += chunk)) + res.on('end', () => { + let parsed: unknown = null + try { + parsed = JSON.parse(data) + } catch { + parsed = data + } + resolve({ status: res.statusCode ?? 0, body: parsed }) + }) + } + ) + req.on('error', reject) + if (payload !== undefined) req.write(payload) + req.end() + }) +} + +async function post(path: string, body: Record) { + return request('POST', path, body) +} + +async function get(path: string) { + return request('GET', path) +} + +const settings = { kochava_app_id: KOCHAVA_APP_ID } + +describe('Kochava (Actions) E2E', () => { + beforeAll(async () => { + try { + await get('/manifest') + } catch { + throw new Error('Serve server is not running. Start it with:\n ./bin/run serve kochava --noUI') + } + }) + + describe('Authentication', () => { + // No testAuthentication is defined; the framework default validates the + // settings schema. A valid required App GUID should authenticate. + it('returns ok:true for valid settings', async () => { + const res = await post('/authenticate', { kochava_app_id: KOCHAVA_APP_ID }) + expect(res.status).toBe(200) + expect(res.body.ok).toBe(true) + }) + }) + + describe('event (Post-Install Event)', () => { + it('delivers a post-install event via perform()', async () => { + const res = await post('/event', { + settings, + payload: { + type: 'track', + event: 'Subscription Started', + messageId: 'msg-e2e-event-1', + timestamp: '2021-03-20T18:06:56.000Z', + properties: { currency: 'USD', sum: 150 }, + context: { + device: { id: 'device-e2e-1', advertisingId: 'idfa-e2e-1', adTrackingEnabled: true }, + os: { version: '14.4' }, + app: { version: '1.0.0' }, + userAgent: 'Mozilla/5.0 (iPhone)', + ip: '104.219.46.66' + } + }, + mapping: { + event_name: { '@path': '$.event' }, + idfa: { '@path': '$.context.device.advertisingId' }, + device_ua: { '@path': '$.context.userAgent' }, + device_ver: { '@path': '$.context.os.version' }, + origination_ip: { '@path': '$.context.ip' }, + usertime: { '@path': '$.timestamp' } + } + }) + expect(res.status).toBe(200) + expect(Array.isArray(res.body)).toBe(true) + }) + + it('honors kochava_app_id override in the mapping', async () => { + const res = await post('/event', { + settings, + payload: { type: 'track', event: 'Purchase', messageId: 'msg-e2e-event-2' }, + mapping: { + kochava_app_id: 'override-guid-e2e', + event_name: 'Purchase', + idfa: 'idfa-e2e-2', + device_ver: '15.0' + } + }) + expect(res.status).toBe(200) + expect(Array.isArray(res.body)).toBe(true) + }) + + it('returns a validation error when no device identifier is provided', async () => { + const res = await post('/event', { + settings, + payload: { type: 'track', event: 'Purchase', messageId: 'msg-e2e-event-3' }, + mapping: { + event_name: 'Purchase', + device_ver: '15.0' + // no idfa / idfv / adid / android_id + } + }) + expect(res.status).toBe(200) + expect(res.body[0].message).toContain('device identifier') + }) + + it('returns a validation error when event_name (required) is missing', async () => { + const res = await post('/event', { + settings, + payload: { type: 'track', messageId: 'msg-e2e-event-4' }, + mapping: { + idfa: 'idfa-e2e-4', + device_ver: '15.0' + } + }) + expect(res.status).toBe(200) + expect(res.body[0].message).toMatch(/event_name|required/i) + }) + }) + + describe('install (Install Notification)', () => { + it('delivers an install notification via perform()', async () => { + const res = await post('/install', { + settings, + payload: { + type: 'track', + event: 'Application Installed', + messageId: 'msg-e2e-install-1', + timestamp: '2021-03-20T18:06:56.000Z', + context: { + device: { id: 'device-e2e-1', advertisingId: 'idfa-e2e-1' }, + os: { version: '15.3' }, + app: { version: '3.3.0' }, + userAgent: 'Mozilla/5.0 (iPhone)', + ip: '77.224.141.10' + } + }, + mapping: { + idfa: { '@path': '$.context.device.advertisingId' }, + device_ver: { '@path': '$.context.os.version' }, + app_version: { '@path': '$.context.app.version' }, + origination_ip: { '@path': '$.context.ip' } + } + }) + expect(res.status).toBe(200) + expect(Array.isArray(res.body)).toBe(true) + }) + + it('includes the ATT block and AdServices token when provided', async () => { + const res = await post('/install', { + settings, + payload: { type: 'track', event: 'Application Installed', messageId: 'msg-e2e-install-2' }, + mapping: { + idfa: 'idfa-e2e-2', + device_ver: '15.0', + att: true, + att_time: 1616263616, + ad_services_token: 'as-token-e2e' + } + }) + expect(res.status).toBe(200) + expect(Array.isArray(res.body)).toBe(true) + }) + + it('returns a validation error when no device identifier is provided', async () => { + const res = await post('/install', { + settings, + payload: { type: 'track', event: 'Application Installed', messageId: 'msg-e2e-install-3' }, + mapping: { + device_ver: '15.0' + // no idfa / idfv / adid / android_id + } + }) + expect(res.status).toBe(200) + expect(res.body[0].message).toContain('device identifier') + }) + }) + + describe('Skipped (not testable via HTTP)', () => { + // Kochava exposes no auth-verification endpoint and this destination defines + // no testAuthentication, so there is no failure branch to trigger for invalid + // credentials beyond settings-schema validation. + it.skip('testAuthentication failure branch — no custom testAuthentication exists', () => {}) + + // No performBatch is implemented (Kochava /track/json accepts one record per + // call), so there is no batch delivery path to exercise. + it.skip('performBatch delivery path — not implemented (no batch endpoint)', () => {}) + + // 5xx/429 -> RetryableError and 4xx -> APIError mapping depend on Kochava's + // live response to a given payload, which cannot be forced deterministically + // over HTTP without a real (mis)configured App GUID. + it.skip('retryable vs non-retryable HTTP error mapping — depends on live Kochava response', () => {}) + }) +}) diff --git a/packages/destination-actions/src/destinations/kochava/__tests__/index.test.ts b/packages/destination-actions/src/destinations/kochava/__tests__/index.test.ts new file mode 100644 index 0000000000..3bf87802c2 --- /dev/null +++ b/packages/destination-actions/src/destinations/kochava/__tests__/index.test.ts @@ -0,0 +1,23 @@ +import Destination from '../index' + +describe('Kochava (Actions)', () => { + it('exposes the expected metadata', () => { + expect(Destination.name).toBe('Kochava (Actions)') + expect(Destination.slug).toBe('actions-kochava') + expect(Destination.mode).toBe('cloud') + }) + + it('registers the event and install actions', () => { + expect(Object.keys(Destination.actions)).toEqual(expect.arrayContaining(['event', 'install'])) + }) + + it('requires the kochava_app_id setting', () => { + const fields = Destination.authentication?.fields + expect(fields?.kochava_app_id?.required).toBe(true) + }) + + it('defines presets for both actions', () => { + const partnerActions = (Destination.presets ?? []).map((p) => p.partnerAction) + expect(partnerActions).toEqual(expect.arrayContaining(['event', 'install'])) + }) +}) diff --git a/packages/destination-actions/src/destinations/kochava/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/kochava/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..34f0d7bb03 --- /dev/null +++ b/packages/destination-actions/src/destinations/kochava/__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-kochava' + +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/kochava/constants.ts b/packages/destination-actions/src/destinations/kochava/constants.ts new file mode 100644 index 0000000000..5b05ba72e2 --- /dev/null +++ b/packages/destination-actions/src/destinations/kochava/constants.ts @@ -0,0 +1,15 @@ +export const BASE_URL = 'https://control.kochava.com' + +export const TRACK_ENDPOINT = `${BASE_URL}/track/json` + +// Kochava multiplexes install vs event tracking through the top-level `action` field +// on the same /track/json endpoint. +export const KochavaAction = { + Install: 'install', + Event: 'event' +} as const + +export type KochavaActionType = typeof KochavaAction[keyof typeof KochavaAction] + +// The device identifier keys Kochava recognises inside the `device_ids` object. +export const DEVICE_ID_KEYS = ['idfa', 'idfv', 'adid', 'android_id'] as const diff --git a/packages/destination-actions/src/destinations/kochava/event/__tests__/index.test.ts b/packages/destination-actions/src/destinations/kochava/event/__tests__/index.test.ts new file mode 100644 index 0000000000..a2c7d7748f --- /dev/null +++ b/packages/destination-actions/src/destinations/kochava/event/__tests__/index.test.ts @@ -0,0 +1,89 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +const settings = { kochava_app_id: 'ko-app-guid-123' } + +describe('Kochava.event', () => { + afterEach(() => { + nock.cleanAll() + }) + + it('sends a post-install event with action "event" and epoch-seconds usertime', async () => { + nock('https://control.kochava.com').post('/track/json').reply(200, {}) + + const event = createTestEvent({ + type: 'track', + event: 'Subscription Started', + timestamp: '2021-03-20T18:06:56.000Z', + properties: { currency: 'USD', sum: 150 }, + context: { + device: { id: 'device-1', advertisingId: 'idfa-abc', adTrackingEnabled: true }, + os: { version: '14.4' }, + app: { version: '1.0.0' }, + userAgent: 'Mozilla/5.0 (iPhone)', + ip: '104.219.46.66' + } + }) + + const responses = await testDestination.testAction('event', { + event, + settings, + useDefaultMappings: true + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + + const body = JSON.parse(responses[0].options.body as string) + expect(body.action).toBe('event') + expect(body.kochava_app_id).toBe('ko-app-guid-123') + expect(body.data.event_name).toBe('Subscription Started') + expect(body.data.device_ids.idfa).toBe('idfa-abc') + expect(body.data.usertime).toBe(1616263616) + // device_ua is URL-encoded + expect(body.data.device_ua).toBe(encodeURIComponent('Mozilla/5.0 (iPhone)')) + // ad_tracking_enabled true -> device_limit_tracking false + expect(body.data.device_limit_tracking).toBe(false) + expect(body.data.currency).toBe('USD') + }) + + it('overrides the settings App ID with the mapped kochava_app_id', async () => { + nock('https://control.kochava.com').post('/track/json').reply(200, {}) + + const event = createTestEvent({ type: 'track', event: 'Purchase' }) + + const responses = await testDestination.testAction('event', { + event, + settings, + mapping: { + kochava_app_id: 'override-guid', + event_name: 'Purchase', + idfa: 'idfa-xyz', + device_ver: '15.0' + } + }) + + const body = JSON.parse(responses[0].options.body as string) + expect(body.kochava_app_id).toBe('override-guid') + expect(body.data.device_ids.idfa).toBe('idfa-xyz') + }) + + it('throws a validation error when no device identifier is present', async () => { + const event = createTestEvent({ type: 'track', event: 'Purchase' }) + + await expect( + testDestination.testAction('event', { + event, + settings, + mapping: { + event_name: 'Purchase', + idfa: '', + device_ver: '15.0' + } + }) + ).rejects.toThrow('At least one device identifier') + }) +}) diff --git a/packages/destination-actions/src/destinations/kochava/event/generated-types.ts b/packages/destination-actions/src/destinations/kochava/event/generated-types.ts new file mode 100644 index 0000000000..cdecb9467c --- /dev/null +++ b/packages/destination-actions/src/destinations/kochava/event/generated-types.ts @@ -0,0 +1,82 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The Kochava App GUID. Overrides the Kochava App ID configured in Settings for this event. + */ + kochava_app_id?: string + /** + * A consistent, unique device identifier. May be omitted when Device IDs are provided. + */ + kochava_device_id?: string + /** + * iOS advertising identifier (IDFA). At least one device identifier is required. + */ + idfa?: string + /** + * iOS vendor identifier (IDFV). At least one device identifier is required. + */ + idfv?: string + /** + * Android/Google advertising identifier (ADID). At least one device identifier is required. + */ + adid?: string + /** + * Android device identifier. At least one device identifier is required. + */ + android_id?: string + /** + * The device user agent string. Either this or Device OS Version is required for OS detection. + */ + device_ua?: string + /** + * The device OS version. Either this or Device User Agent is required for OS detection. + */ + device_ver?: string + /** + * The IP address of the device. + */ + origination_ip?: string + /** + * The version of the application. + */ + app_version?: string + /** + * The time the event occurred. Sent to Kochava as epoch seconds. + */ + usertime?: string | number + /** + * iOS 14+ App Tracking Transparency authorization status. + */ + att?: boolean + /** + * iOS 14+ App Tracking Transparency prompt time (epoch seconds). + */ + att_time?: number + /** + * iOS 14+ App Tracking Transparency prompt duration in seconds. + */ + att_duration?: number + /** + * iOS 14+ App Tracking Transparency additional detail. + */ + att_detail?: string + /** + * The name of the event. + */ + event_name: string + /** + * Free-form event values (e.g. id, name, sum) associated with the event. + */ + event_data?: { + [k: string]: unknown + } + /** + * The currency code for revenue events (e.g. "USD"). + */ + currency?: string + /** + * Whether ad tracking is enabled on the device. Sent to Kochava as the inverse (device_limit_tracking). + */ + ad_tracking_enabled?: boolean +} diff --git a/packages/destination-actions/src/destinations/kochava/event/index.ts b/packages/destination-actions/src/destinations/kochava/event/index.ts new file mode 100644 index 0000000000..b5ddd553b9 --- /dev/null +++ b/packages/destination-actions/src/destinations/kochava/event/index.ts @@ -0,0 +1,49 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { commonFields } from '../fields' +import { KochavaAction } from '../constants' +import { sendEvent } from '../utils' + +const action: ActionDefinition = { + title: 'Post-Install Event', + description: 'Send a post-install in-app event (e.g. purchase, subscription) to Kochava.', + defaultSubscription: 'type = "track" and event != "Application Installed"', + fields: { + ...commonFields, + event_name: { + label: 'Event Name', + description: 'The name of the event.', + type: 'string', + required: true, + default: { '@path': '$.event' } + }, + event_data: { + label: 'Event Data', + description: 'Free-form event values (e.g. id, name, sum) associated with the event.', + type: 'object', + required: false, + default: { '@path': '$.properties' } + }, + currency: { + label: 'Currency', + description: 'The currency code for revenue events (e.g. "USD").', + type: 'string', + required: false, + default: { '@path': '$.properties.currency' } + }, + ad_tracking_enabled: { + label: 'Ad Tracking Enabled', + description: + 'Whether ad tracking is enabled on the device. Sent to Kochava as the inverse (device_limit_tracking).', + type: 'boolean', + required: false, + default: { '@path': '$.context.device.adTrackingEnabled' } + } + }, + perform: (request, { payload, settings }) => { + return sendEvent(request, settings, payload, KochavaAction.Event) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/kochava/fields.ts b/packages/destination-actions/src/destinations/kochava/fields.ts new file mode 100644 index 0000000000..eba087bd4f --- /dev/null +++ b/packages/destination-actions/src/destinations/kochava/fields.ts @@ -0,0 +1,105 @@ +import type { InputField } from '@segment/actions-core' + +/** + * Fields shared by both the Install Notification and Post-Install Event actions. + * Each action spreads these into its own `fields` object. + */ +export const commonFields: Record = { + kochava_app_id: { + label: 'Kochava App ID', + description: 'The Kochava App GUID. Overrides the Kochava App ID configured in Settings for this event.', + type: 'string', + required: false + }, + kochava_device_id: { + label: 'Kochava Device ID', + description: 'A consistent, unique device identifier. May be omitted when Device IDs are provided.', + type: 'string', + required: false, + default: { '@path': '$.context.device.id' } + }, + idfa: { + label: 'IDFA', + description: 'iOS advertising identifier (IDFA). At least one device identifier is required.', + type: 'string', + required: false, + default: { '@path': '$.context.device.advertisingId' } + }, + idfv: { + label: 'IDFV', + description: 'iOS vendor identifier (IDFV). At least one device identifier is required.', + type: 'string', + required: false + }, + adid: { + label: 'ADID', + description: 'Android/Google advertising identifier (ADID). At least one device identifier is required.', + type: 'string', + required: false + }, + android_id: { + label: 'Android ID', + description: 'Android device identifier. At least one device identifier is required.', + type: 'string', + required: false + }, + device_ua: { + label: 'Device User Agent', + description: 'The device user agent string. Either this or Device OS Version is required for OS detection.', + type: 'string', + required: false, + default: { '@path': '$.context.userAgent' } + }, + device_ver: { + label: 'Device OS Version', + description: 'The device OS version. Either this or Device User Agent is required for OS detection.', + type: 'string', + required: false, + default: { '@path': '$.context.os.version' } + }, + origination_ip: { + label: 'Origination IP', + description: 'The IP address of the device.', + type: 'string', + required: false, + default: { '@path': '$.context.ip' } + }, + app_version: { + label: 'App Version', + description: 'The version of the application.', + type: 'string', + required: false, + default: { '@path': '$.context.app.version' } + }, + usertime: { + label: 'User Time', + description: 'The time the event occurred. Sent to Kochava as epoch seconds.', + type: 'datetime', + required: false, + default: { '@path': '$.timestamp' } + }, + att: { + label: 'ATT Authorized', + description: 'iOS 14+ App Tracking Transparency authorization status.', + type: 'boolean', + required: false + }, + att_time: { + label: 'ATT Time', + description: 'iOS 14+ App Tracking Transparency prompt time (epoch seconds).', + type: 'number', + required: false + }, + att_duration: { + label: 'ATT Duration', + description: 'iOS 14+ App Tracking Transparency prompt duration in seconds.', + type: 'number', + required: false + }, + att_detail: { + label: 'ATT Detail', + description: 'iOS 14+ App Tracking Transparency additional detail.', + type: 'string', + required: false + } +} diff --git a/packages/destination-actions/src/destinations/kochava/generated-types.ts b/packages/destination-actions/src/destinations/kochava/generated-types.ts new file mode 100644 index 0000000000..61398795e0 --- /dev/null +++ b/packages/destination-actions/src/destinations/kochava/generated-types.ts @@ -0,0 +1,8 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * The Kochava App GUID (unique application identifier) found in the Kochava dashboard. Sent with every request. + */ + kochava_app_id: string +} diff --git a/packages/destination-actions/src/destinations/kochava/index.ts b/packages/destination-actions/src/destinations/kochava/index.ts new file mode 100644 index 0000000000..4f7d8575aa --- /dev/null +++ b/packages/destination-actions/src/destinations/kochava/index.ts @@ -0,0 +1,49 @@ +import type { DestinationDefinition } from '@segment/actions-core' +import { defaultValues } from '@segment/actions-core' +import type { Settings } from './generated-types' + +import event from './event' +import install from './install' + +const destination: DestinationDefinition = { + name: 'Kochava (Actions)', + slug: 'actions-kochava', + mode: 'cloud', + description: 'Send install and post-install event data to Kochava for mobile attribution and analytics.', + authentication: { + scheme: 'custom', + fields: { + kochava_app_id: { + label: 'Kochava App ID', + description: + 'The Kochava App GUID (unique application identifier) found in the Kochava dashboard. Sent with every request.', + type: 'string', + required: true + } + } + // Kochava's Server-to-Server API exposes no auth-verification endpoint; the App GUID + // is validated for presence via the required setting above, so there is no testAuthentication. + }, + presets: [ + { + name: 'Install Notification', + partnerAction: 'install', + subscribe: 'type = "track" and event = "Application Installed"', + mapping: defaultValues(install.fields), + type: 'automatic' + }, + { + name: 'Post-Install Event', + partnerAction: 'event', + subscribe: 'type = "track" and event != "Application Installed"', + mapping: defaultValues(event.fields), + type: 'automatic' + } + ], + actions: { + event, + install + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/kochava/install/__tests__/index.test.ts b/packages/destination-actions/src/destinations/kochava/install/__tests__/index.test.ts new file mode 100644 index 0000000000..0f5d94dc69 --- /dev/null +++ b/packages/destination-actions/src/destinations/kochava/install/__tests__/index.test.ts @@ -0,0 +1,90 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +const settings = { kochava_app_id: 'ko-app-guid-123' } + +describe('Kochava.install', () => { + afterEach(() => { + nock.cleanAll() + }) + + it('sends an install notification with action "install" and device ids', async () => { + nock('https://control.kochava.com').post('/track/json').reply(200, {}) + + const event = createTestEvent({ + type: 'track', + event: 'Application Installed', + timestamp: '2021-03-20T18:06:56.000Z', + context: { + device: { id: 'device-1', advertisingId: 'idfa-abc' }, + os: { version: '15.3' }, + app: { version: '3.3.0' }, + userAgent: 'Mozilla/5.0 (iPhone)', + ip: '77.224.141.10' + } + }) + + const responses = await testDestination.testAction('install', { + event, + settings, + useDefaultMappings: true + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + + const body = JSON.parse(responses[0].options.body as string) + expect(body.action).toBe('install') + expect(body.kochava_app_id).toBe('ko-app-guid-123') + expect(body.data.device_ids.idfa).toBe('idfa-abc') + expect(body.data.device_ver).toBe('15.3') + expect(body.data.app_version).toBe('3.3.0') + // install has no event_name + expect(body.data.event_name).toBeUndefined() + }) + + it('includes the App Tracking Transparency block and AdServices token when provided', async () => { + nock('https://control.kochava.com').post('/track/json').reply(200, {}) + + const event = createTestEvent({ type: 'track', event: 'Application Installed' }) + + const responses = await testDestination.testAction('install', { + event, + settings, + mapping: { + idfa: 'idfa-xyz', + device_ver: '15.0', + att: true, + att_time: 1616263616, + ad_services_token: 'as-token-1' + } + }) + + const body = JSON.parse(responses[0].options.body as string) + expect(body.data.app_tracking_transparency).toEqual({ + att: true, + att_time: 1616263616, + att_duration: undefined, + att_detail: undefined + }) + expect(body.data.ad_services_token).toBe('as-token-1') + }) + + it('throws a validation error when no device identifier is present', async () => { + const event = createTestEvent({ type: 'track', event: 'Application Installed' }) + + await expect( + testDestination.testAction('install', { + event, + settings, + mapping: { + idfa: '', + device_ver: '15.0' + } + }) + ).rejects.toThrow('At least one device identifier') + }) +}) diff --git a/packages/destination-actions/src/destinations/kochava/install/generated-types.ts b/packages/destination-actions/src/destinations/kochava/install/generated-types.ts new file mode 100644 index 0000000000..a011320888 --- /dev/null +++ b/packages/destination-actions/src/destinations/kochava/install/generated-types.ts @@ -0,0 +1,68 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The Kochava App GUID. Overrides the Kochava App ID configured in Settings for this event. + */ + kochava_app_id?: string + /** + * A consistent, unique device identifier. May be omitted when Device IDs are provided. + */ + kochava_device_id?: string + /** + * iOS advertising identifier (IDFA). At least one device identifier is required. + */ + idfa?: string + /** + * iOS vendor identifier (IDFV). At least one device identifier is required. + */ + idfv?: string + /** + * Android/Google advertising identifier (ADID). At least one device identifier is required. + */ + adid?: string + /** + * Android device identifier. At least one device identifier is required. + */ + android_id?: string + /** + * The device user agent string. Either this or Device OS Version is required for OS detection. + */ + device_ua?: string + /** + * The device OS version. Either this or Device User Agent is required for OS detection. + */ + device_ver?: string + /** + * The IP address of the device. + */ + origination_ip?: string + /** + * The version of the application. + */ + app_version?: string + /** + * The time the event occurred. Sent to Kochava as epoch seconds. + */ + usertime?: string | number + /** + * iOS 14+ App Tracking Transparency authorization status. + */ + att?: boolean + /** + * iOS 14+ App Tracking Transparency prompt time (epoch seconds). + */ + att_time?: number + /** + * iOS 14+ App Tracking Transparency prompt duration in seconds. + */ + att_duration?: number + /** + * iOS 14+ App Tracking Transparency additional detail. + */ + att_detail?: string + /** + * iOS 14+ Apple AdServices attribution token. + */ + ad_services_token?: string +} diff --git a/packages/destination-actions/src/destinations/kochava/install/index.ts b/packages/destination-actions/src/destinations/kochava/install/index.ts new file mode 100644 index 0000000000..4ba1b41ede --- /dev/null +++ b/packages/destination-actions/src/destinations/kochava/install/index.ts @@ -0,0 +1,26 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { commonFields } from '../fields' +import { KochavaAction } from '../constants' +import { sendEvent } from '../utils' + +const action: ActionDefinition = { + title: 'Install Notification', + description: 'Send an app install notification to Kochava for attribution.', + defaultSubscription: 'type = "track" and event = "Application Installed"', + fields: { + ...commonFields, + ad_services_token: { + label: 'AdServices Token', + description: 'iOS 14+ Apple AdServices attribution token.', + type: 'string', + required: false + } + }, + perform: (request, { payload, settings }) => { + return sendEvent(request, settings, payload, KochavaAction.Install) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/kochava/metadata.json b/packages/destination-actions/src/destinations/kochava/metadata.json new file mode 100644 index 0000000000..a89d16b128 --- /dev/null +++ b/packages/destination-actions/src/destinations/kochava/metadata.json @@ -0,0 +1,1000 @@ +{ + "slug": "actions-kochava", + "name": "Kochava (Actions)", + "mode": "cloud", + "description": "Send install and post-install event data to Kochava for mobile attribution and analytics.", + "authentication": { + "scheme": "custom", + "fields": { + "kochava_app_id": { + "label": "Kochava App ID", + "description": "The Kochava App GUID (unique application identifier) found in the Kochava dashboard. Sent with every request.", + "type": "string", + "required": true, + "multiple": false, + "choices": null, + "default": null, + "depends_on": null + } + } + }, + "audienceConfig": null, + "actions": { + "event": { + "title": "Post-Install Event", + "description": "Send a post-install in-app event (e.g. purchase, subscription) to Kochava.", + "platform": "cloud", + "defaultSubscription": "type = \"track\" and event != \"Application Installed\"", + "hidden": false, + "hasPerformBatch": false, + "syncMode": null, + "hooks": null, + "dynamicFields": null, + "fields": { + "kochava_app_id": { + "label": "Kochava App ID", + "description": "The Kochava App GUID. Overrides the Kochava App ID configured in Settings for this event.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "kochava_device_id": { + "label": "Kochava Device ID", + "description": "A consistent, unique device identifier. May be omitted when Device IDs are provided.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@path": "$.context.device.id" + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "idfa": { + "label": "IDFA", + "description": "iOS advertising identifier (IDFA). At least one device identifier is required.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@path": "$.context.device.advertisingId" + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "idfv": { + "label": "IDFV", + "description": "iOS vendor identifier (IDFV). At least one device identifier is required.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "adid": { + "label": "ADID", + "description": "Android/Google advertising identifier (ADID). At least one device identifier is required.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "android_id": { + "label": "Android ID", + "description": "Android device identifier. At least one device identifier is required.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "device_ua": { + "label": "Device User Agent", + "description": "The device user agent string. Either this or Device OS Version is required for OS detection.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@path": "$.context.userAgent" + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "device_ver": { + "label": "Device OS Version", + "description": "The device OS version. Either this or Device User Agent is required for OS detection.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@path": "$.context.os.version" + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "origination_ip": { + "label": "Origination IP", + "description": "The IP address of the device.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@path": "$.context.ip" + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "app_version": { + "label": "App Version", + "description": "The version of the application.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@path": "$.context.app.version" + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "usertime": { + "label": "User Time", + "description": "The time the event occurred. Sent to Kochava as epoch seconds.", + "type": "datetime", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@path": "$.timestamp" + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "att": { + "label": "ATT Authorized", + "description": "iOS 14+ App Tracking Transparency authorization status.", + "type": "boolean", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "att_time": { + "label": "ATT Time", + "description": "iOS 14+ App Tracking Transparency prompt time (epoch seconds).", + "type": "number", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "att_duration": { + "label": "ATT Duration", + "description": "iOS 14+ App Tracking Transparency prompt duration in seconds.", + "type": "number", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "att_detail": { + "label": "ATT Detail", + "description": "iOS 14+ App Tracking Transparency additional detail.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "event_name": { + "label": "Event Name", + "description": "The name of the event.", + "type": "string", + "required": true, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@path": "$.event" + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "event_data": { + "label": "Event Data", + "description": "Free-form event values (e.g. id, name, sum) associated with the event.", + "type": "object", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@path": "$.properties" + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "currency": { + "label": "Currency", + "description": "The currency code for revenue events (e.g. \"USD\").", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@path": "$.properties.currency" + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "ad_tracking_enabled": { + "label": "Ad Tracking Enabled", + "description": "Whether ad tracking is enabled on the device. Sent to Kochava as the inverse (device_limit_tracking).", + "type": "boolean", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@path": "$.context.device.adTrackingEnabled" + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + } + } + }, + "install": { + "title": "Install Notification", + "description": "Send an app install notification to Kochava for attribution.", + "platform": "cloud", + "defaultSubscription": "type = \"track\" and event = \"Application Installed\"", + "hidden": false, + "hasPerformBatch": false, + "syncMode": null, + "hooks": null, + "dynamicFields": null, + "fields": { + "kochava_app_id": { + "label": "Kochava App ID", + "description": "The Kochava App GUID. Overrides the Kochava App ID configured in Settings for this event.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "kochava_device_id": { + "label": "Kochava Device ID", + "description": "A consistent, unique device identifier. May be omitted when Device IDs are provided.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@path": "$.context.device.id" + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "idfa": { + "label": "IDFA", + "description": "iOS advertising identifier (IDFA). At least one device identifier is required.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@path": "$.context.device.advertisingId" + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "idfv": { + "label": "IDFV", + "description": "iOS vendor identifier (IDFV). At least one device identifier is required.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "adid": { + "label": "ADID", + "description": "Android/Google advertising identifier (ADID). At least one device identifier is required.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "android_id": { + "label": "Android ID", + "description": "Android device identifier. At least one device identifier is required.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "device_ua": { + "label": "Device User Agent", + "description": "The device user agent string. Either this or Device OS Version is required for OS detection.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@path": "$.context.userAgent" + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "device_ver": { + "label": "Device OS Version", + "description": "The device OS version. Either this or Device User Agent is required for OS detection.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@path": "$.context.os.version" + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "origination_ip": { + "label": "Origination IP", + "description": "The IP address of the device.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@path": "$.context.ip" + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "app_version": { + "label": "App Version", + "description": "The version of the application.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@path": "$.context.app.version" + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "usertime": { + "label": "User Time", + "description": "The time the event occurred. Sent to Kochava as epoch seconds.", + "type": "datetime", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@path": "$.timestamp" + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "att": { + "label": "ATT Authorized", + "description": "iOS 14+ App Tracking Transparency authorization status.", + "type": "boolean", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "att_time": { + "label": "ATT Time", + "description": "iOS 14+ App Tracking Transparency prompt time (epoch seconds).", + "type": "number", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "att_duration": { + "label": "ATT Duration", + "description": "iOS 14+ App Tracking Transparency prompt duration in seconds.", + "type": "number", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "att_detail": { + "label": "ATT Detail", + "description": "iOS 14+ App Tracking Transparency additional detail.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "ad_services_token": { + "label": "AdServices Token", + "description": "iOS 14+ Apple AdServices attribution token.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + } + } + } + }, + "presets": [ + { + "name": "Install Notification", + "type": "automatic", + "partnerAction": "install", + "subscribe": "type = \"track\" and event = \"Application Installed\"", + "mapping": { + "kochava_device_id": { + "@path": "$.context.device.id" + }, + "idfa": { + "@path": "$.context.device.advertisingId" + }, + "device_ua": { + "@path": "$.context.userAgent" + }, + "device_ver": { + "@path": "$.context.os.version" + }, + "origination_ip": { + "@path": "$.context.ip" + }, + "app_version": { + "@path": "$.context.app.version" + }, + "usertime": { + "@path": "$.timestamp" + } + }, + "eventSlug": null + }, + { + "name": "Post-Install Event", + "type": "automatic", + "partnerAction": "event", + "subscribe": "type = \"track\" and event != \"Application Installed\"", + "mapping": { + "kochava_device_id": { + "@path": "$.context.device.id" + }, + "idfa": { + "@path": "$.context.device.advertisingId" + }, + "device_ua": { + "@path": "$.context.userAgent" + }, + "device_ver": { + "@path": "$.context.os.version" + }, + "origination_ip": { + "@path": "$.context.ip" + }, + "app_version": { + "@path": "$.context.app.version" + }, + "usertime": { + "@path": "$.timestamp" + }, + "event_name": { + "@path": "$.event" + }, + "event_data": { + "@path": "$.properties" + }, + "currency": { + "@path": "$.properties.currency" + }, + "ad_tracking_enabled": { + "@path": "$.context.device.adTrackingEnabled" + } + }, + "eventSlug": null + } + ] +} diff --git a/packages/destination-actions/src/destinations/kochava/types.ts b/packages/destination-actions/src/destinations/kochava/types.ts new file mode 100644 index 0000000000..a6769dd665 --- /dev/null +++ b/packages/destination-actions/src/destinations/kochava/types.ts @@ -0,0 +1,70 @@ +import type { KochavaActionType } from './constants' + +export interface KochavaDeviceIds { + idfa?: string + idfv?: string + adid?: string + android_id?: string + [key: string]: string | undefined +} + +export interface KochavaAppTrackingTransparency { + att?: boolean + att_time?: number + att_duration?: number + att_detail?: string +} + +export interface KochavaData { + device_ids: KochavaDeviceIds + device_ua?: string + device_ver?: string + origination_ip?: string + app_version?: string + usertime?: number + device_limit_tracking?: boolean + event_name?: string + event_data?: Record + currency?: string + app_tracking_transparency?: KochavaAppTrackingTransparency + ad_services_token?: string +} + +export interface KochavaTrackRequest { + action: KochavaActionType + kochava_app_id: string + kochava_device_id?: string + data: KochavaData +} + +export interface KochavaResponse { + status?: string +} + +/** + * Structural superset of both action payloads (event + install). Every field is + * optional so the generated per-action `Payload` types are assignable to it, which + * lets `utils.ts` build a request body from either action without duplication. + */ +export interface KochavaActionPayload { + kochava_app_id?: string + kochava_device_id?: string + idfa?: string + idfv?: string + adid?: string + android_id?: string + device_ua?: string + device_ver?: string + origination_ip?: string + app_version?: string + usertime?: string | number + ad_tracking_enabled?: boolean + event_name?: string + event_data?: Record + currency?: string + att?: boolean + att_time?: number + att_duration?: number + att_detail?: string + ad_services_token?: string +} diff --git a/packages/destination-actions/src/destinations/kochava/utils.ts b/packages/destination-actions/src/destinations/kochava/utils.ts new file mode 100644 index 0000000000..38863ec6ca --- /dev/null +++ b/packages/destination-actions/src/destinations/kochava/utils.ts @@ -0,0 +1,116 @@ +import type { RequestClient } from '@segment/actions-core' +import { PayloadValidationError } from '@segment/actions-core' +import type { Settings } from './generated-types' +import { TRACK_ENDPOINT } from './constants' +import type { KochavaActionType } from './constants' +import type { + KochavaActionPayload, + KochavaAppTrackingTransparency, + KochavaData, + KochavaDeviceIds, + KochavaResponse, + KochavaTrackRequest +} from './types' + +/** + * Convert a Segment timestamp (ISO string, Date, or epoch ms) into Kochava's + * `usertime`, which the API expects as epoch **seconds**. + */ +function toEpochSeconds(value?: string | number): number | undefined { + if (value === undefined || value === null || value === '') return undefined + const ms = new Date(value).getTime() + if (Number.isNaN(ms)) return undefined + return Math.floor(ms / 1000) +} + +/** + * Assemble Kochava's `device_ids` object from the individual identifier fields, + * keeping only those with a non-empty value. + */ +function buildDeviceIds(payload: KochavaActionPayload): KochavaDeviceIds { + const result: KochavaDeviceIds = {} + const candidates: Array<[keyof KochavaDeviceIds, string | undefined]> = [ + ['idfa', payload.idfa], + ['idfv', payload.idfv], + ['adid', payload.adid], + ['android_id', payload.android_id] + ] + for (const [key, val] of candidates) { + if (val !== undefined && val !== null && String(val).length > 0) { + result[key] = String(val) + } + } + return result +} + +function buildAppTrackingTransparency(payload: KochavaActionPayload): KochavaAppTrackingTransparency | undefined { + const { att, att_time, att_duration, att_detail } = payload + if (att === undefined && att_time === undefined && att_duration === undefined && att_detail === undefined) { + return undefined + } + return { att, att_time, att_duration, att_detail } +} + +function buildData(payload: KochavaActionPayload, deviceIds: KochavaDeviceIds): KochavaData { + const data: KochavaData = { + device_ids: deviceIds, + // Kochava expects a URL-encoded user agent string. + device_ua: payload.device_ua ? encodeURIComponent(payload.device_ua) : undefined, + device_ver: payload.device_ver, + origination_ip: payload.origination_ip, + app_version: payload.app_version, + usertime: toEpochSeconds(payload.usertime), + event_name: payload.event_name, + event_data: payload.event_data, + currency: payload.currency, + ad_services_token: payload.ad_services_token, + app_tracking_transparency: buildAppTrackingTransparency(payload) + } + + // `device_limit_tracking` is the inverse of Segment's `context.device.adTrackingEnabled`. + if (payload.ad_tracking_enabled !== undefined) { + data.device_limit_tracking = !payload.ad_tracking_enabled + } + + return data +} + +/** + * Build a fully-typed Kochava /track/json request body for the given action. + * Throws PayloadValidationError when Kochava's minimum requirements aren't met. + */ +export function buildTrackRequest( + payload: KochavaActionPayload, + settings: Settings, + action: KochavaActionType +): KochavaTrackRequest { + const kochavaAppId = payload.kochava_app_id || settings.kochava_app_id + if (!kochavaAppId) { + throw new PayloadValidationError('A Kochava App ID (App GUID) is required.') + } + + const deviceIds = buildDeviceIds(payload) + if (Object.keys(deviceIds).length === 0) { + throw new PayloadValidationError('At least one device identifier (IDFA, IDFV, ADID, or Android ID) is required.') + } + + return { + action, + kochava_app_id: kochavaAppId, + kochava_device_id: payload.kochava_device_id, + data: buildData(payload, deviceIds) + } +} + +export function sendEvent( + request: RequestClient, + settings: Settings, + payload: KochavaActionPayload, + action: KochavaActionType +) { + const body = buildTrackRequest(payload, settings, action) + return request(TRACK_ENDPOINT, { + method: 'POST', + json: body + }) +}