Imported from DataDog/dd-flag-migration (
AGENTS.md). Install upstream withnpx skills add DataDog/dd-flag-migration. Copyright stays with the author.
Agent guidelines
Guidance for future changes to dd-flag-migration.
Customer-facing terminology
Use the term "targeting filters" or "targeting" instead of "allocations" in customer-facing text.
API specifications
Use these specifications as the source of truth when working on the API integrations:
API authentication
Never commit API keys or tokens. DD_SITE is fine to store locally for convenience, but otherwise do not save credentials.
Use the following for authentication when building new scripts that require API integrations:
- Datadog: Set
DD_API_KEY,DD_APP_KEY, andDD_SITE(the Datadog site host, such asdatadoghq.comorus5.datadoghq.com). Requests send the keys as thedd-api-keyanddd-application-keyheaders.scripts/get-datadog-flag.shrequiresDD_SITE. For the CLI, an explicit--datadog-sitetakes precedence overDD_SITE; interactive prompts may also use the saved site preference when neither is provided. - LaunchDarkly: Set
LAUNCHDARKLY_API_KEY. The scripts send it directly in theAuthorizationheader tohttps://app.launchdarkly.com. - Eppo:
yarn migraterequiresEPPO_API_KEYwhen Eppo is selected as the source provider (alongside the Datadog keys). The migration client sends it in thex-eppo-tokenheader tohttps://eppo.cloud. The standalonescripts/eppo-raw.shhelper currently readseppoApiKeyfrom$HOME/.dd-flag-migration/config.jsonand uses the same header.
Committing and Opening PRs
Use Conventional Commits for every commit message and PR title. Prefix the summary with a type followed by a colon and a space, e.g. feat: , fix: , chore: , docs: , refactor: , test: , ci: , build: , perf: .
- Commit messages:
feat: add Eppo audience sync to re-migration path - PR titles: same prefix on the PR title, e.g.
fix: preserve variant UUID on source-side rename
Re-migration sync contract
Customers typically run their source platform (LaunchDarkly or Eppo) and Datadog in parallel for weeks-to-months while migrating the code that evaluates flags. During this dual-run window the source platform is the source of truth — customers continue to edit flags there. The migration tool's job on re-migration is to make Datadog match.
When adding or changing what gets synced, classify each field into one of these tiers and follow the contract for that tier:
Tier 1 — Full sync (replace, including deletes)
The source platform owns these end-to-end. On re-migration, make Datadog match exactly — add what's new, update what changed, delete what's gone.
- Targeting rules / allocations (PUT-replace per environment)
- Default variant (per environment)
- Environment state (enablement and disablement)
- Variants (POST/PUT/DELETE via the
/feature-flags/{id}/variantssub-resource)
When introducing a new field in this tier: a missing value in the source must propagate as a removal in Datadog, not as a no-op.
Configurable sync
- Tags, including
team:*tags: interactive migrations ask whether to merge source-derived tags into existing Datadog tags or overwrite them. LaunchDarkly team tags are included only when the source team is matched to a Datadog team. Merge preserves Datadog-only tags; overwrite is Tier 1 full sync and an empty source array clears all tags. Non-interactive migrations retain overwrite as the backward-compatible default.
Tier 2 — Additive merge
Datadog-side state may exist that the source system doesn't know about, and clobbering it would be surprising. Merge: union with what's on Datadog, dedupe, preserve unrelated bindings.
- Restriction policies: editor-team principals derived from RBAC walk are merged into existing editor bindings; non-editor bindings (e.g., viewer) are preserved.
- Saved filters for LD segments / Eppo audiences: PUT-replace the body (
name,targeting_rules,description,migration_metadata) of every matched filter so source-side edits propagate, but saved filters outside the current re-migration's selection are preserved. Deletes are intentionally out of scope — a saved filter can be referenced by flags outside the current selection, so deleting one would orphan those references.
When introducing a new field in this tier: GET current state, compute union with source-derived state, PUT/POST merged result.
Tier 3 — Never sync
Identifiers and anchors that downstream Datadog references depend on. Changing them silently has high blast radius (dashboards, alerts, links, allocations).
- Flag
key(primary identifier) - Flag
id(UUID, immutable) - Variant UUID (allocations reference variants by ID)
Renames in the source platform do not propagate. Customers must delete + recreate if they truly need a different key.
When adding a new sync field
- Decide which tier it belongs to. Default to Tier 1 unless there's a concrete reason to merge or preserve.
- Wire it into both
src/eppo/index.tsandsrc/launchdarkly/index.tsre-migration paths — they have identical structure. - Each path has two sub-branches:
envsToEnable.length === 0(no active source environments; sync metadata and disable inactive mappings) andenvsToEnable.length > 0(full re-sync, including disabling inactive mappings). Cover both. - Each branch has a
dryRunand a live mode. Cover both. - Mirror the existing
migration_metadatapattern when creating new Datadog resources, withprovider: 'launchdarkly' | 'eppo'plus a source identifier. - Update the spinner success message to include counts for the new field.
When the backend gains new capabilities
Saved-filter deletes are intentionally out of scope (a saved filter can be referenced by flags outside the current re-migration's selection — deleting one orphans those references). When a new backend API ships:
- Check whether it changes the tier of an existing field. If a Tier 3 field becomes updatable, promote to Tier 1.
- Audit the dual-run divergence implication — what state does the new API let us reconcile that we couldn't before?
- Wire it through both sources, both branches, both dry-run and live paths, with tests.
Sync ordering invariants
Datadog allocations reference variants by UUID, not by key. That makes variants write-after-references: any reordering that lets an allocation reference a removed variant causes either a server-side delete rejection or a dangling reference.
Two rules follow:
- Variant deletes run last. In
syncVariants, creates and updates fire before deletes. When a caller has its own allocation rewrites to interleave, it should callsyncVariantsCreatesAndUpdatesfirst, thensyncAllocationsForEnvironment, thenapplyVariantDeletes— in that order. - Variant key is immutable; UUID is the contract. A source-side rename
changes the slugified key but the matching DD variant's
keystays put; onlyname,value, andmigration_metadatamove.planVariantSyncmatches onmigration_metadata.source_idfirst (survives renames), then falls back to key (for legacy variants migrated beforesource_idwas recorded).
The envsToEnable.length === 0 re-migration sub-branch does not rewrite
allocations, so it must skip variant deletes entirely — a delete there
would orphan UUID references that no code path is going to clean up. Tags and
restriction policy still sync, mapped inactive environments are disabled, and
variants only get creates and updates.
When adding a new source provider
A new provider integration needs to translate around the constraints below. Copying the source shape directly will produce evaluations that diverge from the source even when migration reports success.
Targeting on the subject's identity uses id, not key
Datadog's UFC evaluator only aliases the literal attribute name "id" to the
subject's targeting_key. Any condition emitted with attribute: "key" is
looked up in the attribute bag verbatim, finds nothing for SDK callers who pass
their identity via targeting_key, and fails to match — sending the subject
silently down the fallthrough.
Translate any source-side identity reference (LD individual targets,
user-kind contextTargets, rule clauses on key, equivalent Eppo / future
provider constructs) to attribute: "id" for user-kind contexts. For non-user
contexts, dot-notate the kind (see below). The targetKeyAttribute helper in
src/launchdarkly/migration.ts is the canonical implementation.
Multi-context targeting uses dot notation
Datadog has no multi-context type — there is one flat attribute bag per
evaluation. LD's per-kind context attributes (and any analogous construct in a
new provider) must be flattened to dot-prefixed attribute names on emit
(ld_application.versionName, device.os, etc.). The evaluation tool mirrors
this on the read side: synthetic test cases for non-user kinds attach
${kind}.key to the attribute bag rather than expecting Datadog to understand
a separate context object.
JSON variants cannot be top-level arrays
Datadog's variant-value schema rejects top-level JSON arrays. When the source
provider allows a variant whose value is [...], wrap it as { "value": [...] }
during migration (and unwrap symmetrically anywhere the migration tool needs to
compare against the source). Top-level objects, scalars, and null are accepted
as-is; only the array case needs the wrapper.
Required verification
yarn typecheck && yarn lint:fix && yarn test must pass before claiming a change complete. Do not skip.