diff --git a/.claude/skills/api-version-upgrade/SKILL.md b/.claude/skills/api-version-upgrade/SKILL.md new file mode 100644 index 00000000000..c0365df1534 --- /dev/null +++ b/.claude/skills/api-version-upgrade/SKILL.md @@ -0,0 +1,939 @@ +--- +name: api-version-upgrade +description: | + Upgrade API versions for Segment Action Destinations with feature flags, comprehensive breaking change analysis, automated testing, and PR creation. + + Use this skill when the user wants to: + - Upgrade a destination's API version (e.g., "upgrade Klaviyo to 2026-01-15") + - Update API endpoints to newer versions + - Bump version numbers for any action destination + - Migrate a destination to use versioned APIs + - Test a new API version with feature flags + + This skill handles the complete workflow: version detection, changelog analysis, feature flag implementation, testing, and PR creation with detailed breaking changes documentation. +allowed-tools: Read, Write, Edit, Glob, Grep, WebFetch, Bash(git *), Bash(gh *), Bash(yarn *), Bash(nvm *), Bash(source *), Bash(node *), Bash(find *), Bash(ls *), Bash(grep *), Bash(which *) +--- + +# API Version Upgrade Workflow + +This skill automates the complete process of upgrading API versions for Segment Action Destinations, ensuring safe rollouts with feature flags and comprehensive testing. + +## Overview + +API version upgrades in action-destinations follow a **canary pattern**: + +- Stable production version remains unchanged +- New version is deployed behind a feature flag +- Gradual rollout allows safe testing and rollback +- Full breaking changes analysis before merge + +## ⚠️ MANDATORY REQUIREMENTS ⚠️ + +**EVERY API version upgrade MUST include:** + +1. **versioning-info.ts file** - If it doesn't exist, CREATE IT. No exceptions. +2. **Feature flag implementation** - ALL upgrades must be behind a feature flag. No direct version changes. +3. **Feature flag tests** - Test both stable (default) and canary (feature flag enabled) versions. + +These are not optional. They are required for safe, gradual rollouts and instant rollback capability. + +## Step 0: Pre-flight Tool Check + +Before doing anything else, verify all required tools are installed. Run each check and report the results in a summary table. + +```bash +which git && git --version +which gh && gh --version +which yarn && yarn --version +which node && node --version +which nvm || (source ~/.nvm/nvm.sh && nvm --version) +``` + +| Tool | Required | Purpose | +| ------ | -------- | ---------------------------------- | +| `git` | REQUIRED | Branch management, commits | +| `gh` | REQUIRED | Pull request creation | +| `yarn` | REQUIRED | Running tests | +| `node` | REQUIRED | Running tests (v18.17+ or v22.13+) | +| `nvm` | OPTIONAL | Switching Node versions | + +**If any REQUIRED tool is missing, stop immediately and tell the user:** + +``` +❌ Pre-flight check failed. + +Missing required tools: +- : + +Please install the missing tools and re-run the skill. +``` + +Install hints: + +- `gh` not found: `brew install gh` (Mac) or see https://cli.github.com +- `yarn` not found: `npm install -g yarn` +- `git` not found: `brew install git` or install Xcode Command Line Tools +- `node` not found: install via https://nodejs.org or `nvm install 22` +- `nvm` not found (optional): `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash` + +If `nvm` is missing but node is already at v18.17+ or v22.13+, that is acceptable — skip the `nvm use` step later. + +Only continue to the next step after all REQUIRED tools are confirmed present. + +## Prerequisites Check + +Before starting, verify: + +1. You're in the action-destinations repository root +2. Node version is compatible (v18.17+ or v22.13+) +3. Git working directory is clean +4. User has provided: + - Destination name (e.g., "klaviyo", "google-campaign-manager-360") + - Target API version (e.g., "2026-01-15", "v5") + +If any information is missing, ask the user before proceeding. + +## Step 1: Information Gathering + +### 1.1 Collect Required Information + +Ask the user for: + +``` +- Destination name: [e.g., klaviyo] +- Current version: [will auto-detect if not provided] +- Target version: [user must provide] +- Changelog URL (optional): [base URL for API docs] +``` + +### 1.2 Locate Destination Files + +Find the destination directory: + +```bash +packages/destination-actions/src/destinations// +``` + +Key files to check: + +- `versioning-info.ts` - version constants (CREATE if missing) +- `config.ts` - may have version constants +- `functions.ts` or `utils.ts` - API request building +- `index.ts` - main destination definition +- `__tests__/` - test files + +### 1.3 Detect Current Version + +Search for version constants in this order: + +1. `versioning-info.ts` (preferred pattern) +2. `config.ts` (simple pattern) +3. Hardcoded in `utils.ts` or `functions.ts` + +Common patterns: + +```typescript +// Pattern 1: versioning-info.ts (PREFERRED - Google CM360 style) +export const DESTINATION_API_VERSION = 'v4' +export const DESTINATION_CANARY_API_VERSION = 'v5' + +// Pattern 2: config.ts (Klaviyo style) - MIGRATE TO versioning-info.ts +export const REVISION_DATE = '2025-01-15' +export const API_URL = 'https://api.example.com' + +// Pattern 3: Inline constant - MIGRATE TO versioning-info.ts +const API_VERSION = 'v3' +const BASE_URL = `https://api.example.com/${API_VERSION}` +``` + +**If versioning-info.ts does NOT exist, you MUST create it in Step 4.** + +## Step 2: Changelog Analysis + +### 2.1 Determine Changelog Location + +Check if `versioning-info.ts` has a changelog URL comment: + +```typescript +/** DESTINATION_API_VERSION + * API version documentation. + * Changelog: https://developers.example.com/changelog + */ +``` + +If not present, ask user for changelog URL or look for common patterns: + +- `https://developers.{destination}.com/changelog` +- `https://developers.{destination}.com/docs/api-versions` +- `https://{destination}.com/api/reference` + +### 2.2 Fetch Changelog + +Use WebFetch to retrieve changelog for the version range: + +```bash +# Fetch the main changelog page + + +# Look for version-specific pages if needed + +``` + +### 2.3 Deep Breaking Changes Analysis + +**CRITICAL: This analysis must be thorough. We cannot afford discrepancies.** + +For each API change between current and target version, check: + +#### Request Changes + +- [ ] New required parameters +- [ ] Removed or deprecated parameters +- [ ] Changed parameter types or formats +- [ ] Modified validation rules +- [ ] Different authentication methods +- [ ] New headers required +- [ ] Changed request body structure + +#### Response Changes + +- [ ] Modified response schema +- [ ] Removed response fields +- [ ] Changed field types +- [ ] Different error codes +- [ ] New error response formats +- [ ] Pagination changes + +#### Behavioral Changes + +- [ ] Rate limiting differences +- [ ] Batching size limits +- [ ] Timeout changes +- [ ] Retry logic requirements +- [ ] Idempotency key handling + +#### Endpoint Changes + +- [ ] URL pattern changes +- [ ] Method changes (GET → POST, etc.) +- [ ] Deprecated endpoints +- [ ] New endpoints replacing old ones + +Create a structured breaking changes document: + +```markdown +## Breaking Changes Analysis: {Current Version} → {Target Version} + +### Summary + +[High-level overview of changes] + +### Critical Breaking Changes + +1. **[Category]**: [Description] + - **Impact**: [How this affects our implementation] + - **Required Action**: [What needs to be changed] + - **Risk Level**: HIGH/MEDIUM/LOW + +### Non-Breaking Changes + +- [List of additive or compatible changes] + +### Deprecation Warnings + +- [Features deprecated but still functional] + +### Testing Requirements + +- [Specific scenarios that must be tested] +``` + +Save this analysis to `breaking-changes-analysis.md` in the destination directory. + +## Step 3: Git Branch Setup + +### 3.1 Pull Latest Main + +```bash +git fetch origin +git checkout main +git pull origin main +``` + +### 3.2 Create Feature Branch + +Use naming convention: `{destination}-api-{target-version}` + +```bash +git checkout -b klaviyo-api-2026-01-15 +# or +git checkout -b google-cm360-api-v5 +``` + +## Step 4: Implement Version Upgrade with Feature Flag + +### ⚠️ CRITICAL: Feature Flag is MANDATORY ⚠️ + +**ALL API version upgrades MUST be behind a feature flag.** +**If versioning-info.ts does NOT exist, you MUST create it.** + +There is NO option to upgrade the version directly without a feature flag. + +### 4.1 Check if versioning-info.ts Exists + +```bash +ls packages/destination-actions/src/destinations/{destination}/versioning-info.ts +``` + +**Two possible scenarios:** + +--- + +### 4.2 Scenario A: versioning-info.ts EXISTS + +If `versioning-info.ts` already exists, update it: + +**Step 1**: Update `versioning-info.ts` with new canary version: + +```typescript +/** DESTINATION_API_VERSION + * {Destination} API version (stable/production). + * Changelog: {URL} + */ +export const DESTINATION_API_VERSION = '{current-version}' + +/** DESTINATION_CANARY_API_VERSION + * {Destination} API version (canary/feature-flagged). + * Testing new version {target-version} behind feature flag. + */ +export const DESTINATION_CANARY_API_VERSION = '{target-version}' +``` + +**Step 2**: Verify or create helper in `utils.ts` or `functions.ts`: + +```typescript +import { Features } from '@segment/actions-core' +import { DESTINATION_API_VERSION, DESTINATION_CANARY_API_VERSION } from './versioning-info' + +export const API_VERSION = DESTINATION_API_VERSION +export const CANARY_API_VERSION = DESTINATION_CANARY_API_VERSION +export const FLAGON_NAME = '{destination-slug}-canary-version' + +export function getApiVersion(features?: Features): string { + return features && features[FLAGON_NAME] ? CANARY_API_VERSION : API_VERSION +} +``` + +**Step 3**: Update all API calls to use `getApiVersion(features)`: + +```typescript +// Before +const response = await request(`${API_URL}/${API_VERSION}/endpoint`) + +// After +const version = getApiVersion(features) +const response = await request(`${API_URL}/${version}/endpoint`) +``` + +**Step 4**: Ensure `features` parameter is passed through actions: + +```typescript +// In action's perform or performBatch +async perform(request, { payload, settings, features }) { + const version = getApiVersion(features) + // use version in API calls +} + +async performBatch(request, { payload, settings, features }) { + const version = getApiVersion(features) + // use version in API calls +} +``` + +--- + +### 4.3 Scenario B: versioning-info.ts DOES NOT EXIST + +**If versioning-info.ts doesn't exist, you MUST create it. This is mandatory.** + +**Step 1**: Create `versioning-info.ts` file: + +```typescript +/** DESTINATION_API_VERSION + * {Destination} API version (stable/production). + * Changelog: {changelog-url} + */ +export const DESTINATION_API_VERSION = '{current-version}' + +/** DESTINATION_CANARY_API_VERSION + * {Destination} API version (canary/feature-flagged). + * Testing new version {target-version} behind feature flag. + */ +export const DESTINATION_CANARY_API_VERSION = '{target-version}' +``` + +**Step 2**: Find where the current version is defined: + +Search for version constants: + +```bash +grep -r "REVISION_DATE\|API_VERSION\|VERSION" --include="*.ts" packages/destination-actions/src/destinations/{destination}/ | grep -v test | grep -v node_modules +``` + +Common locations: + +- `config.ts`: `export const REVISION_DATE = '2025-01-15'` +- `utils.ts`: `const API_VERSION = 'v4'` +- Inline in API calls: `https://api.example.com/v4/endpoint` + +**Step 3**: Update the file with the old version constant: + +If in `config.ts`: + +```typescript +// Before +export const REVISION_DATE = '2025-01-15' + +// After - import from versioning-info +import { DESTINATION_API_VERSION } from './versioning-info' +export const REVISION_DATE = DESTINATION_API_VERSION +``` + +If in `utils.ts`: + +```typescript +// Before +const API_VERSION = 'v4' + +// After - import from versioning-info +import { DESTINATION_API_VERSION } from './versioning-info' +export const API_VERSION = DESTINATION_API_VERSION +``` + +**Step 4**: Add feature flag helper to `utils.ts` or `functions.ts`: + +> ⚠️ **Import order**: All `import` statements must come first. Add `FLAGON_NAME` and `getApiVersion` exports AFTER the last import — never between imports. Violating this triggers the `import/first` lint rule and will fail CI. + +```typescript +// All imports first +import { Features } from '@segment/actions-core' +import { DESTINATION_API_VERSION, DESTINATION_CANARY_API_VERSION } from './versioning-info' +// ... all other imports ... + +// Exports after all imports +export const API_VERSION = DESTINATION_API_VERSION +export const CANARY_API_VERSION = DESTINATION_CANARY_API_VERSION +export const FLAGON_NAME = '{destination-slug}-canary-version' + +export function getApiVersion(features?: Features): string { + return features && features[FLAGON_NAME] ? CANARY_API_VERSION : API_VERSION +} +``` + +**Step 5**: Update all places where version is used: + +Find all usage sites: + +```bash +grep -r "{old-version-constant}" --include="*.ts" packages/destination-actions/src/destinations/{destination}/ +``` + +Update them to use `getApiVersion(features)`: + +```typescript +// Example 1: Direct API call +// Before +const response = await request(`https://api.example.com/v4/endpoint`) + +// After +const version = getApiVersion(features) +const response = await request(`https://api.example.com/${version}/endpoint`) + +// Example 2: In a helper function +// Before +export function buildUrl(path: string) { + return `https://api.example.com/v4/${path}` +} + +// After +export function buildUrl(path: string, features?: Features) { + const version = getApiVersion(features) + return `https://api.example.com/${version}/${path}` +} +``` + +**Step 6**: Update `extendRequest` in `index.ts` if needed: + +If the version is used in headers or base URL: + +```typescript +// Add features parameter +extendRequest({ settings, features }) { + const version = getApiVersion(features) + return { + headers: { + 'api-version': version, + // ... other headers + } + } +} +``` + +**Step 7**: Update action perform functions to pass `features`: + +```typescript +// In each action file (e.g., conversionUpload/index.ts) +async perform(request, { payload, settings, features }) { + // Make sure to pass features to any helper functions + return await sendRequest(request, settings, payload, features) +} + +async performBatch(request, { payload, settings, features }) { + return await sendRequest(request, settings, payload, features) +} +``` + +--- + +### 4.4 Verify Feature Flag Implementation + +**MANDATORY CHECKLIST** - verify ALL items before proceeding: + +```markdown +- [ ] versioning-info.ts file EXISTS with both DESTINATION_API_VERSION and DESTINATION_CANARY_API_VERSION +- [ ] FLAGON_NAME constant defined with format '{destination-slug}-canary-version' +- [ ] FLAGON_NAME and getApiVersion are placed AFTER all import statements (import/first lint rule) +- [ ] getApiVersion(features) helper function implemented in utils.ts or functions.ts +- [ ] All API calls use getApiVersion(features) instead of hardcoded version +- [ ] extendRequest passes features parameter if version is used there +- [ ] All action perform/performBatch functions accept features parameter +- [ ] Features parameter is passed to all helper functions that need it +- [ ] No hardcoded version strings remain in the codebase +- [ ] Tests can toggle feature flag +``` + +**If ANY item is not checked, the implementation is INCOMPLETE.** + +## Step 5: Update Tests + +### 5.1 Auto-Detect Test Pattern + +The test path follows the pattern: + +``` +packages/destination-actions/src/destinations/{destination}/__tests__/ +``` + +Find test files: + +```bash +find packages/destination-actions/src/destinations/{destination} -name "*.test.ts" -o -name "*.test.js" +``` + +### 5.2 Update Existing Tests + +Update existing tests to use `API_VERSION` constant instead of hardcoded version strings: + +```typescript +// Before +import Destination from '../../index' + +nock('https://api.example.com/v4/endpoint').post('').reply(200, {}) + +// After +import Destination from '../../index' +import { API_VERSION } from '../../utils' + +nock(`https://api.example.com/${API_VERSION}/endpoint`).post('').reply(200, {}) +``` + +### 5.3 Add Feature Flag Tests (MANDATORY) + +**Add a new test suite** for feature flag behavior: + +```typescript +describe('API Version Feature Flag', () => { + it('should use stable API version by default', async () => { + const event = createTestEvent({ + // ... event data + }) + + nock(`https://api.example.com/${API_VERSION}/endpoint`).post('').reply(200, {}) + + const responses = await testDestination.testAction('actionName', { + event, + mapping: { + // ... required mappings + }, + useDefaultMappings: true, + settings + // NO features parameter = uses stable version + }) + + expect(responses[0].status).toBe(200) + }) + + it('should use canary API version when feature flag is enabled', async () => { + const event = createTestEvent({ + // ... same event data + }) + + // Should call canary version endpoint + nock(`https://api.example.com/${CANARY_API_VERSION}/endpoint`).post('').reply(200, {}) + + const responses = await testDestination.testAction('actionName', { + event, + mapping: { + // ... same mappings + }, + useDefaultMappings: true, + settings, + features: { [FLAGON_NAME]: true } // Feature flag enabled + }) + + expect(responses[0].status).toBe(200) + }) +}) +``` + +**IMPORTANT**: Add these tests for EVERY action in the destination (conversionUpload, identify, track, etc.) + +### 5.4 Run Tests + +Switch to compatible Node version and run tests: + +```bash +# Switch Node version if needed +source ~/.nvm/nvm.sh && nvm use 22.13.1 + +# Run destination-specific tests +# NOTE: `yarn cloud jest --testPathPattern` fails in newer Jest versions with +# "Option was replaced by --testPathPatterns". Use npx jest directly instead: +cd packages/destination-actions && TZ=UTC npx jest "{destination}" --no-coverage +``` + +**Expected outcome**: All tests must pass. If tests fail: + +1. Review breaking changes analysis +2. Update implementation to handle differences +3. Re-run tests until all pass + +### 5.5 Handle Test Failures + +If tests fail due to breaking changes: + +1. **Identify failure cause** from test output +2. **Check breaking changes analysis** for related changes +3. **Update implementation**: + + - Modify request/response handling + - Add new required fields + - Update validation logic + - Adjust error handling + +4. **Add regression tests** for the specific breaking change +5. **Re-run tests** + +Repeat until all tests pass. + +## Step 5.5: Run API Validation Against Real Endpoints + +This step makes real HTTP calls to both the stable and canary revisions and structurally diffs the responses. It catches breaking changes that mocked unit tests cannot detect. + +### Prerequisites + +- `KLAVIYO_TEST_API_KEY` (or equivalent) set in env — use a test account, never production +- A pre-existing test list ID for the destination + +### Run the validation script + +```bash +KLAVIYO_TEST_API_KEY=xxx \ +KLAVIYO_TEST_LIST_ID=your-list-id \ +npx ts-node packages/destination-actions/src/destinations/{destination}/__validation__/validate.ts +``` + +When chamber is available: + +```bash +chamber exec {destination}-test -- npx ts-node .../validate.ts +``` + +### What it does + +- Fires each fixture against **both** revisions sequentially (stable first, then canary) +- Each write fixture uses revision-scoped identifiers (`revisionEmail(revision)`) so calls never conflict +- Normalizes non-deterministic fields (IDs, timestamps) before diffing +- Writes `__validation__/validation-report.md` — **commit this to the PR** +- Exits non-zero if any structural differences are found + +### Expected outcome + +``` +✅ All N endpoints are structurally identical across both revisions. Safe to promote canary. +``` + +If differences are found, review `validation-report.md` for the specific fields that changed and update the implementation accordingly before proceeding. + +### Notes + +- `validation-report.md` is gitignored by default but should be **force-added** to the upgrade PR as evidence +- Delete it during the cleanup phase (Step 8) when the canary is promoted to stable +- The script uses a `RUN_ID` timestamp so repeated runs never collide on the same test profiles/events + +## Step 6: Commit Changes + +### 6.1 Review Changes + +```bash +git status +git diff +``` + +Verify: + +- versioning-info.ts created or updated correctly +- Feature flag implemented properly +- All usages updated to use getApiVersion() +- Tests updated and passing +- No unintended changes + +### 6.2 Stage and Commit + +```bash +# Stage all changes +git add packages/destination-actions/src/destinations/{destination}/ + +# Commit with descriptive message +git commit -m "feat({destination}): upgrade API to {target-version} behind feature flag + +- Add/update versioning-info.ts with canary version {target-version} +- Implement feature flag '{flagon-name}' +- Update API calls to use getApiVersion() helper +- Add tests for both stable and canary versions +- All tests passing + +Breaking changes analysis in breaking-changes-analysis.md + +Co-Authored-By: Claude Sonnet 4.5 " +``` + +## Step 7: Push and Create PR + +### 7.1 Push Branch + +```bash +git push origin {branch-name} -u +``` + +### 7.2 Create Pull Request + +Use GitHub CLI to create PR with comprehensive description: + +```bash +gh pr create --title "feat({destination}): Upgrade API to {target-version} with feature flag" --body "$(cat <<'EOF' +## Summary + +Upgrades {Destination} API from **{current-version}** to **{target-version}**, deployed behind feature flag `{flagon-name}`. + +## Changes + +### Version Management +- ✅ Created/updated `versioning-info.ts` with canary pattern +- ✅ Implemented `getApiVersion(features)` helper function +- ✅ Updated all API calls to use feature-flagged version +- ✅ Added feature flag constant: `{flagon-name}` + +### Testing +- ✅ All existing tests pass +- ✅ Added tests for both stable and canary versions +- ✅ Test pattern: `destinations/{destination}` +- ✅ Test results: **{X} test suites passed, {Y} tests passed** + +## Breaking Changes + +{Insert breaking changes analysis here - read from breaking-changes-analysis.md} + +### Critical Breaking Changes +{List high-priority breaking changes} + +### Medium Priority Changes +{List medium-priority changes} + +### Low Priority / Informational +{List low-priority changes} + +## Testing Plan + +### Manual Testing Required +- [ ] Test with feature flag disabled (stable version) +- [ ] Test with feature flag enabled (canary version) +- [ ] Verify no regression in existing functionality +- [ ] Test edge cases identified in breaking changes + +### Automated Testing +- [x] Unit tests passing +- [x] Integration tests passing (if applicable) +- [x] Snapshot tests updated (if applicable) + +## Rollout Plan + +1. **Phase 1**: Merge PR, feature flag off by default +2. **Phase 2**: Enable for internal testing +3. **Phase 3**: Gradual rollout to subset of customers +4. **Phase 4**: Full rollout, promote canary to stable +5. **Phase 5**: Remove feature flag, clean up old version + +## Risk Assessment + +**Risk Level**: {HIGH/MEDIUM/LOW} + +**Mitigation**: +- Feature flag allows instant rollback +- Comprehensive test coverage +- Breaking changes documented and addressed +- Gradual rollout prevents widespread impact + +## Additional Notes + +{Any additional context, concerns, or considerations} + +--- + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +### 7.3 Update Breaking Changes Section + +After creating PR, read `breaking-changes-analysis.md` and manually insert the detailed breaking changes into the PR description by editing it: + +```bash +gh pr edit --body "$(cat )" +``` + +## Step 8: Final Verification + +### 8.1 Verify PR Created Successfully + +```bash +gh pr view --web +``` + +Check: + +- PR title is descriptive +- Breaking changes section is complete +- All checkboxes are present +- Test results are included +- Feature flag name is documented + +### 8.2 Report to User + +Provide summary: + +```markdown +✅ **API Version Upgrade Complete** + +**Destination**: {destination} +**Version**: {current-version} → {target-version} +**Feature Flag**: `{flagon-name}` +**Branch**: `{branch-name}` +**PR**: {pr-url} + +**Test Results**: + +- ✅ {X} test suites passed +- ✅ {Y} tests passed +- ⏱️ Duration: {duration}s + +**Breaking Changes**: {number} identified and documented + +**Next Steps**: + +1. Review PR and breaking changes analysis +2. Manual testing with feature flag +3. Gradual rollout plan +4. Monitor for issues + +The version upgrade is safely deployed behind the feature flag `{flagon-name}` and ready for testing. +``` + +## Troubleshooting + +### Common Issues + +**Issue**: Tests fail with authentication errors + +- **Solution**: Check if auth headers changed in new version +- **Action**: Update authentication implementation + +**Issue**: Tests fail with schema validation errors + +- **Solution**: Review request/response schema changes +- **Action**: Update payload types and validation + +**Issue**: Can't find current version + +- **Solution**: Search more broadly for version strings +- **Action**: Check all .ts files in destination directory + +**Issue**: Feature flag not working + +- **Solution**: Verify features parameter is passed through +- **Action**: Check extendRequest and perform functions + +**Issue**: Breaking changes analysis incomplete + +- **Solution**: Fetch additional documentation pages +- **Action**: Search for migration guides, release notes + +## Best Practices + +1. **Always create versioning-info.ts if it doesn't exist** - No exceptions +2. **Always use feature flags for version upgrades** - No direct version changes +3. **Always add tests for both versions** - Stable and canary +4. **Never skip breaking changes analysis** - Be thorough +5. **Run tests multiple times** to ensure consistency +6. **Document all assumptions** in PR description +7. **Keep stable version unchanged** until canary is validated +8. **Use descriptive feature flag names** with destination prefix +9. **Update changelog URL** in versioning-info.ts comments +10. **Plan gradual rollout** in PR description + +## Success Criteria + +Before marking complete, verify: + +- [ ] versioning-info.ts file exists (created or updated) +- [ ] Feature flag implemented correctly +- [ ] getApiVersion(features) helper function exists +- [ ] All API calls use getApiVersion(features) +- [ ] Tests for both stable and canary versions added +- [ ] All tests passing (100% pass rate) +- [ ] Breaking changes documented thoroughly +- [ ] PR created with comprehensive description +- [ ] Branch pushed to remote +- [ ] No merge conflicts +- [ ] Code review requested (automatic) +- [ ] User understands rollout plan + +## Reference Files + +The skill may need to read these for context: + +- `references/common-patterns.md` - Common destination patterns +- `references/feature-flags.md` - Feature flag best practices +- `references/testing-guide.md` - Testing strategies + +## Notes + +- **versioning-info.ts is MANDATORY** - Create it if it doesn't exist +- **Feature flags are MANDATORY** - All upgrades must be behind a flag +- **Tests for both versions are MANDATORY** - No exceptions +- Feature flags are managed by Segment's infrastructure team +- Reviewer assignment is automatic via CODEOWNERS +- Breaking changes analysis is the most critical step +- When in doubt, be more thorough, not less +- This workflow protects production systems - don't rush diff --git a/.claude/skills/api-version-upgrade/evals/evals.json b/.claude/skills/api-version-upgrade/evals/evals.json new file mode 100644 index 00000000000..8d00d751773 --- /dev/null +++ b/.claude/skills/api-version-upgrade/evals/evals.json @@ -0,0 +1,29 @@ +{ + "skill_name": "api-version-upgrade", + "evals": [ + { + "id": 1, + "name": "klaviyo-simple-to-canary-migration", + "prompt": "Upgrade Klaviyo destination from version 2025-01-15 to 2026-01-15. The destination currently uses a simple REVISION_DATE constant in config.ts without feature flags. Please migrate it to use the canary pattern with feature flags, analyze breaking changes from the Klaviyo API changelog (https://developers.klaviyo.com/en/docs/changelog), run all tests, and create a PR. The Klaviyo API uses the revision date as an HTTP header.", + "expected_output": "Complete upgrade with:\n- versioning-info.ts created with both versions\n- config.ts updated to import from versioning-info\n- buildHeaders() updated to accept features parameter\n- getApiRevision() helper function created\n- extendRequest updated to pass features\n- All tests passing for both versions\n- Breaking changes analysis document\n- PR created with detailed description\n- Branch pushed to origin", + "files": [], + "assertions": [] + }, + { + "id": 2, + "name": "google-cm360-canary-update", + "prompt": "hey can you bump Google Campaign Manager 360 from v4 to v5? they already have the canary pattern set up with versioning-info.ts and feature flags. just need to update the CANARY version from v5 to v6 and check for breaking changes. their changelog is at https://developers.google.com/doubleclick-advertisers/deprecation. run tests and push a pr when done", + "expected_output": "Complete upgrade with:\n- GOOGLE_CM360_CANARY_API_VERSION updated to 'v6'\n- GOOGLE_CM360_API_VERSION remains 'v4' (stable)\n- Breaking changes fetched from Google docs\n- Comprehensive analysis of v4→v6 changes\n- All tests passing (stable and canary)\n- Feature flag tests verified\n- PR created with breaking changes\n- Branch created and pushed", + "files": [], + "assertions": [] + }, + { + "id": 3, + "name": "new-destination-first-version", + "prompt": "We're adding a new destination called 'amplitude-web' that uses API version 'v2' from https://amplitude.com/api/v2. It currently has the version hardcoded in utils.ts as a constant. Can you set up the versioning structure properly with v1 as stable and v2 as canary behind a feature flag? Also check https://amplitude.com/docs/apis/api-changelog for breaking changes between v1 and v2.", + "expected_output": "Complete setup with:\n- versioning-info.ts created from scratch\n- AMPLITUDE_WEB_API_VERSION = 'v1'\n- AMPLITUDE_WEB_CANARY_API_VERSION = 'v2'\n- getApiVersion() helper created\n- FLAGON_NAME defined\n- All API call sites updated to use helper\n- Tests added for both versions\n- Breaking changes analysis\n- PR with full implementation", + "files": [], + "assertions": [] + } + ] +} diff --git a/.claude/skills/api-version-upgrade/references/common-patterns.md b/.claude/skills/api-version-upgrade/references/common-patterns.md new file mode 100644 index 00000000000..e1828e3cdd9 --- /dev/null +++ b/.claude/skills/api-version-upgrade/references/common-patterns.md @@ -0,0 +1,334 @@ +# Common API Versioning Patterns in Action Destinations + +This document catalogs the different patterns used for API versioning across Segment Action Destinations. + +## Pattern 1: Canary with versioning-info.ts (Preferred) + +**Used by**: Google Campaign Manager 360, The Trade Desk CRM + +**Structure**: + +``` +destination/ +├── versioning-info.ts # Version constants +├── utils.ts # Helper with getApiVersion() +└── actions/ + └── someAction/ + └── index.ts # Uses getApiVersion(features) +``` + +**versioning-info.ts**: + +```typescript +/** DESTINATION_API_VERSION + * Destination API version (stable/production). + * API reference: https://... + */ +export const DESTINATION_API_VERSION = 'v4' + +/** DESTINATION_CANARY_API_VERSION + * Destination API version (canary/feature-flagged). + */ +export const DESTINATION_CANARY_API_VERSION = 'v5' +``` + +**utils.ts** (or functions.ts): + +```typescript +import { Features } from '@segment/actions-core' +import { DESTINATION_API_VERSION, DESTINATION_CANARY_API_VERSION } from './versioning-info' + +export const API_VERSION = DESTINATION_API_VERSION +export const CANARY_API_VERSION = DESTINATION_CANARY_API_VERSION +export const FLAGON_NAME = 'destination-canary-api-version' + +export function getApiVersion(features?: Features): string { + return features && features[FLAGON_NAME] ? CANARY_API_VERSION : API_VERSION +} + +// Usage in API calls +export async function send(request, settings, payload, features) { + const version = getApiVersion(features) + const response = await request(`${BASE_URL}/${version}/endpoint`, { + method: 'POST', + json: payload + }) + return response +} +``` + +**index.ts** (destination definition): + +```typescript +extendRequest({ settings, features }) { + return { + headers: buildHeaders(settings.api_key, features) + } +} +``` + +**Advantages**: + +- Clean separation of version config +- Feature flag allows safe testing +- Easy to promote canary to stable +- Consistent pattern across destinations +- Gradual rollout capability + +## Pattern 2: Simple Constant in config.ts + +**Used by**: Klaviyo (before migration) + +**Structure**: + +``` +destination/ +├── config.ts # Version constant +├── functions.ts # Uses version in buildHeaders() +└── actions/ + └── someAction/ + └── index.ts # Uses buildHeaders() +``` + +**config.ts**: + +```typescript +export const API_URL = 'https://a.klaviyo.com/api' +export const REVISION_DATE = '2025-01-15' +``` + +**functions.ts**: + +```typescript +import { API_URL, REVISION_DATE } from './config' + +export function buildHeaders(authKey: string) { + return { + Authorization: `Klaviyo-API-Key ${authKey}`, + Accept: 'application/json', + revision: REVISION_DATE, // ← Used as HTTP header + 'Content-Type': 'application/json' + } +} +``` + +**index.ts**: + +```typescript +extendRequest({ settings }) { + return { + headers: buildHeaders(settings.api_key) + } +} +``` + +**Limitations**: + +- No feature flag support +- No canary testing capability +- Direct change to production version +- Harder to roll back if issues arise + +**Migration path**: Convert to Pattern 1 + +## Pattern 3: Inline Constants + +**Used by**: Some older destinations + +**Structure**: + +```typescript +// Directly in utils.ts or functions.ts +const API_VERSION = 'v3' +const BASE_URL = `https://api.example.com/${API_VERSION}` + +export async function sendData(request, payload) { + return await request(`${BASE_URL}/endpoint`, { + method: 'POST', + json: payload + }) +} +``` + +**Limitations**: + +- Hardcoded, scattered across codebase +- No feature flag support +- Difficult to test multiple versions +- Hard to track version changes + +**Migration path**: + +1. Extract to config.ts +2. Then migrate to Pattern 1 (versioning-info.ts) + +## Pattern 4: Version in URL Path vs Header + +Some APIs use version differently: + +### URL Path Version + +```typescript +const BASE_URL = `https://api.example.com/${API_VERSION}` +// Results in: https://api.example.com/v4/users +``` + +### Header Version (Klaviyo) + +```typescript +headers: { + 'revision': '2025-01-15' // ← Version as header +} +// URL stays: https://a.klaviyo.com/api/profiles/ +``` + +### Query Parameter Version + +```typescript +const url = `${BASE_URL}/endpoint?api-version=${API_VERSION}` +// Results in: https://api.example.com/endpoint?api-version=v4 +``` + +## Feature Flag Naming Convention + +**Pattern**: `{destination-slug}-canary-api-version` + +**Examples**: + +- `cm360-canary-api-version` (Google Campaign Manager 360) +- `klaviyo-canary-api-version` (Klaviyo) +- `the-trade-desk-crm-canary-api-version` (The Trade Desk CRM) + +**Usage in code**: + +```typescript +export const FLAGON_NAME = 'destination-canary-api-version' + +// In actions +async perform(request, { payload, settings, features }) { + const version = getApiVersion(features) + // use version... +} +``` + +## Testing Both Versions + +**Standard pattern**: + +```typescript +import { FLAGON_NAME } from '../utils' + +describe('SomeAction', () => { + it('should use stable version by default', async () => { + const responses = await testDestination.testAction('actionName', { + event, + mapping, + settings + // features undefined = stable version + }) + + expect(mockRequest).toHaveBeenCalledWith(expect.stringContaining(API_VERSION)) + }) + + it('should use canary version with feature flag', async () => { + const responses = await testDestination.testAction('actionName', { + event, + mapping, + settings, + features: { [FLAGON_NAME]: true } // ← Enable canary + }) + + expect(mockRequest).toHaveBeenCalledWith(expect.stringContaining(CANARY_API_VERSION)) + }) +}) +``` + +## Migration Checklist: Simple → Canary Pattern + +When migrating from Pattern 2/3 to Pattern 1: + +- [ ] Create `versioning-info.ts` with both versions +- [ ] Export stable version with current value +- [ ] Export canary version with new value +- [ ] Add JSDoc comments with changelog URL +- [ ] Create `getApiVersion(features)` helper +- [ ] Define `FLAGON_NAME` constant +- [ ] Update `config.ts` to import from versioning-info +- [ ] Update all API call sites to use `getApiVersion()` +- [ ] Add `features` parameter to helper functions +- [ ] Update `extendRequest` to pass features +- [ ] Update action `perform` functions to receive features +- [ ] Add tests for both versions +- [ ] Verify feature flag toggles correctly +- [ ] Document in PR + +## Real-World Examples + +### Google Campaign Manager 360 + +```typescript +// versioning-info.ts +export const GOOGLE_CM360_API_VERSION = 'v4' // stable +export const GOOGLE_CM360_CANARY_API_VERSION = 'v5' // canary + +// utils.ts +export const FLAGON_NAME = 'cm360-canary-api-version' +export function getApiVersion(features?: Features): string { + return features && features[FLAGON_NAME] ? CANARY_API_VERSION : API_VERSION +} + +// Usage +const version = getApiVersion(features) +const url = `https://dfareporting.googleapis.com/dfareporting/${version}/userprofiles/...` +``` + +### The Trade Desk CRM + +```typescript +// versioning-info.ts +export const THE_TRADE_DESK_CRM_API_VERSION = 'v3' + +// functions.ts +const BASE_URL = `https://api.thetradedesk.com/${THE_TRADE_DESK_CRM_API_VERSION}` +export const TTD_LEGACY_FLOW_FLAG_NAME = 'actions-the-trade-desk-crm-legacy-flow' +``` + +### Klaviyo (Simple → Canary Migration) + +```typescript +// BEFORE (config.ts) +export const REVISION_DATE = '2025-01-15' + +// AFTER (versioning-info.ts) +export const KLAVIYO_API_VERSION = '2025-01-15' +export const KLAVIYO_CANARY_API_VERSION = '2026-01-15' + +// AFTER (config.ts) - maintain compatibility +import { KLAVIYO_API_VERSION } from './versioning-info' +export const REVISION_DATE = KLAVIYO_API_VERSION + +// AFTER (functions.ts) - add feature flag support +export const FLAGON_NAME = 'klaviyo-canary-api-version' +export function getApiRevision(features?: Features): string { + return features && features[FLAGON_NAME] ? KLAVIYO_CANARY_API_VERSION : KLAVIYO_API_VERSION +} + +export function buildHeaders(authKey: string, features?: Features) { + return { + Authorization: `Klaviyo-API-Key ${authKey}`, + Accept: 'application/json', + revision: getApiRevision(features), // ← Dynamic based on feature flag + 'Content-Type': 'application/json' + } +} +``` + +## Key Takeaways + +1. **Always use Pattern 1** (canary with versioning-info.ts) for new destinations +2. **Migrate older patterns** when doing version upgrades +3. **Feature flags are mandatory** for API version changes +4. **Test both versions** in your test suite +5. **Document changelog URL** in version constant comments +6. **Use consistent naming** for feature flags +7. **Pass features through** the entire call chain diff --git a/.claude/skills/api-version-upgrade/references/feature-flags.md b/.claude/skills/api-version-upgrade/references/feature-flags.md new file mode 100644 index 00000000000..4b4c55a1148 --- /dev/null +++ b/.claude/skills/api-version-upgrade/references/feature-flags.md @@ -0,0 +1,560 @@ +# Feature Flags Best Practices + +## Overview + +Feature flags (also called "feature toggles" or "feature switches") allow deploying code changes that can be toggled on/off without redeploying. For API version upgrades, they enable safe, gradual rollouts with instant rollback capability. + +## Why Feature Flags for API Upgrades? + +### Risk Mitigation + +- **Instant rollback** - Disable problematic version immediately +- **Gradual rollout** - Test with small percentage before full deployment +- **A/B testing** - Compare performance between versions +- **Safe production testing** - Test in production with real traffic +- **Zero-downtime upgrades** - Switch versions without redeployment + +### Business Benefits + +- **Reduced deployment risk** - Can deploy anytime without fear +- **Faster iteration** - No need to wait for perfect implementation +- **Customer segmentation** - Roll out to specific customers first +- **Emergency brake** - Quick mitigation if issues arise + +## Feature Flag Lifecycle + +``` +┌─────────────┐ +│ Develop │ Create code with flag, default OFF +└──────┬──────┘ + │ +┌──────▼──────┐ +│ Merge │ Flag in codebase, still OFF in production +└──────┬──────┘ + │ +┌──────▼──────┐ +│ Deploy │ Code deployed, flag still OFF (no risk) +└──────┬──────┘ + │ +┌──────▼──────┐ +│ Test (1%) │ Enable for 1% of traffic, monitor closely +└──────┬──────┘ + │ +┌──────▼──────┐ +│ Expand (10%)│ Gradually increase percentage +└──────┬──────┘ + │ +┌──────▼──────┐ +│ Full (100%) │ Enable for all traffic +└──────┬──────┘ + │ +┌──────▼──────┐ +│ Promote │ Make new version default (stable) +└──────┬──────┘ + │ +┌──────▼──────┐ +│ Cleanup │ Remove flag and old code after validation period +└─────────────┘ +``` + +## Implementation Pattern + +### Standard Structure + +**versioning-info.ts**: + +```typescript +/** DESTINATION_API_VERSION + * Destination API version (stable/production). + * Used by default when feature flag is not enabled. + * Changelog: https://... + */ +export const DESTINATION_API_VERSION = 'v4' + +/** DESTINATION_CANARY_API_VERSION + * Destination API version (canary/testing). + * Used when feature flag is enabled. + * Testing migration from v4 to v5. + */ +export const DESTINATION_CANARY_API_VERSION = 'v5' +``` + +**utils.ts** (or functions.ts): + +```typescript +import { Features } from '@segment/actions-core' +import { DESTINATION_API_VERSION, DESTINATION_CANARY_API_VERSION } from './versioning-info' + +export const API_VERSION = DESTINATION_API_VERSION +export const CANARY_API_VERSION = DESTINATION_CANARY_API_VERSION + +/** Feature flag name for canary API version testing */ +export const FLAGON_NAME = 'destination-canary-api-version' + +/** + * Get API version based on feature flag. + * @param features - Feature flags object from request context + * @returns API version to use (stable or canary) + */ +export function getApiVersion(features?: Features): string { + return features && features[FLAGON_NAME] ? CANARY_API_VERSION : API_VERSION +} +``` + +### Usage in Code + +**Action implementation**: + +```typescript +export default { + perform: async (request, { payload, settings, features }) => { + const version = getApiVersion(features) + + const response = await request(`https://api.example.com/${version}/endpoint`, { + method: 'POST', + json: payload + }) + + return response + } +} +``` + +**Shared utilities**: + +```typescript +export function buildHeaders(authKey: string, features?: Features) { + const version = getApiVersion(features) + + return { + Authorization: `Bearer ${authKey}`, + 'X-API-Version': version, + 'Content-Type': 'application/json' + } +} +``` + +**Request extension**: + +```typescript +export default { + extendRequest({ settings, features }) { + return { + headers: buildHeaders(settings.api_key, features) + } + } +} +``` + +## Naming Conventions + +### Feature Flag Names + +**Format**: `{destination-slug}-canary-api-version` + +**Examples**: + +- `klaviyo-canary-api-version` +- `google-cm360-canary-api-version` +- `facebook-custom-audiences-canary-api-version` +- `the-trade-desk-crm-canary-api-version` + +**Rules**: + +- Use destination slug (kebab-case) +- Always suffix with `-canary-api-version` +- Keep consistent across all destinations +- Document in code as `FLAGON_NAME` constant + +### Constant Names + +**versioning-info.ts**: + +```typescript +// Pattern: {DESTINATION}_API_VERSION +export const KLAVIYO_API_VERSION = '2025-01-15' +export const KLAVIYO_CANARY_API_VERSION = '2026-01-15' + +export const GOOGLE_CM360_API_VERSION = 'v4' +export const GOOGLE_CM360_CANARY_API_VERSION = 'v5' +``` + +**Function names**: + +```typescript +// Pattern: getApiVersion +export function getApiVersion(features?: Features): string + +// Alternative patterns (less common): +export function getRevision(features?: Features): string +export function getVersion(features?: Features): string +``` + +## Testing with Feature Flags + +### Test Structure + +```typescript +import { FLAGON_NAME, API_VERSION, CANARY_API_VERSION } from '../utils' +import { createTestIntegration, createTestEvent } from '@segment/actions-core' + +const testDestination = createTestIntegration(Definition) + +describe('API Version Feature Flag', () => { + describe('Stable Version (default)', () => { + it('should use stable version when flag not set', async () => { + const scope = nock('https://api.example.com').post(`/${API_VERSION}/endpoint`).reply(200, { success: true }) + + await testDestination.testAction('actionName', { + event: createTestEvent({}), + mapping: { field: 'value' }, + settings: { api_key: 'test' } + // features not provided = stable version + }) + + expect(scope.isDone()).toBeTruthy() + }) + + it('should use stable version when flag explicitly false', async () => { + const scope = nock('https://api.example.com').post(`/${API_VERSION}/endpoint`).reply(200, { success: true }) + + await testDestination.testAction('actionName', { + event: createTestEvent({}), + mapping: { field: 'value' }, + settings: { api_key: 'test' }, + features: { [FLAGON_NAME]: false } // Explicitly disabled + }) + + expect(scope.isDone()).toBeTruthy() + }) + }) + + describe('Canary Version (feature flag enabled)', () => { + it('should use canary version when flag enabled', async () => { + const scope = nock('https://api.example.com') + .post(`/${CANARY_API_VERSION}/endpoint`) + .reply(200, { success: true }) + + await testDestination.testAction('actionName', { + event: createTestEvent({}), + mapping: { field: 'value' }, + settings: { api_key: 'test' }, + features: { [FLAGON_NAME]: true } // ← Enable canary + }) + + expect(scope.isDone()).toBeTruthy() + }) + + it('should handle canary-specific response format', async () => { + nock('https://api.example.com') + .post(`/${CANARY_API_VERSION}/endpoint`) + .reply(200, { + // New response format in canary + result: { status: 'success' }, + metadata: { version: CANARY_API_VERSION } + }) + + const responses = await testDestination.testAction('actionName', { + event: createTestEvent({}), + mapping: { field: 'value' }, + settings: { api_key: 'test' }, + features: { [FLAGON_NAME]: true } + }) + + expect(responses[0].status).toBe(200) + expect(responses[0].data.metadata.version).toBe(CANARY_API_VERSION) + }) + }) + + describe('Feature Flag Isolation', () => { + it('should not affect other feature flags', async () => { + const scope = nock('https://api.example.com').post(`/${CANARY_API_VERSION}/endpoint`).reply(200, {}) + + await testDestination.testAction('actionName', { + event: createTestEvent({}), + mapping: { field: 'value' }, + settings: { api_key: 'test' }, + features: { + [FLAGON_NAME]: true, // Enable this flag + 'other-feature-flag': false, // Other flags unaffected + 'another-flag': true + } + }) + + expect(scope.isDone()).toBeTruthy() + }) + }) +}) +``` + +### Test Best Practices + +1. **Test both states** - Always test with flag on AND off +2. **Test default behavior** - Verify stable version is default +3. **Test explicit false** - Verify false value works same as undefined +4. **Test isolation** - Verify flag doesn't affect unrelated behavior +5. **Test edge cases** - Invalid flag values, missing features object +6. **Test combinations** - Multiple feature flags together + +## Rollout Strategy + +### Phase 1: Development (0%) + +- Code merged with flag OFF +- Feature in production but inactive +- No user impact +- **Duration**: Immediate (with PR merge) + +### Phase 2: Internal Testing (0.1%) + +- Enable for Segment internal workspaces +- Monitor errors, latency, correctness +- Gather feedback from internal users +- **Duration**: 1-3 days + +### Phase 3: Beta Customers (1-5%) + +- Enable for select early-adopter customers +- Monitor closely for issues +- Collect feedback and metrics +- **Duration**: 3-7 days + +### Phase 4: Gradual Rollout (5-50%) + +- Incrementally increase percentage +- Monitor error rates, performance +- Steps: 5% → 10% → 25% → 50% +- **Duration**: 7-14 days + +### Phase 5: Majority Rollout (50-95%) + +- Most users now on new version +- Old version still available as fallback +- Continue monitoring +- **Duration**: 7-14 days + +### Phase 6: Full Rollout (100%) + +- All users on new version +- Keep flag in case rollback needed +- Validate everything works +- **Duration**: 7-30 days (observation period) + +### Phase 7: Promotion + +- Make new version the stable default +- Update `DESTINATION_API_VERSION` to canary value +- Prepare new canary for next upgrade +- **Duration**: Immediate (code change) + +### Phase 8: Cleanup + +- Remove feature flag logic +- Remove old version constant +- Clean up conditional code +- **Duration**: 1-2 weeks (next sprint) + +## Monitoring and Metrics + +### Key Metrics to Track + +**Error Rates**: + +``` +error_rate_stable = errors_stable / requests_stable +error_rate_canary = errors_canary / requests_canary +delta = error_rate_canary - error_rate_stable +``` + +**Latency**: + +``` +p50_stable vs p50_canary +p95_stable vs p95_canary +p99_stable vs p99_canary +``` + +**Success Rates**: + +``` +success_rate = successful_requests / total_requests +compare: stable vs canary +``` + +**Request Volume**: + +``` +requests_per_minute_stable +requests_per_minute_canary +total_requests (both combined) +``` + +### Alert Thresholds + +**Error rate spike**: + +- **Warning**: Canary error rate > Stable error rate + 5% +- **Critical**: Canary error rate > Stable error rate + 10% +- **Action**: Investigate immediately, prepare rollback + +**Latency degradation**: + +- **Warning**: Canary p95 > Stable p95 + 20% +- **Critical**: Canary p95 > Stable p95 + 50% +- **Action**: Analyze slow requests, consider rollback + +**Success rate drop**: + +- **Warning**: Canary success rate < Stable success rate - 2% +- **Critical**: Canary success rate < Stable success rate - 5% +- **Action**: Rollback immediately + +### Rollback Triggers + +Automatic rollback if: + +1. Canary error rate > 10% absolute +2. Canary error rate > 2x stable error rate +3. Canary success rate < 90% +4. Multiple critical alerts in short timeframe +5. Customer escalation for canary users + +## Documentation Requirements + +### PR Description Template + +```markdown +## Feature Flag Details + +**Flag Name**: `destination-canary-api-version` + +**Stable Version**: v4 +**Canary Version**: v5 + +**Default State**: OFF (stable version) + +**Rollout Plan**: + +- Phase 1: Internal testing (0.1%) +- Phase 2: Beta customers (1-5%) +- Phase 3: Gradual rollout (5-50%) +- Phase 4: Full rollout (100%) +- Phase 5: Promotion to stable +- Phase 6: Cleanup + +**Monitoring**: + +- Error rates (stable vs canary) +- Latency (p50, p95, p99) +- Success rates +- Request volume + +**Rollback Criteria**: + +- Error rate > 10% +- Success rate < 90% +- Customer escalation +- Critical alerts + +**Breaking Changes**: See below + +## Breaking Changes + +[... detailed analysis ...] +``` + +### Code Comments + +```typescript +/** DESTINATION_CANARY_API_VERSION + * Canary API version for gradual rollout testing. + * + * This version is behind the feature flag 'destination-canary-api-version'. + * + * Rollout status: [UPDATE THIS] + * - Phase: Internal testing + * - Enabled: 0.1% of traffic + * - Started: 2026-03-14 + * - Expected full rollout: 2026-04-14 + * + * Known issues: None + * Breaking changes: See breaking-changes-analysis.md + */ +export const DESTINATION_CANARY_API_VERSION = 'v5' +``` + +## Common Pitfalls + +### ❌ Don't: Default to new version + +```typescript +// WRONG - New version is default +export function getApiVersion(features?: Features): string { + return features && features[FLAGON_NAME] ? API_VERSION : CANARY_API_VERSION +} +``` + +### ✅ Do: Default to stable version + +```typescript +// CORRECT - Stable version is default +export function getApiVersion(features?: Features): string { + return features && features[FLAGON_NAME] ? CANARY_API_VERSION : API_VERSION +} +``` + +### ❌ Don't: Forget to pass features through + +```typescript +// WRONG - features lost in chain +export function buildHeaders(authKey: string) { + return { + 'X-API-Version': API_VERSION // Always stable! + } +} +``` + +### ✅ Do: Thread features through call chain + +```typescript +// CORRECT - features passed through +export function buildHeaders(authKey: string, features?: Features) { + return { + 'X-API-Version': getApiVersion(features) + } +} +``` + +### ❌ Don't: Remove flag too quickly + +```typescript +// WRONG - Remove flag same day as 100% rollout +// What if there's a production issue tomorrow? +``` + +### ✅ Do: Keep flag for observation period + +```typescript +// CORRECT - Keep flag 2-4 weeks after 100% +// Allows safe rollback if issues arise +``` + +## Best Practices Summary + +1. **Always default to stable** - New version should require opt-in +2. **Test both versions** - Comprehensive test coverage for both paths +3. **Document thoroughly** - Clear comments, PR descriptions, rollout plans +4. **Monitor closely** - Track metrics during rollout +5. **Rollout gradually** - Don't jump from 0% to 100% +6. **Keep escape hatch** - Maintain ability to rollback +7. **Plan promotion** - Have strategy for making canary stable +8. **Schedule cleanup** - Don't leave flags forever +9. **Communicate status** - Keep team informed of rollout progress +10. **Learn from incidents** - Document issues and improvements + +## Resources + +- **Feature Flags at Segment**: [Internal docs link] +- **Rollout best practices**: [Internal docs link] +- **Monitoring dashboards**: [Dashboard links] +- **On-call runbooks**: [Runbook links] diff --git a/.claude/skills/api-version-upgrade/references/testing-guide.md b/.claude/skills/api-version-upgrade/references/testing-guide.md new file mode 100644 index 00000000000..3d84dae417f --- /dev/null +++ b/.claude/skills/api-version-upgrade/references/testing-guide.md @@ -0,0 +1,522 @@ +# Testing Guide for API Version Upgrades + +## Overview + +This guide covers testing strategies for API version upgrades in Segment Action Destinations, including running tests, interpreting results, and handling failures. + +## Test Organization + +### Directory Structure + +``` +packages/destination-actions/src/destinations/{destination}/ +├── __tests__/ +│ ├── index.test.ts # Main destination tests +│ ├── snapshot.test.ts # Snapshot tests +│ └── multistatus.test.ts # Multi-status response tests +├── {action1}/ +│ └── __tests__/ +│ ├── index.test.ts # Action-specific tests +│ └── snapshot.test.ts # Action snapshots +└── {action2}/ + └── __tests__/ + └── index.test.ts +``` + +## Running Tests + +### Prerequisites + +#### Node Version + +Action destinations require Node 18.17+ or 22.13+: + +```bash +# Check current version +node --version + +# Switch if needed (nvm) +source ~/.nvm/nvm.sh +nvm use 22.13.1 + +# Verify +node --version # Should show v22.13.1 +``` + +### Test Commands + +#### Run All Destination Tests + +```bash +# From repository root +TZ=UTC yarn test --testPathPattern="destinations/{destination}" --no-coverage +``` + +#### Run Specific Test File + +```bash +TZ=UTC yarn test --testPathPattern="destinations/{destination}/__tests__/index.test.ts" +``` + +#### Run With Coverage + +```bash +yarn test --testPathPattern="destinations/{destination}" --coverage +``` + +#### Run in Watch Mode + +```bash +yarn test --testPathPattern="destinations/{destination}" --watch +``` + +### Auto-Detecting Test Pattern + +The skill should automatically detect the destination name and construct the pattern: + +```typescript +// Given destination name: "klaviyo" +const testPattern = `destinations/${destinationName}` +// Results in: destinations/klaviyo + +// Run tests +const command = `TZ=UTC yarn test --testPathPattern="${testPattern}" --no-coverage` +``` + +## Test Output Interpretation + +### Success Output + +``` +PASS src/destinations/klaviyo/__tests__/index.test.ts (20.435 s) +PASS src/destinations/klaviyo/upsertProfile/__tests__/index.test.ts (21.627 s) + +Test Suites: 14 passed, 14 total +Tests: 133 passed, 133 total +Snapshots: 24 passed, 24 total +Time: 26.888 s +``` + +**Green flags**: + +- All test suites passed +- All tests passed +- All snapshots passed +- Exit code 0 + +### Failure Output + +``` +FAIL src/destinations/klaviyo/upsertProfile/__tests__/index.test.ts + ● upsertProfile › should create profile with new API version + + Expected request to be called with URL containing "2026-01-15" + but got "2025-01-15" + + 102 | expect(mockRequest).toHaveBeenCalledWith( + 103 | expect.stringContaining('2026-01-15') + > 104 | ) +``` + +**Red flags**: + +- FAIL markers +- Expected vs Received mismatches +- Snapshot mismatches +- Exit code 1 + +### Common Test Patterns + +#### Testing Feature Flag Toggle + +```typescript +import { FLAGON_NAME, API_VERSION, CANARY_API_VERSION } from '../utils' + +describe('API Version Feature Flag', () => { + const testDestination = createTestIntegration(Definition) + + beforeEach(() => { + nock('https://api.example.com') + .post(/v4|v5/) // Accept both versions + .reply(200, { success: true }) + }) + + it('uses stable version by default', async () => { + await testDestination.testAction('actionName', { + event: createTestEvent({}), + mapping: { field: 'value' }, + settings: { api_key: 'test-key' } + // No features parameter = stable version + }) + + expect(nock.pendingMocks()).toHaveLength(0) + // Verify stable version was called + }) + + it('uses canary version when feature flag enabled', async () => { + await testDestination.testAction('actionName', { + event: createTestEvent({}), + mapping: { field: 'value' }, + settings: { api_key: 'test-key' }, + features: { [FLAGON_NAME]: true } // ← Enable canary + }) + + expect(nock.pendingMocks()).toHaveLength(0) + // Verify canary version was called + }) +}) +``` + +#### Testing Request URL + +```typescript +it('should call correct API endpoint with canary version', async () => { + const scope = nock('https://api.example.com') + .post('/v5/users') // Canary version + .reply(200, { id: '123' }) + + await testDestination.testAction('actionName', { + event, + mapping, + settings, + features: { [FLAGON_NAME]: true } + }) + + expect(scope.isDone()).toBeTruthy() +}) +``` + +#### Testing Request Headers + +```typescript +it('should send correct revision header with canary version', async () => { + const scope = nock('https://api.example.com') + .post('/endpoint') + .matchHeader('revision', '2026-01-15') // Canary revision + .reply(200, {}) + + await testDestination.testAction('actionName', { + event, + mapping, + settings, + features: { [FLAGON_NAME]: true } + }) + + expect(scope.isDone()).toBeTruthy() +}) +``` + +#### Testing Response Handling + +```typescript +it('should handle canary version response format', async () => { + nock('https://api.example.com') + .post('/v5/users') + .reply(200, { + data: { id: '123', status: 'active' }, + // New response format in canary + metadata: { version: 'v5' } + }) + + const responses = await testDestination.testAction('actionName', { + event, + mapping, + settings, + features: { [FLAGON_NAME]: true } + }) + + expect(responses[0].status).toBe(200) + expect(responses[0].data).toHaveProperty('metadata') +}) +``` + +## Handling Test Failures + +### Failure Analysis Workflow + +1. **Read the error message carefully** + + - What was expected? + - What was received? + - Which line failed? + +2. **Identify the root cause** + + - Breaking API change? + - Implementation bug? + - Test needs updating? + +3. **Check breaking changes analysis** + + - Does this align with documented breaking changes? + - Is this an expected difference? + +4. **Fix and retest** + - Update implementation OR + - Update test expectations OR + - Both + +### Common Failure Scenarios + +#### Scenario 1: URL Format Changed + +**Error**: + +``` +Expected URL: https://api.example.com/v5/users +Received URL: https://api.example.com/v5/user +``` + +**Cause**: API endpoint renamed (users → user) + +**Fix**: Update endpoint in implementation: + +```typescript +// Before +const url = `${BASE_URL}/${version}/users` + +// After +const url = `${BASE_URL}/${version}/user` // Singular +``` + +#### Scenario 2: Required Field Added + +**Error**: + +``` +API returned 400: Missing required field 'client_id' +``` + +**Cause**: New API version requires additional field + +**Fix**: Add field to request: + +```typescript +// Before +const payload = { + user_id: userId, + email: email +} + +// After +const payload = { + user_id: userId, + email: email, + client_id: settings.client_id // New required field +} +``` + +#### Scenario 3: Response Schema Changed + +**Error**: + +``` +TypeError: Cannot read property 'data' of undefined +``` + +**Cause**: Response structure changed + +**Fix**: Update response handling: + +```typescript +// Before +const userId = response.data.id + +// After +const userId = response.result?.user?.id // New structure +``` + +#### Scenario 4: Authentication Method Changed + +**Error**: + +``` +401 Unauthorized: Invalid authentication method +``` + +**Cause**: API changed from API key to Bearer token + +**Fix**: Update authentication: + +```typescript +// Before +headers: { + 'X-API-Key': settings.api_key +} + +// After +headers: { + 'Authorization': `Bearer ${settings.api_token}` +} +``` + +### Snapshot Test Updates + +If snapshots need updating (when changes are intentional): + +```bash +# Update snapshots +yarn test --testPathPattern="destinations/{destination}" --updateSnapshot + +# Review changes +git diff +``` + +**⚠️ Warning**: Only update snapshots if you understand and approve the changes. Don't blindly update. + +## Testing Checklist + +Before marking tests as complete: + +### Pre-Test + +- [ ] Node version is compatible (18.17+ or 22.13+) +- [ ] All dependencies installed (`yarn install`) +- [ ] Working directory is clean +- [ ] No other tests running (can cause conflicts) + +### During Test + +- [ ] All test suites pass +- [ ] All individual tests pass +- [ ] All snapshots match +- [ ] No warnings or deprecation notices +- [ ] Tests run in reasonable time (<5 min) + +### Post-Test + +- [ ] Stable version tests pass (no feature flag) +- [ ] Canary version tests pass (with feature flag) +- [ ] Both versions tested in same test run +- [ ] No test files were skipped +- [ ] Coverage is adequate (if checking) + +### Breaking Changes Validation + +- [ ] Each documented breaking change has a test +- [ ] Tests verify new behavior works correctly +- [ ] Tests verify old behavior still works (stable version) +- [ ] Edge cases are covered + +## Debugging Test Failures + +### Enable Verbose Output + +```bash +yarn test --testPathPattern="destinations/{destination}" --verbose +``` + +### Run Single Test + +```bash +yarn test --testPathPattern="destinations/{destination}" --testNamePattern="specific test name" +``` + +### Add Debug Logging + +Temporarily add console.log in tests: + +```typescript +it('should do something', async () => { + console.log('Request:', JSON.stringify(mockRequest.mock.calls, null, 2)) + console.log('Response:', JSON.stringify(responses, null, 2)) + // ... test assertions +}) +``` + +### Check Nock Mocks + +```typescript +afterEach(() => { + if (!nock.isDone()) { + console.log('Pending mocks:', nock.pendingMocks()) + } + nock.cleanAll() +}) +``` + +### Validate Request Matching + +```typescript +// Be specific with nock matchers +nock('https://api.example.com') + .post('/v5/users', (body) => { + console.log('Request body:', body) + return true // Return false to reject + }) + .reply(200, {}) +``` + +## Performance Considerations + +### Timeouts + +Increase timeout for slow tests: + +```typescript +it('should handle large batch', async () => { + // ... test +}, 30000) // 30 second timeout +``` + +### Parallel Execution + +Tests run in parallel by default. If this causes issues: + +```bash +yarn test --testPathPattern="destinations/{destination}" --runInBand +``` + +### Test Isolation + +Ensure tests don't affect each other: + +```typescript +beforeEach(() => { + // Reset state + nock.cleanAll() +}) + +afterEach(() => { + // Clean up + nock.cleanAll() +}) +``` + +## CI/CD Integration + +Tests must pass in CI before merge: + +### Local Pre-Push Check + +```bash +# Run what CI will run +TZ=UTC yarn test --testPathPattern="destinations/{destination}" --no-coverage --ci + +# Check exit code +echo $? # Should be 0 +``` + +### CI Environment Differences + +CI environments may: + +- Use different Node versions +- Have stricter timeout limits +- Run more tests in parallel +- Have network restrictions + +Test locally in conditions similar to CI when possible. + +## Best Practices + +1. **Test both versions** - Always verify stable and canary work +2. **Use realistic data** - Test with production-like payloads +3. **Test error cases** - Don't just test happy paths +4. **Keep tests focused** - One thing per test +5. **Use descriptive names** - Test names should explain what's tested +6. **Clean up mocks** - Prevent test interference +7. **Document assumptions** - Explain why tests are written that way +8. **Run multiple times** - Ensure tests aren't flaky +9. **Check coverage** - Ensure new code paths are tested +10. **Update regularly** - Keep tests in sync with implementation diff --git a/.gitignore b/.gitignore index d90a2db08a7..a5f3934496c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ dist .eslintcache package-lock.json .env +# API upgrade validation reports — generated files, force-add to upgrade PRs only +**/__validation__/validation-report.md # JetBrains byproduct .idea coverage diff --git a/packages/destination-actions/src/destinations/klaviyo/__tests__/index.test.ts b/packages/destination-actions/src/destinations/klaviyo/__tests__/index.test.ts index 089e33ed052..735cd8ea818 100644 --- a/packages/destination-actions/src/destinations/klaviyo/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/klaviyo/__tests__/index.test.ts @@ -1,6 +1,8 @@ import nock from 'nock' import { APIError, IntegrationError, createTestEvent, createTestIntegration } from '@segment/actions-core' import Definition from '../index' +import { KLAVIYO_API_REVISION, KLAVIYO_CANARY_API_REVISION } from '../versioning-info' +import { FLAGON_NAME } from '../functions' const testDestination = createTestIntegration(Definition) @@ -144,4 +146,59 @@ describe('Klaviyo (actions)', () => { await expect(audiencePromise).rejects.toHaveProperty('status', 404) }) }) + + describe('API Revision Feature Flag', () => { + it('should use stable API revision by default', async () => { + const event = createTestEvent({ + type: 'track', + userId: 'user-123', + traits: { + email: 'test@example.com' + } + }) + + nock(API_URL) + .post('/profiles/') + .matchHeader('revision', KLAVIYO_API_REVISION) + .reply(200, { data: { id: 'profile-123' } }) + + const responses = await testDestination.testAction('upsertProfile', { + event, + settings, + useDefaultMappings: true, + mapping: { + email: { '@path': '$.traits.email' } + } + }) + + expect(responses[0].status).toBe(200) + }) + + it('should use canary API revision when feature flag is enabled', async () => { + const event = createTestEvent({ + type: 'track', + userId: 'user-123', + traits: { + email: 'test@example.com' + } + }) + + nock(API_URL) + .post('/profiles/') + .matchHeader('revision', KLAVIYO_CANARY_API_REVISION) + .reply(200, { data: { id: 'profile-123' } }) + + const responses = await testDestination.testAction('upsertProfile', { + event, + settings, + useDefaultMappings: true, + mapping: { + email: { '@path': '$.traits.email' } + }, + features: { [FLAGON_NAME]: true } + }) + + expect(responses[0].status).toBe(200) + }) + }) }) diff --git a/packages/destination-actions/src/destinations/klaviyo/__validation__/differ.ts b/packages/destination-actions/src/destinations/klaviyo/__validation__/differ.ts new file mode 100644 index 00000000000..4d87c522ad2 --- /dev/null +++ b/packages/destination-actions/src/destinations/klaviyo/__validation__/differ.ts @@ -0,0 +1,109 @@ +/** + * Structural differ for normalized API responses. + * + * Compares two normalized response objects and produces a human-readable + * diff highlighting added, removed, or changed fields. + */ + +export interface DiffResult { + hasDiff: boolean + statusChanged: boolean + changes: Change[] +} + +export interface Change { + type: 'added' | 'removed' | 'changed' + path: string + stable?: unknown + canary?: unknown +} + +export function diff(stable: unknown, canary: unknown, path = ''): Change[] { + const changes: Change[] = [] + + if (stable === canary) return changes + + // Both primitives but different values (after normalization, this means + // a structural difference — e.g. a field changed from string to number) + if (typeof stable !== 'object' || typeof canary !== 'object' || stable === null || canary === null) { + if (typeof stable !== typeof canary) { + changes.push({ type: 'changed', path, stable, canary }) + } + // Same type, different normalized values = acceptable (e.g. two s) + return changes + } + + if (Array.isArray(stable) && Array.isArray(canary)) { + // For arrays: compare element shapes using the first element as representative + const stableFirst = stable[0] + const canaryFirst = canary[0] + if (stableFirst !== undefined || canaryFirst !== undefined) { + changes.push(...diff(stableFirst, canaryFirst, `${path}[0]`)) + } + return changes + } + + if (Array.isArray(stable) !== Array.isArray(canary)) { + changes.push({ type: 'changed', path, stable: 'array', canary: typeof canary }) + return changes + } + + const stableObj = stable as Record + const canaryObj = canary as Record + + const allKeys = new Set([...Object.keys(stableObj), ...Object.keys(canaryObj)]) + + for (const key of allKeys) { + const childPath = path ? `${path}.${key}` : key + const inStable = Object.prototype.hasOwnProperty.call(stableObj, key) + const inCanary = Object.prototype.hasOwnProperty.call(canaryObj, key) + + if (inStable && !inCanary) { + changes.push({ type: 'removed', path: childPath, stable: stableObj[key] }) + } else if (!inStable && inCanary) { + changes.push({ type: 'added', path: childPath, canary: canaryObj[key] }) + } else { + changes.push(...diff(stableObj[key], canaryObj[key], childPath)) + } + } + + return changes +} + +export function diffResponses( + stableStatus: number, + stableBody: unknown, + canaryStatus: number, + canaryBody: unknown +): DiffResult { + const statusChanged = stableStatus !== canaryStatus + const bodyChanges = diff(stableBody, canaryBody) + + return { + hasDiff: statusChanged || bodyChanges.length > 0, + statusChanged, + changes: bodyChanges + } +} + +export function formatDiff(result: DiffResult, stableStatus: number, canaryStatus: number): string { + if (!result.hasDiff) return ' ✅ No structural differences' + + const lines: string[] = [] + + if (result.statusChanged) { + lines.push(` ⚠️ Status code changed: ${stableStatus} → ${canaryStatus}`) + } + + for (const change of result.changes) { + if (change.type === 'added') { + lines.push(` + added: ${change.path}`) + } else if (change.type === 'removed') { + lines.push(` - removed: ${change.path}`) + } else if (change.type === 'changed') { + lines.push(` ~ changed: ${change.path} (${JSON.stringify(change.stable)} → ${JSON.stringify(change.canary)})`) + } + } + + return lines.join('\n') +} diff --git a/packages/destination-actions/src/destinations/klaviyo/__validation__/fixtures.ts b/packages/destination-actions/src/destinations/klaviyo/__validation__/fixtures.ts new file mode 100644 index 00000000000..7fa24d2df4b --- /dev/null +++ b/packages/destination-actions/src/destinations/klaviyo/__validation__/fixtures.ts @@ -0,0 +1,279 @@ +/** + * Validation fixtures for Klaviyo API upgrade testing. + * + * One fixture per unique API endpoint. These make real HTTP calls against + * both the stable and canary revisions and diff the response shapes. + * + * Credentials are read from env vars (never hardcoded): + * KLAVIYO_TEST_API_KEY - Klaviyo private API key for test account + * KLAVIYO_TEST_LIST_ID - Pre-existing list ID in the test account + */ + +export interface Fixture { + id: string // unique name for this fixture, used in report + description: string + method: 'GET' | 'POST' | 'PATCH' | 'DELETE' + path: string + // body can be static or a function receiving the revision string. + // Use a function when the body contains identifiers that must differ + // between stable and canary calls to avoid conflicts (e.g. POST /profiles/ + // returns 409 if the same email is submitted twice sequentially). + body?: unknown | ((revision: string) => unknown) +} + +// Each run gets a unique ID so repeated runs don't collide on existing profiles/events. +const RUN_ID = Date.now() + +// Returns an email scoped to both the run and the revision being tested, +// ensuring stable and canary calls never conflict with each other. +const revisionEmail = (revision: string, suffix = '') => `validation-${revision}-${RUN_ID}${suffix}@example.com` + +const revisionExternalId = (revision: string) => `validation-${revision}-${RUN_ID}` + +// Used for read-only fixtures where a single shared email is fine +const TEST_EMAIL = `validation-${RUN_ID}@example.com` + +export function buildFixtures(listId: string): Fixture[] { + return [ + // ------------------------------------------------------------------------- + // Profiles + // ------------------------------------------------------------------------- + { + id: 'POST /profiles/ (upsert single profile)', + description: 'Create or update a profile by email', + method: 'POST', + path: '/profiles/', + body: (revision: string) => ({ + data: { + type: 'profile', + attributes: { + email: revisionEmail(revision), + external_id: revisionExternalId(revision), + first_name: 'Validation', + last_name: 'Test', + properties: { source: 'api-validation' } + } + } + }) + }, + + // ------------------------------------------------------------------------- + // Profile bulk import (batched upsert / addProfileToList) + // ------------------------------------------------------------------------- + { + id: 'POST /profile-bulk-import-jobs/ (bulk upsert, no list)', + description: 'Bulk import profiles without a list association', + method: 'POST', + path: '/profile-bulk-import-jobs/', + body: (revision: string) => ({ + data: { + type: 'profile-bulk-import-job', + attributes: { + profiles: { + data: [ + { + type: 'profile', + attributes: { + email: revisionEmail(revision, '-bulk'), + external_id: revisionExternalId(revision) + '-bulk', + first_name: 'Validation', + last_name: 'Test' + } + } + ] + } + } + } + }) + }, + { + id: 'POST /profile-bulk-import-jobs/ (bulk upsert, with list)', + description: 'Bulk import profiles with a list association', + method: 'POST', + path: '/profile-bulk-import-jobs/', + body: (revision: string) => ({ + data: { + type: 'profile-bulk-import-job', + attributes: { + profiles: { + data: [ + { + type: 'profile', + attributes: { + email: revisionEmail(revision, '-bulk-list'), + external_id: revisionExternalId(revision) + '-bulk-list' + } + } + ] + } + }, + relationships: { + lists: { + data: [{ type: 'list', id: listId }] + } + } + } + }) + }, + + // ------------------------------------------------------------------------- + // List membership + // ------------------------------------------------------------------------- + { + id: 'GET /lists/', + description: 'Fetch all lists (used for dynamic field population)', + method: 'GET', + path: '/lists/' + }, + { + id: 'GET /profiles/ (filter by email)', + description: 'Look up profile IDs by email (used before list removal)', + method: 'GET', + path: `/profiles/?filter=any(email,["${TEST_EMAIL}"])` + }, + + // ------------------------------------------------------------------------- + // Events + // ------------------------------------------------------------------------- + { + id: 'POST /events/ (single track event)', + description: 'Track a single event against a profile', + method: 'POST', + path: '/events/', + body: (revision: string) => ({ + data: { + type: 'event', + attributes: { + properties: { source: 'api-validation', plan: 'enterprise' }, + time: new Date().toISOString(), + value: 99.99, + unique_id: `validation-event-${revision}-${RUN_ID}`, + metric: { + data: { + type: 'metric', + attributes: { name: 'Validation Test Event' } + } + }, + profile: { + data: { + type: 'profile', + attributes: { email: TEST_EMAIL } + } + } + } + } + }) + }, + { + id: 'POST /event-bulk-create-jobs/ (batched track events)', + description: 'Bulk create events (batched trackEvent / orderCompleted)', + method: 'POST', + path: '/event-bulk-create-jobs/', + body: (revision: string) => ({ + data: { + type: 'event-bulk-create-job', + attributes: { + 'events-bulk-create': { + data: [ + { + type: 'event-bulk-create', + attributes: { + profile: { + data: { + type: 'profile', + attributes: { email: TEST_EMAIL } + } + }, + events: { + data: [ + { + type: 'event', + attributes: { + metric: { + data: { + type: 'metric', + attributes: { name: 'Validation Bulk Event' } + } + }, + properties: { source: 'api-validation' }, + time: new Date().toISOString(), + value: 49.99, + unique_id: `validation-bulk-event-${revision}-${RUN_ID}` + } + } + ] + } + } + } + ] + } + } + } + }) + }, + + // ------------------------------------------------------------------------- + // Subscriptions + // ------------------------------------------------------------------------- + { + id: 'POST /profile-subscription-bulk-create-jobs/ (subscribe)', + description: 'Subscribe a profile to email/SMS marketing', + method: 'POST', + path: '/profile-subscription-bulk-create-jobs/', + body: (revision: string) => ({ + data: { + type: 'profile-subscription-bulk-create-job', + attributes: { + historical_import: false, + custom_source: 'api-validation', + profiles: { + data: [ + { + type: 'profile', + attributes: { + email: revisionEmail(revision, '-sub'), + subscriptions: { + email: { marketing: { consent: 'SUBSCRIBED' } } + } + } + } + ] + } + }, + relationships: { + list: { data: { type: 'list', id: listId } } + } + } + }) + }, + { + id: 'POST /profile-subscription-bulk-delete-jobs (unsubscribe)', + description: 'Unsubscribe a profile from email/SMS marketing', + method: 'POST', + path: '/profile-subscription-bulk-delete-jobs', + body: (revision: string) => ({ + data: { + type: 'profile-subscription-bulk-delete-job', + attributes: { + profiles: { + data: [ + { + type: 'profile', + attributes: { + email: revisionEmail(revision, '-sub'), + subscriptions: { + email: { marketing: { consent: 'UNSUBSCRIBED' } } + } + } + } + ] + } + }, + relationships: { + list: { data: { type: 'list', id: listId } } + } + } + }) + } + ] +} diff --git a/packages/destination-actions/src/destinations/klaviyo/__validation__/normalizer.ts b/packages/destination-actions/src/destinations/klaviyo/__validation__/normalizer.ts new file mode 100644 index 00000000000..4b48d44a4f5 --- /dev/null +++ b/packages/destination-actions/src/destinations/klaviyo/__validation__/normalizer.ts @@ -0,0 +1,104 @@ +/** + * Normalizes API response bodies before diffing. + * + * Strips all non-deterministic values (IDs, timestamps, job statuses) + * and replaces them with type placeholders so structural diffs are clean. + * + * e.g. { id: "abc123" } → { id: "" } + * { created: "2026-04-01T..." } → { created: "" } + */ + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i +const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T[\d:.Z+-]+)?$/ +// Field names whose values are always non-deterministic +const NONDETERMINISTIC_KEYS = new Set([ + 'id', + 'job_id', + 'request_id', + 'correlation_id', + 'created', + 'updated', + 'datetime', + 'completed_at', + 'expires_at', + 'scheduled_at', + 'started_at', + 'created_at', + 'updated_at', + 'completed_count', + 'errored_count', + 'failed_count', + 'total_count' +]) + +// Field names that are status strings that may change mid-flight +const STATUS_KEYS = new Set(['status']) + +export function normalizeValue(key: string, value: unknown): unknown { + if (value === null) return null + if (value === undefined) return undefined + + if (NONDETERMINISTIC_KEYS.has(key)) { + if (typeof value === 'string') return '' + if (typeof value === 'number') return '' + return '' + } + + if (STATUS_KEYS.has(key) && typeof value === 'string') { + return '' + } + + if (typeof value === 'string') { + if (UUID_RE.test(value)) return '' + if (ISO_DATE_RE.test(value)) return '' + // Don't replace opaque IDs in values unless the key is known non-deterministic + return value + } + + if (typeof value === 'number') return value + if (typeof value === 'boolean') return value + + if (Array.isArray(value)) { + return value.map((item) => normalize(item)) + } + + if (typeof value === 'object') { + return normalize(value as Record) + } + + return value +} + +export function normalize(obj: unknown): unknown { + if (obj === null || obj === undefined) return obj + if (typeof obj !== 'object') return obj + if (Array.isArray(obj)) return obj.map((item) => normalize(item)) + + const result: Record = {} + for (const [key, value] of Object.entries(obj as Record)) { + result[key] = normalizeValue(key, value) + } + return result +} + +export interface NormalizedResponse { + status: number + body: unknown + headers: Record +} + +// Headers we care about structurally (not values like rate limit remaining) +const STRUCTURAL_HEADERS = new Set(['content-type', 'x-klaviyo-rate-limit-tier']) + +export function normalizeResponse(status: number, body: unknown, headers: Record): NormalizedResponse { + const filteredHeaders: Record = {} + for (const key of STRUCTURAL_HEADERS) { + if (headers[key]) filteredHeaders[key] = headers[key] + } + + return { + status, + body: normalize(body), + headers: filteredHeaders + } +} diff --git a/packages/destination-actions/src/destinations/klaviyo/__validation__/validate.ts b/packages/destination-actions/src/destinations/klaviyo/__validation__/validate.ts new file mode 100644 index 00000000000..1c50f867e16 --- /dev/null +++ b/packages/destination-actions/src/destinations/klaviyo/__validation__/validate.ts @@ -0,0 +1,238 @@ +#!/usr/bin/env npx ts-node +/** + * Klaviyo API Upgrade Validation Script + * + * Makes real HTTP calls against both the stable and canary API revisions, + * structurally diffs the responses, and writes a validation-report.md. + * + * Usage: + * KLAVIYO_TEST_API_KEY=xxx \ + * KLAVIYO_TEST_LIST_ID=R8kpbJ \ + * npx ts-node packages/destination-actions/src/destinations/klaviyo/__validation__/validate.ts + * + * When chamber is available: + * chamber exec klaviyo-test -- npx ts-node .../validate.ts + * + * Required env vars: + * KLAVIYO_TEST_API_KEY - Klaviyo private API key + * + * Optional env vars: + * KLAVIYO_TEST_LIST_ID - List ID for fixtures requiring a list (default: R8kpbJ) + * KLAVIYO_STABLE_REVISION - Stable revision date (default: 2025-01-15) + * KLAVIYO_CANARY_REVISION - Canary revision date (default: 2026-01-15) + */ + +import * as fs from 'fs' +import * as path from 'path' +import * as https from 'https' +import { URL } from 'url' + +import { buildFixtures, Fixture } from './fixtures' +import { normalizeResponse } from './normalizer' +import { diffResponses, formatDiff } from './differ' + +// --------------------------------------------------------------------------- +// Config from env +// --------------------------------------------------------------------------- + +const LIST_ID = process.env.KLAVIYO_TEST_LIST_ID ?? 'R8kpbJ' +const STABLE_REVISION = process.env.KLAVIYO_STABLE_REVISION ?? '2025-01-15' +const CANARY_REVISION = process.env.KLAVIYO_CANARY_REVISION ?? '2026-01-15' +const API_BASE = 'https://a.klaviyo.com/api' + +// --------------------------------------------------------------------------- +// HTTP client (no extra dependencies — uses built-in https) +// --------------------------------------------------------------------------- + +interface HttpResponse { + status: number + headers: Record + body: unknown +} + +function request(method: string, url: string, revision: string, body?: unknown): Promise { + return new Promise((resolve, reject) => { + const payload = body ? JSON.stringify(body) : undefined + const parsed = new URL(url) + + const options: https.RequestOptions = { + hostname: parsed.hostname, + path: parsed.pathname + parsed.search, + method, + headers: { + Authorization: `Klaviyo-API-Key ${process.env.KLAVIYO_TEST_API_KEY}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + revision + } + } + + if (payload && options.headers) { + options.headers['Content-Length'] = Buffer.byteLength(payload).toString() + } + + const req = https.request(options, (res) => { + let raw = '' + res.on('data', (chunk) => (raw += chunk)) + res.on('end', () => { + let parsed: unknown = raw + try { + parsed = JSON.parse(raw) + } catch { + // leave as string + } + resolve({ + status: res.statusCode ?? 0, + headers: res.headers as Record, + body: parsed + }) + }) + }) + + req.on('error', reject) + if (payload) req.write(payload) + req.end() + }) +} + +// --------------------------------------------------------------------------- +// Run a single fixture against both revisions +// --------------------------------------------------------------------------- + +interface FixtureResult { + fixture: Fixture + stable: HttpResponse + canary: HttpResponse + reportSection: string +} + +async function runFixture(fixture: Fixture): Promise { + const url = `${API_BASE}${fixture.path}` + process.stdout.write(` Running ${fixture.id}... `) + + // Resolve body — fixtures may provide a function so each revision gets + // distinct identifiers, avoiding 409 conflicts on sequential write calls + const resolveBody = (b: unknown) => (typeof b === 'function' ? (b as (r: string) => unknown)(STABLE_REVISION) : b) + const resolveCanaryBody = (b: unknown) => + typeof b === 'function' ? (b as (r: string) => unknown)(CANARY_REVISION) : b + + // Run sequentially — parallel calls to write endpoints cause race conditions + const stable = await request(fixture.method, url, STABLE_REVISION, resolveBody(fixture.body)) + await new Promise((r) => setTimeout(r, 200)) + const canary = await request(fixture.method, url, CANARY_REVISION, resolveCanaryBody(fixture.body)) + + const normalizedStable = normalizeResponse(stable.status, stable.body, stable.headers) + const normalizedCanary = normalizeResponse(canary.status, canary.body, canary.headers) + + const result = diffResponses( + normalizedStable.status, + normalizedStable.body, + normalizedCanary.status, + normalizedCanary.body + ) + + const icon = result.hasDiff ? '⚠️' : '✅' + console.log(icon) + + const reportSection = [ + `## ${fixture.id}`, + `_${fixture.description}_`, + ``, + `- Stable (${STABLE_REVISION}): **HTTP ${stable.status}**`, + `- Canary (${CANARY_REVISION}): **HTTP ${canary.status}**`, + ``, + formatDiff(result, stable.status, canary.status) + ].join('\n') + + return { fixture, stable, canary, reportSection } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + const API_KEY = process.env.KLAVIYO_TEST_API_KEY + if (!API_KEY) { + console.error('❌ KLAVIYO_TEST_API_KEY is required') + process.exit(1) + } + + console.log(`\nKlaviyo API Validation: ${STABLE_REVISION} → ${CANARY_REVISION}`) + console.log(`${'─'.repeat(60)}\n`) + + const fixtures = buildFixtures(LIST_ID) + const results: FixtureResult[] = [] + + for (const fixture of fixtures) { + try { + results.push(await runFixture(fixture)) + } catch (err) { + console.log('❌ ERROR') + results.push({ + fixture, + stable: { status: 0, headers: {}, body: null }, + canary: { status: 0, headers: {}, body: null }, + reportSection: `## ${fixture.id}\n\n❌ Error: ${(err as Error).message}` + }) + } + // Brief pause to avoid rate limiting + await new Promise((r) => setTimeout(r, 300)) + } + + const diffs = results.filter((r) => { + const norm = normalizeResponse(r.stable.status, r.stable.body, r.stable.headers) + const normC = normalizeResponse(r.canary.status, r.canary.body, r.canary.headers) + return diffResponses(norm.status, norm.body, normC.status, normC.body).hasDiff + }) + + // ------------------------------------------------------------------------- + // Write report + // ------------------------------------------------------------------------- + const report = [ + `# Klaviyo API Validation Report`, + ``, + `**Stable revision**: \`${STABLE_REVISION}\``, + `**Canary revision**: \`${CANARY_REVISION}\``, + `**Generated**: ${new Date().toISOString()}`, + `**Fixtures run**: ${results.length}`, + `**Differences found**: ${diffs.length}`, + ``, + diffs.length === 0 + ? `> ✅ All ${results.length} endpoints are structurally identical across both revisions. Safe to promote canary.` + : `> ⚠️ ${diffs.length} endpoint(s) have structural differences. Review before promoting canary.`, + ``, + `---`, + ``, + ...results.map((r) => r.reportSection), + ``, + `---`, + ``, + `_This file is generated by the api-version-upgrade validation script._`, + `_Delete this file when the canary revision is promoted to stable._` + ].join('\n') + + const reportPath = path.join(__dirname, 'validation-report.md') + fs.writeFileSync(reportPath, report, 'utf8') + + console.log(`\n${'─'.repeat(60)}`) + console.log(`Fixtures: ${results.length} run, ${diffs.length} with differences`) + console.log(`Report written to: ${reportPath}`) + + if (diffs.length > 0) { + console.log(`\n⚠️ Differences detected in:`) + diffs.forEach((r) => console.log(` - ${r.fixture.id}`)) + process.exit(1) + } else { + console.log(`\n✅ All clear — no structural differences between revisions`) + process.exit(0) + } +} + +// Only run when executed directly (not when imported by type generators or tests) +if (require.main === module) { + main().catch((err) => { + console.error('Fatal error:', err) + process.exit(1) + }) +} diff --git a/packages/destination-actions/src/destinations/klaviyo/__validation__/validation-report.md b/packages/destination-actions/src/destinations/klaviyo/__validation__/validation-report.md new file mode 100644 index 00000000000..f8591cb12c8 --- /dev/null +++ b/packages/destination-actions/src/destinations/klaviyo/__validation__/validation-report.md @@ -0,0 +1,97 @@ +# Klaviyo API Validation Report + +**Stable revision**: `2025-01-15` +**Canary revision**: `2026-01-15` +**Generated**: 2026-03-31T21:32:52.933Z +**Fixtures run**: 9 +**Differences found**: 0 + +> ✅ All 9 endpoints are structurally identical across both revisions. Safe to promote canary. + +--- + +## POST /profiles/ (upsert single profile) + +_Create or update a profile by email_ + +- Stable (2025-01-15): **HTTP 201** +- Canary (2026-01-15): **HTTP 201** + + ✅ No structural differences + +## POST /profile-bulk-import-jobs/ (bulk upsert, no list) + +_Bulk import profiles without a list association_ + +- Stable (2025-01-15): **HTTP 202** +- Canary (2026-01-15): **HTTP 202** + + ✅ No structural differences + +## POST /profile-bulk-import-jobs/ (bulk upsert, with list) + +_Bulk import profiles with a list association_ + +- Stable (2025-01-15): **HTTP 202** +- Canary (2026-01-15): **HTTP 202** + + ✅ No structural differences + +## GET /lists/ + +_Fetch all lists (used for dynamic field population)_ + +- Stable (2025-01-15): **HTTP 200** +- Canary (2026-01-15): **HTTP 200** + + ✅ No structural differences + +## GET /profiles/ (filter by email) + +_Look up profile IDs by email (used before list removal)_ + +- Stable (2025-01-15): **HTTP 200** +- Canary (2026-01-15): **HTTP 200** + + ✅ No structural differences + +## POST /events/ (single track event) + +_Track a single event against a profile_ + +- Stable (2025-01-15): **HTTP 202** +- Canary (2026-01-15): **HTTP 202** + + ✅ No structural differences + +## POST /event-bulk-create-jobs/ (batched track events) + +_Bulk create events (batched trackEvent / orderCompleted)_ + +- Stable (2025-01-15): **HTTP 202** +- Canary (2026-01-15): **HTTP 202** + + ✅ No structural differences + +## POST /profile-subscription-bulk-create-jobs/ (subscribe) + +_Subscribe a profile to email/SMS marketing_ + +- Stable (2025-01-15): **HTTP 202** +- Canary (2026-01-15): **HTTP 202** + + ✅ No structural differences + +## POST /profile-subscription-bulk-delete-jobs (unsubscribe) + +_Unsubscribe a profile from email/SMS marketing_ + +- Stable (2025-01-15): **HTTP 202** +- Canary (2026-01-15): **HTTP 202** + + ✅ No structural differences + +--- + +_This file is generated by the api-version-upgrade validation script._ +_Delete this file when the canary revision is promoted to stable._ diff --git a/packages/destination-actions/src/destinations/klaviyo/breaking-changes-analysis.md b/packages/destination-actions/src/destinations/klaviyo/breaking-changes-analysis.md new file mode 100644 index 00000000000..d2015310093 --- /dev/null +++ b/packages/destination-actions/src/destinations/klaviyo/breaking-changes-analysis.md @@ -0,0 +1,70 @@ +# Breaking Changes Analysis: Klaviyo API 2025-01-15 → 2026-01-15 + +## Summary + +Four GA releases between 2025-01-15 and 2026-01-15: **2025-04-15**, **2025-07-15**, **2025-10-15**, **2026-01-15**. All changes in this window are **additive** — no breaking changes were introduced after 2025-01-15. + +**Risk Level: LOW** + +## Critical Breaking Changes + +None. All changes between these two revisions are additive (new endpoints, new optional fields, new resources). + +## Non-Breaking Changes by Release + +### 2025-04-15 GA + +- **Web Feeds API**: New CRUD endpoints for web feeds. +- **Custom Metrics API**: New API for custom metrics (1 per standard account). +- **Reviews Client APIs**: New client-side review endpoints. +- **Push Token APIs**: New endpoints for managing push tokens; push tokens now includable on `GET /api/profiles`. +- **Campaigns API**: Added push badge settings to `campaign-message` resource (optional field). +- **Templates API**: Optional `amp` field for AMP email template versions. + +### 2025-07-15 GA + +- **Mapped Metrics API**: New endpoints for mapped metrics retrieval and configuration. +- **Custom Objects API**: New endpoints for ingesting and retrieving custom objects from third-party sources. +- **Data Source APIs**: New CRUD endpoints for data sources. +- **Universal Content API**: Extended support for additional block types (button, divider, image, spacer, etc.). +- **Update Flow Action API**: New `PATCH /api/flow-actions/{id}` endpoint (beta). + +### 2025-10-15 GA + +- **Forms API** (graduated from beta): `GET` and `POST /api/forms` endpoints. +- **Flow Actions API** (graduated from beta): Full flow actions and flow messages retrieval. +- **Coupon Codes API**: `DELETE /api/coupon-codes/{id}` endpoint added. +- **Profiles API**: Added `subscriptions` field to profile inclusions. +- **Universal Content**: Enrolled accounts can now create/update universal content blocks. + +### 2026-01-15 GA + +- **Catalog Variants API**: New endpoints for managing catalog item variants. +- **Tags API enhancements**: Extended tagging support to additional resource types. +- **Reporting API additions**: New fields in campaign and flow reporting responses. +- **Additional optional fields**: Various optional fields added across existing endpoints. + +## Deprecation Notices + +Per Klaviyo's **2-year API lifecycle policy**, revisions older than 2 years enter "Retired" status (return 410 errors). Revision **2024-02-15** and earlier are approaching or past the retirement window. This upgrade from `2025-01-15` to `2026-01-15` is forward-compatible. + +## Impact on Current Klaviyo Destination Implementation + +Our destination uses: + +- `POST /api/profiles/` — No changes +- `POST /api/profile-bulk-import-jobs/` — No changes +- `POST /api/profile-subscription-bulk-create-job` — No changes (subscriptions field already supported) +- `POST /api/profile-subscription-bulk-delete-job` — No changes +- `POST /api/event-bulk-create-jobs/` — No changes +- `GET /api/profiles/` — No changes +- `GET/DELETE /api/lists/` — No changes +- `POST /api/data-privacy-deletion-jobs/` — No changes + +**All endpoints used by this destination are unaffected by this version upgrade.** + +## Testing Requirements + +- Verify `revision` header correctly sends `2025-01-15` without feature flag +- Verify `revision` header correctly sends `2026-01-15` with feature flag enabled +- All existing action tests should pass without modification diff --git a/packages/destination-actions/src/destinations/klaviyo/config.ts b/packages/destination-actions/src/destinations/klaviyo/config.ts index 4c57095c2a5..671ece6037e 100644 --- a/packages/destination-actions/src/destinations/klaviyo/config.ts +++ b/packages/destination-actions/src/destinations/klaviyo/config.ts @@ -1,5 +1,7 @@ +import { KLAVIYO_API_REVISION } from './versioning-info' + export const API_URL = 'https://a.klaviyo.com/api' -export const REVISION_DATE = '2025-01-15' +export const REVISION_DATE = KLAVIYO_API_REVISION export const COUNTRY_CODES = [ { label: 'AD - Andorra', value: 'AD' }, { label: 'AE - United Arab Emirates', value: 'AE' }, diff --git a/packages/destination-actions/src/destinations/klaviyo/functions.ts b/packages/destination-actions/src/destinations/klaviyo/functions.ts index bb0efcaf0c9..a3d4a02261d 100644 --- a/packages/destination-actions/src/destinations/klaviyo/functions.ts +++ b/packages/destination-actions/src/destinations/klaviyo/functions.ts @@ -8,9 +8,11 @@ import { HTTPError, MultiStatusResponse, ErrorCodes, - StatsContext + StatsContext, + Features } from '@segment/actions-core' -import { API_URL, REVISION_DATE } from './config' +import { API_URL } from './config' +import { KLAVIYO_API_REVISION, KLAVIYO_CANARY_API_REVISION } from './versioning-info' import { Settings } from './generated-types' import { KlaviyoAPIError, @@ -38,6 +40,12 @@ import { Payload as AddProfileToListPayload } from './addProfileToList/generated import { eventBulkCreateRegex } from './properties' import { ActionDestinationErrorResponseType } from '@segment/actions-core/destination-kittypes' +export const FLAGON_NAME = 'klaviyo-canary-version' + +export function getApiRevision(features?: Features): string { + return features && features[FLAGON_NAME] ? KLAVIYO_CANARY_API_REVISION : KLAVIYO_API_REVISION +} + const phoneUtil = PhoneNumberUtil.getInstance() export async function getListIdDynamicData(request: RequestClient): Promise { @@ -127,11 +135,11 @@ export async function createProfile( } } -export function buildHeaders(authKey: string) { +export function buildHeaders(authKey: string, features?: Features) { return { Authorization: `Klaviyo-API-Key ${authKey}`, Accept: 'application/json', - revision: REVISION_DATE, + revision: getApiRevision(features), 'Content-Type': 'application/json' } } diff --git a/packages/destination-actions/src/destinations/klaviyo/index.ts b/packages/destination-actions/src/destinations/klaviyo/index.ts index a92ecb2e339..79fdbf16c74 100644 --- a/packages/destination-actions/src/destinations/klaviyo/index.ts +++ b/packages/destination-actions/src/destinations/klaviyo/index.ts @@ -60,9 +60,9 @@ const destination: AudienceDestinationDefinition = { }) }, - extendRequest({ settings }) { + extendRequest({ settings, features }) { return { - headers: buildHeaders(settings.api_key) + headers: buildHeaders(settings.api_key, features) } }, audienceFields: { diff --git a/packages/destination-actions/src/destinations/klaviyo/versioning-info.ts b/packages/destination-actions/src/destinations/klaviyo/versioning-info.ts new file mode 100644 index 00000000000..1af7dd4d8b6 --- /dev/null +++ b/packages/destination-actions/src/destinations/klaviyo/versioning-info.ts @@ -0,0 +1,11 @@ +/** KLAVIYO_API_REVISION + * Klaviyo API revision date (stable/production). + * Changelog: https://developers.klaviyo.com/en/docs/changelog_new + */ +export const KLAVIYO_API_REVISION = '2025-01-15' + +/** KLAVIYO_CANARY_API_REVISION + * Klaviyo API revision date (canary/feature-flagged). + * Testing new revision 2026-01-15 behind feature flag. + */ +export const KLAVIYO_CANARY_API_REVISION = '2026-01-15'