Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
23fa314
FFL-3459: Support flagSelection on Flags precompute requests
mark-caii Oct 22, 2026
845e923
FFL-3459: Tighten flag selection internals
mark-caii Oct 22, 2026
afba396
FFL-3459: Merge develop into flag selection branch
mark-caii Oct 26, 2026
d61bd6e
FFL-3459: Read the ignored header only when a selection was sent
mark-caii Oct 26, 2026
9e78344
FFL-3459: Accept null lists in flagSelection
mark-caii Oct 26, 2026
e5562f4
FFL-3459: Document flagSelection limits and server behavior
mark-caii Oct 26, 2026
6766ce9
FFL-3459: Cover the shared flag selection vectors in unit tests
mark-caii Oct 26, 2026
61f6476
FFL-3459: Ignore an empty flag selection ignored header
mark-caii Oct 27, 2026
e9a6306
FFL-3459: Cover selection wiring, legacy caches and the trim set
mark-caii Oct 27, 2026
2d05334
FFL-3459: Simplify flag selection internals and tests
mark-caii Oct 27, 2026
b754a44
FFL-3459: Merge develop into flag selection branch
mark-caii Oct 28, 2026
ec0039c
FFL-3459: Test the ignored reason with an unparseable body
mark-caii Oct 28, 2026
a8cfcab
FFL-3459: Assert the selection reaches the request factory
mark-caii Oct 28, 2026
fce8625
FFL-3459: Describe flag selection rules without backend names
mark-caii Oct 28, 2026
1cc6771
FFL-3459: Document flagSelection in the module README
mark-caii Oct 28, 2026
d365cbb
FFL-3459: Run every shared selection vector case by name
mark-caii Oct 28, 2026
f80f2c3
FFL-3459: Test the ignored header name without case sensitivity
mark-caii Oct 28, 2026
f79b4d7
FFL-3459: Test equivalent selections and empty configurations in the …
mark-caii Oct 28, 2026
ca5b080
FFL-3459: Test selections with no values after canonicalization
mark-caii Oct 28, 2026
db50cef
FFL-3459: Drop a selection with non-string elements instead of throwing
mark-caii Oct 28, 2026
765454c
FFL-3459: Remove the region markers from the flag selection client test
mark-caii Oct 28, 2026
ff7c444
FFL-3459: Verify the absent selection logs with verifyLog
mark-caii Oct 28, 2026
d29e8e3
FFL-3459: Cover every pair of the selection error order
mark-caii Oct 28, 2026
f68612f
FFL-3459: Keep selected flag state out of the key older SDKs read
mark-caii Oct 28, 2026
f10b90f
FFL-3459: Report only known DD-Flag-Selection-Ignored reasons
mark-caii Oct 28, 2026
344f0f8
FFL-3459: Document unknown ignored reasons and the proxy header
mark-caii Oct 28, 2026
99e58b4
FFL-3459: Rename flag presence checks to configuration checks
mark-caii Oct 29, 2026
e47cbe7
FFL-3459: Share the empty flags response in EvaluationsManagerTest
mark-caii Oct 29, 2026
9c970b1
FFL-3459: Report an ignored selection only for an accepted response
mark-caii Oct 29, 2026
75d6d26
FFL-3459: Test that interior Unicode whitespace is kept
mark-caii Oct 29, 2026
28e82cd
FFL-3459: Document that the selection applies to every client and cache
mark-caii Oct 29, 2026
a916cb2
FFL-3459: Merge develop with the experimentation opt-out
mark-caii Oct 30, 2026
00787ac
FFL-3459: Send flag selection telemetry once per process
mark-caii Oct 30, 2026
161f308
FFL-3459: Report an ignored selection only when its flags are installed
mark-caii Oct 30, 2026
8db220a
FFL-3459: Remove an inline comment from the opt-out key test
mark-caii Oct 30, 2026
e812566
FFL-3459: Merge develop into flag selection
mark-caii Nov 2, 2026
9ce29b1
FFL-3459: Clarify the flag selection scope in the README
mark-caii Nov 2, 2026
98b7fc9
RUM-19577: Break dependency of test fixtures on :tools:unit module
0xnm Nov 3, 2026
d1b0dd5
Merge pull request #4097 from DataDog/nogorodnikov/rum-19577/break-de…
0xnm Nov 3, 2026
198c684
FFL-3459: Clarify install flow logging and model server ignored reasons
mark-caii Nov 3, 2026
27a03bc
FFL-3568: Enforce shared mobile precomputed response fixtures
leoromanovsky Nov 3, 2026
3acee34
FFL-3568: Import upstream serial-ID response coverage
leoromanovsky Nov 3, 2026
9493bf6
Merge pull request #4031 from DataDog/mark.cai/FFL-3459
mark-caii Nov 3, 2026
1d77268
FFL-3568: Apply lossless numeric contract and shared boundaries
leoromanovsky Nov 3, 2026
39f4c68
FFL-3568: Merge current flag-selection integration
leoromanovsky Nov 3, 2026
15e1f1b
FFL-3568: Pin checkout and satisfy fixture importer lint
leoromanovsky Nov 3, 2026
dce73c2
FFL-3568: Sync generated obfuscation coverage
leoromanovsky Nov 3, 2026
5ff5ae5
FFL-3568: Refresh integral floating-value fixtures
leoromanovsky Nov 4, 2026
c84ae61
FFL-3568: Apply rounded numeric reads with canonical fixtures
leoromanovsky Nov 4, 2026
a45b99c
FFL-3568: Restack numeric conformance and pin merged fixtures
leoromanovsky Nov 4, 2026
96b2bf6
FFL-3568: Align numeric API documentation with the shared contract
leoromanovsky Nov 4, 2026
878dd3f
Test legacy parsing of dependent flag errors
typotter Nov 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/chainguard/self.precomputed-fixtures.sts.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
issuer: https://token.actions.githubusercontent.com
subject: repo:DataDog/dd-sdk-android:ref:refs/heads/develop
claim_pattern:
event_name: "(schedule|workflow_dispatch)"
workflow_ref: 'DataDog/dd-sdk-android/\.github/workflows/precomputed-fixtures\.yml@refs/heads/develop'
repository: "DataDog/dd-sdk-android"
permissions:
contents: write
pull_requests: write
86 changes: 86 additions & 0 deletions .github/workflows/precomputed-fixtures.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
name: Precomputed flag fixtures
on:
pull_request:
paths:
- 'ci/*precomputed*'
- 'reliability/single-fit/flags/src/test/resources/precomputed/**'
- '.github/workflows/precomputed-fixtures.yml'
schedule:
- cron: '23 8 * * 1'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: precomputed-fixtures-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: false
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- name: Verify the local snapshot
run: python3 ci/sync-precomputed-fixtures.py --verify
- name: Compare with the recorded upstream commit
run: python3 ci/sync-precomputed-fixtures.py --check
update:
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/develop'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
id-token: write
steps:
- uses: DataDog/dd-octo-sts-action@c85802a58e202b7e764b5441e4378c76043c8108 # v1.0.5
id: token
with:
scope: DataDog/dd-sdk-android
policy: self.precomputed-fixtures
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- name: Import upstream fixtures
id: sync
run: python3 ci/sync-precomputed-fixtures.py --ref main
- name: Prepare a draft update
id: prepare
if: steps.sync.outputs.changed == 'true'
env:
GH_TOKEN: ${{ steps.token.outputs.token }}
SOURCE_COMMIT: ${{ steps.sync.outputs.source_commit }}
run: |
existing=$(gh pr list --state open --search '"Update precomputed flag fixtures" in:title' --json number --jq 'length')
if [ "$existing" != 0 ]; then
echo 'An update PR is already open. Merge or close it before the next update.'
exit 0
fi
branch="automation/precomputed-fixtures-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
git add -- 'reliability/single-fit/flags/src/test/resources/precomputed'
echo "branch=$branch" >> "$GITHUB_OUTPUT"
echo 'ready=true' >> "$GITHUB_OUTPUT"
python3 - <<'BODY'
import os
from pathlib import Path
template = Path('ci/precomputed-fixtures-pr.md').read_text()
Path('/tmp/precomputed-pr.md').write_text(template.replace('SOURCE_COMMIT', os.environ['SOURCE_COMMIT']))
BODY
- name: Create a signed fixture commit
if: steps.prepare.outputs.ready == 'true'
uses: DataDog/commit-headless@d6afabfd690ea1dbdd7d368acbefd71304928699 # action/v3.4.1
with:
command: commit
token: ${{ steps.token.outputs.token }}
branch: ${{ steps.prepare.outputs.branch }}
head-sha: ${{ github.sha }}
create-branch: true
force: false
message: 'FFL-3568: Update precomputed flag fixtures'
- name: Open the draft pull request
if: steps.prepare.outputs.ready == 'true'
env:
GH_TOKEN: ${{ steps.token.outputs.token }}
UPDATE_BRANCH: ${{ steps.prepare.outputs.branch }}
run: |
gh pr create --draft --base develop --head "$UPDATE_BRANCH" --title 'FFL-3568: Update precomputed flag fixtures' --body-file /tmp/precomputed-pr.md
17 changes: 17 additions & 0 deletions ci/precomputed-fixtures-pr.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
### What does this PR do?

