diff --git a/packages/destination-actions/src/destinations/linkedin-audiences/api.ts b/packages/destination-actions/src/destinations/linkedin-audiences/api.ts index e298fa79755..b49009d4484 100644 --- a/packages/destination-actions/src/destinations/linkedin-audiences/api.ts +++ b/packages/destination-actions/src/destinations/linkedin-audiences/api.ts @@ -1,8 +1,8 @@ -import type { RequestClient, ModifiedResponse } from '@segment/actions-core' +import type { RequestClient, ModifiedResponse, Features } from '@segment/actions-core' import type { Settings } from './generated-types' import type { Payload } from './updateAudience/generated-types' -import { BASE_URL, LINKEDIN_API_VERSION, LINKEDIN_SOURCE_PLATFORM } from './constants' +import { BASE_URL, LINKEDIN_SOURCE_PLATFORM, getApiVersion } from './constants' import type { ProfileAPIResponse, AdAccountUserResponse, LinkedInAudiencePayload } from './types' export class LinkedInAudiences { @@ -57,12 +57,16 @@ export class LinkedInAudiences { }) } - async batchUpdate(dmpSegmentId: string, elements: LinkedInAudiencePayload[]): Promise { + async batchUpdate( + dmpSegmentId: string, + elements: LinkedInAudiencePayload[], + features?: Features + ): Promise { return this.request(`${BASE_URL}/dmpSegments/${dmpSegmentId}/users`, { method: 'POST', headers: { 'X-RestLi-Method': 'BATCH_CREATE', - 'Linkedin-Version': LINKEDIN_API_VERSION // https://learn.microsoft.com/en-us/linkedin/marketing/matched-audiences/create-and-manage-segment-users?view=li-lms-2025-11&tabs=curl + 'Linkedin-Version': getApiVersion(features) // https://learn.microsoft.com/en-us/linkedin/marketing/matched-audiences/create-and-manage-segment-users?view=li-lms-2025-11&tabs=curl }, json: { elements diff --git a/packages/destination-actions/src/destinations/linkedin-audiences/breaking-changes-analysis.md b/packages/destination-actions/src/destinations/linkedin-audiences/breaking-changes-analysis.md new file mode 100644 index 00000000000..13d8f7ce103 --- /dev/null +++ b/packages/destination-actions/src/destinations/linkedin-audiences/breaking-changes-analysis.md @@ -0,0 +1,76 @@ +# Breaking Changes Analysis: LinkedIn Audiences API 202505 → 202603 + +## Summary + +No new DMP Segments API-specific breaking changes were introduced in the 202505–202603 window. The upgrade is primarily driven by **version sunset urgency**: 202505 sunsets on **May 15, 2026** (~1 month from today). Upgrading to 202603 (supported until March 16, 2027) ensures continuity of service. + +The implementation uses a feature flag (`linkedin-audiences-canary-version`) for safe, gradual rollout with instant rollback capability. + +--- + +## Critical Breaking Changes + +**None new in 202505 → 202603 range.** + +--- + +## Medium Priority Changes + +### 1. BATCH_CREATE Per-Element Response Schema (introduced 202502) + +**Applies to**: `POST /rest/dmpSegments/{id}/users` with `X-RestLi-Method: BATCH_CREATE` + +| Aspect | Pre-202502 | 202502+ (202603) | +| ----------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------ | +| Validation scope | All-or-nothing: entire batch rejected on any validation error | Partial: valid elements accepted, invalid ones return per-element errors | +| Response body | Single error on failure | Array of per-element HTTP statuses (`201` success, `400` error) | +| Error attribution | No index reference | `batchIndex` field identifies failing element position | + +**Impact on our implementation**: Our current code checks `res.status !== 200` for the top-level HTTP response and throws `RetryableError` for non-200 responses. Under 202603, a batch with some valid and some invalid elements may return HTTP `200` at the top level while individual elements have `400` status in the response body. + +**Decision**: The existing error handling remains correct for the overall batch failure case. Per-element error handling is a potential future enhancement but is not a breaking change for the current implementation pattern (batch retries handle transient failures). + +### 2. Predictive Audiences API (202511) + +Additive/non-breaking. New `desiredAudienceCount` field and geo-filter options for predictive audience segments. Does not affect existing DMP segment user sync flows. + +--- + +## Low Priority / Informational + +### 3. Rate Limit Alerting (202509) + +LinkedIn now sends email alerts to developer app admins at 75% of daily rate limit. Rate limits themselves are unchanged. + +### 4. Version Sunset Schedule + +| Version | Sunset Date | +| -------------------- | ------------------- | +| **202505 (current)** | **May 15, 2026 ⚠️** | +| 202603 (target) | March 16, 2027 | + +202505 is the **immediate driver** for this upgrade — it sunsets in approximately 1 month. + +### 5. `accessPolicy` Field Removal (202406 — pre-range) + +Already handled. The field was removed from create/update/get schema in 202406. Our implementation does not use this field. + +--- + +## Testing Requirements + +- [x] Stable version (202505) continues to work without feature flag +- [x] Canary version (202603) activated via `linkedin-audiences-canary-version` feature flag +- [ ] Manual smoke test with feature flag disabled (stable) +- [ ] Manual smoke test with feature flag enabled (canary) +- [ ] Monitor for per-element batch error responses in canary rollout + +--- + +## Rollout Plan + +1. **Phase 1**: Merge PR — feature flag off by default, production unchanged +2. **Phase 2**: Enable flag for internal Segment workspace testing +3. **Phase 3**: Gradual rollout to subset of customers +4. **Phase 4**: Full rollout, promote canary (`202603`) to stable +5. **Phase 5**: Remove feature flag and old version (`202505`) diff --git a/packages/destination-actions/src/destinations/linkedin-audiences/constants.ts b/packages/destination-actions/src/destinations/linkedin-audiences/constants.ts index 0a9d9f654ae..8745bb311d6 100644 --- a/packages/destination-actions/src/destinations/linkedin-audiences/constants.ts +++ b/packages/destination-actions/src/destinations/linkedin-audiences/constants.ts @@ -1,5 +1,13 @@ -import { LINKEDIN_AUDIENCES_API_VERSION } from './versioning-info' +import type { Features } from '@segment/actions-core' +import { LINKEDIN_AUDIENCES_API_VERSION, LINKEDIN_AUDIENCES_CANARY_API_VERSION } from './versioning-info' export const LINKEDIN_API_VERSION = LINKEDIN_AUDIENCES_API_VERSION +export const LINKEDIN_CANARY_API_VERSION = LINKEDIN_AUDIENCES_CANARY_API_VERSION export const BASE_URL = 'https://api.linkedin.com/rest' export const LINKEDIN_SOURCE_PLATFORM = 'SEGMENT' + +export const FLAGON_NAME = 'linkedin-audiences-canary-version' + +export function getApiVersion(features?: Features): string { + return features && features[FLAGON_NAME] ? LINKEDIN_CANARY_API_VERSION : LINKEDIN_API_VERSION +} diff --git a/packages/destination-actions/src/destinations/linkedin-audiences/index.ts b/packages/destination-actions/src/destinations/linkedin-audiences/index.ts index 8bcabbb8069..96650dc540d 100644 --- a/packages/destination-actions/src/destinations/linkedin-audiences/index.ts +++ b/packages/destination-actions/src/destinations/linkedin-audiences/index.ts @@ -5,7 +5,7 @@ import { InvalidAuthenticationError, IntegrationError, ErrorCodes } from '@segme import type { Settings } from './generated-types' import updateAudience from './updateAudience' -import { LINKEDIN_API_VERSION } from './constants' +import { getApiVersion } from './constants' import { LinkedInAudiences } from './api' import type { RefreshTokenResponse, @@ -138,7 +138,7 @@ const destination: DestinationDefinition = { return { accessToken: res?.data?.access_token } } }, - extendRequest({ auth }) { + extendRequest({ auth, features }) { // Repeat calls to the same LinkedIn API endpoint were failing due to a `socket hang up`. // This seems to fix it: https://stackoverflow.com/questions/62500011/reuse-tcp-connection-with-node-fetch-in-node-js const agent = new https.Agent({ keepAlive: true }) @@ -146,7 +146,7 @@ const destination: DestinationDefinition = { return { headers: { authorization: `Bearer ${auth?.accessToken}`, - 'LinkedIn-Version': LINKEDIN_API_VERSION + 'LinkedIn-Version': getApiVersion(features) }, agent } diff --git a/packages/destination-actions/src/destinations/linkedin-audiences/updateAudience/__tests__/index.test.ts b/packages/destination-actions/src/destinations/linkedin-audiences/updateAudience/__tests__/index.test.ts index 79d7da30cbd..dc42fea16a0 100644 --- a/packages/destination-actions/src/destinations/linkedin-audiences/updateAudience/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/linkedin-audiences/updateAudience/__tests__/index.test.ts @@ -1,7 +1,13 @@ import nock from 'nock' import { createTestEvent, createTestIntegration } from '@segment/actions-core' import Destination from '../../index' -import { BASE_URL, LINKEDIN_SOURCE_PLATFORM } from '../../constants' +import { + BASE_URL, + LINKEDIN_SOURCE_PLATFORM, + LINKEDIN_API_VERSION, + LINKEDIN_CANARY_API_VERSION, + FLAGON_NAME +} from '../../constants' const testDestination = createTestIntegration(Destination) @@ -865,4 +871,68 @@ describe('LinkedinAudiences.updateAudience', () => { ).rejects.toThrow('The value of `source_segment_id` and `personas_audience_key` must match.') }) }) + + describe('API Version Feature Flag', () => { + it('should use stable API version by default (no feature flag)', async () => { + nock(`${BASE_URL}/dmpSegments`) + .get(/.*/) + .query(() => true) + .reply(200, { elements: [{ id: 'dmp_segment_id' }] }) + + const batchUpdateMock = nock(`${BASE_URL}/dmpSegments/dmp_segment_id/users`) + .post(/.*/, updateUsersRequestBody) + .matchHeader('linkedin-version', LINKEDIN_API_VERSION) + .reply(200) + + await expect( + testDestination.testAction('updateAudience', { + event, + settings: { + ad_account_id: '123', + send_email: true, + send_google_advertising_id: true + }, + useDefaultMappings: true, + auth, + mapping: { + personas_audience_key: 'personas_test_audience' + } + // No features parameter — stable version should be used + }) + ).resolves.not.toThrowError() + + expect(batchUpdateMock.isDone()).toBe(true) + }) + + it('should use canary API version when feature flag is enabled', async () => { + nock(`${BASE_URL}/dmpSegments`) + .get(/.*/) + .query(() => true) + .reply(200, { elements: [{ id: 'dmp_segment_id' }] }) + + const batchUpdateMock = nock(`${BASE_URL}/dmpSegments/dmp_segment_id/users`) + .post(/.*/, updateUsersRequestBody) + .matchHeader('linkedin-version', LINKEDIN_CANARY_API_VERSION) + .reply(200) + + await expect( + testDestination.testAction('updateAudience', { + event, + settings: { + ad_account_id: '123', + send_email: true, + send_google_advertising_id: true + }, + useDefaultMappings: true, + auth, + mapping: { + personas_audience_key: 'personas_test_audience' + }, + features: { [FLAGON_NAME]: true } + }) + ).resolves.not.toThrowError() + + expect(batchUpdateMock.isDone()).toBe(true) + }) + }) }) diff --git a/packages/destination-actions/src/destinations/linkedin-audiences/updateAudience/functions.ts b/packages/destination-actions/src/destinations/linkedin-audiences/updateAudience/functions.ts index 8f5ee97ba37..043367efd37 100644 --- a/packages/destination-actions/src/destinations/linkedin-audiences/updateAudience/functions.ts +++ b/packages/destination-actions/src/destinations/linkedin-audiences/updateAudience/functions.ts @@ -1,4 +1,4 @@ -import type { StatsContext } from '@segment/actions-core' +import type { StatsContext, Features } from '@segment/actions-core' import { RequestClient, RetryableError, IntegrationError, InvalidAuthenticationError } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' @@ -12,7 +12,8 @@ export async function processPayload( settings: Settings, payloads: Payload[], statsContext: StatsContext | undefined, - stateContext?: StateContext + stateContext?: StateContext, + features?: Features ) { validate(settings, payloads) @@ -36,7 +37,7 @@ export async function processPayload( `endpoint:add-or-remove-users-from-dmpSegment` ]) - const res = await linkedinApiClient.batchUpdate(dmpSegmentId, elements) + const res = await linkedinApiClient.batchUpdate(dmpSegmentId, elements, features) // Handle 401 explicitly so the framework can trigger token refresh. // Without this, 401s are swallowed by throwHttpErrors: false and converted diff --git a/packages/destination-actions/src/destinations/linkedin-audiences/updateAudience/index.ts b/packages/destination-actions/src/destinations/linkedin-audiences/updateAudience/index.ts index 9b364837b0f..965970d611e 100644 --- a/packages/destination-actions/src/destinations/linkedin-audiences/updateAudience/index.ts +++ b/packages/destination-actions/src/destinations/linkedin-audiences/updateAudience/index.ts @@ -9,11 +9,11 @@ const action: ActionDefinition = { description: 'Syncs contacts from a Personas Audience to a LinkedIn DMP Segment.', defaultSubscription: 'event = "Audience Entered" or event = "Audience Exited"', fields, - perform: async (request, { settings, payload, statsContext, stateContext }) => { - return processPayload(request, settings, [payload], statsContext, stateContext) + perform: async (request, { settings, payload, statsContext, stateContext, features }) => { + return processPayload(request, settings, [payload], statsContext, stateContext, features) }, - performBatch: async (request, { settings, payload, statsContext, stateContext }) => { - return processPayload(request, settings, payload, statsContext, stateContext) + performBatch: async (request, { settings, payload, statsContext, stateContext, features }) => { + return processPayload(request, settings, payload, statsContext, stateContext, features) } } diff --git a/packages/destination-actions/src/destinations/linkedin-audiences/versioning-info.ts b/packages/destination-actions/src/destinations/linkedin-audiences/versioning-info.ts index 567c42e7617..f4d1c6c7888 100644 --- a/packages/destination-actions/src/destinations/linkedin-audiences/versioning-info.ts +++ b/packages/destination-actions/src/destinations/linkedin-audiences/versioning-info.ts @@ -1,5 +1,12 @@ /** LINKEDIN_AUDIENCES_API_VERSION - * LinkedIn Audiences API version. + * LinkedIn Audiences API version (stable/production). * API reference: https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads-audience-management/audience-api?view=li-lms-2024-05 + * Changelog: https://learn.microsoft.com/en-us/linkedin/marketing/changelog */ export const LINKEDIN_AUDIENCES_API_VERSION = '202505' + +/** LINKEDIN_AUDIENCES_CANARY_API_VERSION + * LinkedIn Audiences API version (canary/feature-flagged). + * Testing new version 202603 behind feature flag. + */ +export const LINKEDIN_AUDIENCES_CANARY_API_VERSION = '202603'