Refresh the shared precomputed response fixtures to `SOURCE_COMMIT`.

### Motivation

Keep mobile response tests aligned with the canonical JSON contract. Ref: FFL-3568.

### Additional Notes

The existing fixture loop runs new cases in normal SDK CI. Review those results before merge.

### Review checklist (to be filled by reviewers)

- [ ] Feature or bugfix MUST have appropriate tests (unit, integration, e2e)
- [ ] Make sure you discussed the feature or bugfix with the maintaining team in an Issue
- [ ] Make sure each commit and the PR mention the Issue number (cf the [CONTRIBUTING](../CONTRIBUTING.md) doc)
3 changes: 3 additions & 0 deletions ci/precomputed-fixtures.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"destination": "reliability/single-fit/flags/src/test/resources/precomputed"
}
221 changes: 221 additions & 0 deletions ci/sync-precomputed-fixtures.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
#!/usr/bin/env python3
# Unless explicitly stated otherwise, all files in this repository are licensed under the
# Apache License Version 2.0. This product includes software developed at Datadog
# (https://www.datadoghq.com/). Copyright 2025-Present Datadog, Inc.
"""Import a reproducible precompute test snapshot; never edit imported JSON."""
import argparse
import hashlib
import json
import os
import re
import subprocess
import tempfile
import uuid
from pathlib import Path

REPOSITORY = 'https://github.com/DataDog/ffe-system-test-data.git'
PREFIX = 'precomputed-assignments/'
FILES = {
'schemas/precomputed-assignment.schema.json': 'schema.json',
'precomputed-assignments/README.md': 'README.md',
'LICENSE': 'LICENSE',
'NOTICE': 'NOTICE',
}


def git(source, *args):
return subprocess.check_output(['git', '-C', str(source), *args])


def response_bytes(content):
"""Extract the top-level response without re-encoding any numeric token."""
text = content.decode('utf-8')
decoder = json.JSONDecoder()
position = text.index('{') + 1
while True:
while text[position].isspace() or text[position] == ',':
position += 1
if text[position] == '}':
raise ValueError('Fixture is missing response')
key, position = decoder.raw_decode(text, position)
while text[position].isspace():
position += 1
if text[position] != ':':
raise ValueError('Invalid fixture object')
position += 1
while text[position].isspace():
position += 1
start = position
_, position = decoder.raw_decode(text, position)
if key == 'response':
return text[start:position].encode('utf-8') + b'\n'


def snapshot(source, ref):
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._/-]*', ref) or '..' in ref:
raise ValueError('Invalid fixture revision')
sha = git(source, 'rev-parse', '--verify', ref + '^{commit}').decode().strip()
if not re.fullmatch(r'[0-9a-f]{40}', sha):
raise ValueError('Expected a full source commit SHA')
result = {}
for entry in git(source, 'ls-tree', '-rz', '--full-tree', sha).split(b'\0'):
if not entry:
continue
metadata, raw_path = entry.split(b'\t', 1)
path = raw_path.decode()
target = FILES.get(path)
if path.startswith(PREFIX + 'cases/') and path.endswith('.json'):
target = path[len(PREFIX):]
if target is None:
continue
mode, kind, blob = metadata.decode().split()
if mode != '100644' or kind != 'blob':
raise ValueError('Fixture entries must be ordinary files: ' + path)
result[target] = git(source, 'cat-file', 'blob', blob)
cases = sorted(p for p in result if p.startswith('cases/'))
if not cases or not all(p in result for p in FILES.values()):
raise ValueError('Missing or empty precomputed fixture corpus')
for path in cases:
data = json.loads(result[path])
if not isinstance(data, dict) or data.get('name') != Path(path).stem:
raise ValueError('Invalid fixture identity: ' + path)
if not isinstance(data.get('evaluations'), list):
raise TypeError('Missing evaluations: ' + path)
result['responses/' + path] = response_bytes(result[path])
result['index.json'] = (json.dumps(cases, indent=2) + '\n').encode()
result['SOURCE.json'] = (json.dumps({
'repository': REPOSITORY,
'commit': sha,
'files': {p: hashlib.sha256(v).hexdigest() for p, v in sorted(result.items())},
}, indent=2) + '\n').encode()
return result


def read_snapshot(destination):
if not destination.exists():
return {}
result = {}
for path in destination.rglob('*'):
if path.is_symlink():
raise ValueError('Snapshot must not contain symbolic links')
if path.is_file() and path.suffix != '.meta':
result[path.relative_to(destination).as_posix()] = path.read_bytes()
return result


def verify(destination):
content = read_snapshot(destination)
metadata = json.loads(content.pop('SOURCE.json'))
if metadata['repository'] != REPOSITORY or not re.fullmatch(r'[0-9a-f]{40}', metadata['commit']):
raise ValueError('Invalid source provenance')
hashes = {p: hashlib.sha256(v).hexdigest() for p, v in sorted(content.items())}
if hashes != metadata['files']:
raise ValueError('Snapshot content differs from its recorded checksums')
cases = sorted(p for p in content if p.startswith('cases/') and p.endswith('.json'))
if not cases or json.loads(content['index.json']) != cases:
raise ValueError('Missing, empty, or stale fixture index')
print(f"Verified {len(cases)} fixtures at {metadata['commit']}")


def unity_metadata(destination):
for asset in sorted(destination.rglob('*')):
if asset.suffix == '.meta':
continue
meta = asset.with_name(asset.name + '.meta')
if not meta.exists():
guid = uuid.uuid5(uuid.NAMESPACE_URL, 'ffe-precomputed/' + asset.relative_to(destination).as_posix()).hex
folder = 'folderAsset: yes\n' if asset.is_dir() else ''
importer = 'DefaultImporter' if asset.is_dir() or asset.suffix != '.json' else 'TextScriptImporter'
meta.write_text(f'fileFormatVersion: 2\nguid: {guid}\n{folder}{importer}:\n externalObjects: {{}}\n userData:\n assetBundleName:\n assetBundleVariant:\n')


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--ref', default='main')
parser.add_argument('--source', type=Path, help='Read a local Git checkout at the requested committed revision')
parser.add_argument('--verify', action='store_true', help='Verify local checksums and discovery index without network access')
parser.add_argument('--check', action='store_true', help='Compare with the recorded upstream SHA; do not write')
args = parser.parse_args()
root = Path(__file__).resolve().parent.parent
config = json.loads((root / 'ci/precomputed-fixtures.json').read_text())
relative = Path(config['destination'])
if relative.is_absolute() or '..' in relative.parts:
raise ValueError('Destination must be inside the repository')
destination = root / relative
resolved = destination.resolve()
if not resolved.is_relative_to(root):
raise ValueError('Destination escapes the repository')
if resolved == root or '.git' in relative.parts:
raise ValueError('Destination must be a dedicated directory inside the repository')
if resolved != destination:
raise ValueError('Destination must not contain symbolic links')
if destination.exists():
if not destination.is_dir():
raise ValueError('Destination must be a directory')
if any(destination.iterdir()):
provenance = destination / 'SOURCE.json'
if not provenance.is_file():
raise ValueError('Destination must be empty or contain a previous snapshot')
metadata = json.loads(provenance.read_text())
if metadata.get('repository') != REPOSITORY or not isinstance(metadata.get('files'), dict):
raise ValueError('Destination must contain valid snapshot provenance')
extra = read_snapshot(destination).keys() - metadata['files'].keys() - {'SOURCE.json'}
if extra:
raise ValueError('Destination contains files outside the previous snapshot')
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._/-]*', args.ref) or '..' in args.ref:
raise ValueError('Invalid fixture revision')
if args.verify:
verify(destination)
return
before = read_snapshot(destination)
ref = json.loads(before['SOURCE.json'])['commit'] if args.check else args.ref
with tempfile.TemporaryDirectory(prefix='ffe-precompute-') as temporary:
source = args.source
if source is None:
source = Path(temporary) / 'source'
subprocess.run(['git', 'init', '-q', str(source)], check=True)
subprocess.run(['git', '-C', str(source), 'fetch', '-q', '--depth=1', REPOSITORY, ref], check=True)
ref = 'FETCH_HEAD'
after = snapshot(source, ref)
changed = {p:v for p,v in before.items() if p != 'SOURCE.json'} != {p:v for p,v in after.items() if p != 'SOURCE.json'}
if args.check:
if before != after:
raise ValueError('Snapshot differs from its recorded source commit')
elif changed:
destination.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix='.precompute-', dir=destination.parent) as staging:
staged = Path(staging) / 'snapshot'
staged.mkdir()
for path, content in after.items():
output = staged / path
output.parent.mkdir(parents=True, exist_ok=True)
output.write_bytes(content)
# Preserve Unity's existing asset identities when refreshing a snapshot.
if destination.exists():
for meta in destination.rglob('*.meta'):
rel = meta.relative_to(destination)
asset = staged / str(rel)[:-5]
if asset.exists():
(staged / rel).write_bytes(meta.read_bytes())
backup = Path(staging) / 'previous'
destination.rename(backup)
try:
staged.rename(destination)
except BaseException:
backup.rename(destination)
raise
else:
staged.rename(destination)
if config.get('unityMetadata') and not args.check:
unity_metadata(destination)
selected = before if args.check or not changed else after
metadata = json.loads(selected['SOURCE.json'])
count = len(json.loads(selected['index.json']))
print(f"FFE precomputed fixtures: {metadata['commit']}; {count} files; changed={str(changed).lower()}")
if os.environ.get('GITHUB_OUTPUT'):
with open(os.environ['GITHUB_OUTPUT'], 'a') as output:
output.write(f"changed={str(changed).lower()}\nsource_commit={metadata['commit']}\nfixture_count={count}\n")


if __name__ == '__main__':
main()
9 changes: 1 addition & 8 deletions dd-sdk-android-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -146,14 +146,7 @@ dependencies {
testFixturesImplementation(libs.okHttp)
testFixturesImplementation(libs.bundles.testTools)
testFixturesImplementation(project(":dd-sdk-android-internal"))
testFixturesImplementation(project(":tools:unit")) {
attributes {
attribute(
com.android.build.api.attributes.ProductFlavorAttr.of("platform"),
objects.named("jvm")
)
}
}
testFixturesImplementation(testFixtures(project(":dd-sdk-android-internal")))
}

unMock {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
package com.datadog.android.tests.elmyr

import com.datadog.android.api.context.AccountInfo
import com.datadog.android.internal.tests.elmyr.exhaustiveAttributes
import fr.xgouchet.elmyr.Forge
import fr.xgouchet.elmyr.ForgeryFactory

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ package com.datadog.android.tests.elmyr

import com.datadog.android.DatadogSite
import com.datadog.android.api.context.DatadogContext
import com.datadog.android.internal.tests.elmyr.exhaustiveAttributes
import com.datadog.android.privacy.TrackingConsent
import fr.xgouchet.elmyr.Forge
import fr.xgouchet.elmyr.ForgeryFactory
Expand Down
Loading
Loading