Sitelet https://github.com/Parsely/parsely-android/pull/126
Skip to content

Resolve the collection endpoint per site ID at build time - #126

Draft
dhruvkb wants to merge 7 commits into
mainfrom
build-time-pixel-host
Draft

dhruvkb wants to merge 7 commits into
mainfrom
build-time-pixel-host

Conversation

@dhruvkb

@dhruvkb dhruvkb commented Sep 24, 2026

Copy link
Copy Markdown
Member

Parse.ly decides which collection endpoint a site's data goes to, and that decision can differ per site. The SDK hardcoded https://p1.parsely.com/mobileproxy in ROOT_URL, so an app could only ever send to one endpoint regardless of which one its sites actually belong to.

The endpoint is now resolved per site ID at the publisher's build time and baked into the app, the same way the web tracker resolves it when it builds a site's p.js.

How it works

  1. The publisher applies the new com.parsely.hosts Gradle plugin and commits parsely-apikeys.json declaring every site ID the app can track — including any passed as SiteIdSource.Custom, not just the one given to ParselyTracker.init.
  2. The plugin resolves each one against Parse.ly on every build and writes parsely-hosts.json into the variant's generated assets. Nothing is ever written into src/, and the generated asset is not committed.
  3. FlushQueue groups queued events by resolved endpoint and sends one request per endpoint.

Grouping is by endpoint rather than by site ID deliberately: the payload already carries idsite per event, so several site IDs in one region still share a single request, exactly as they did before. A multi-site app in one region sends one request, not N.

There is no default endpoint left in the binary. An event whose site ID is not in the baked map is dropped and logged rather than sent somewhere that might be the wrong region — and removed from storage, since the baked map cannot change while the app runs and retaining them would grow the disk-backed queue forever. Nothing at runtime throws: a missing or malformed asset logs at error level and sends nothing.

Once the plugin is applied it cannot be forgotten, and it fails the build rather than guessing: a missing or empty declaration, a site ID Parse.ly does not recognise, or any other HTTP error stops the build. The one deliberate exception is an unreachable Parse.ly — if the generated asset already covers every declared site ID the build warns and proceeds, so an outage does not break every publisher's CI, while still failing closed whenever data is actually missing.

Two bugs fixed along the way

  • FlushQueue removed the whole batch with a single repository.remove(eventsToSend). Events now come out of storage per group, on that group's own success, so a failed request no longer drops the events it failed to send.
  • FlushQueue.kt:41 logged the ROOT_URL constant rather than the URL actually used. ROOT_URL is gone and the line logs the real URL.

RestClient.send takes the URL per call instead of capturing it at construction, so one ParselyAPIConnection serves every endpoint.

Breaking change

An app that upgrades without applying the plugin sends no analytics at all. That is intentional — the alternative is silently sending a site's data to an endpoint it does not belong to — but it means this cannot ship as a minor version. The README says so up front. apiDump shows no public API movement, so the break is behavioural, not source-level.

Testing

  • ./gradlew :parsely:testDebugUnitTest → 75 tests, 0 failures. New coverage: no baked asset sends nothing and does not crash; malformed and wrong-version assets fail closed; one request per endpoint; two site IDs sharing an endpoint coalesce into one request; an undeclared site ID is dropped from storage while known ones still send; a failing request for one endpoint does not remove the other endpoint's events.
  • ./gradlew apiCheck → clean, parsely/api/parsely.api unchanged.
  • ./gradlew lintDebug → clean. ./gradlew :parsely-gradle-plugin:build → clean, including validatePlugins.
  • The plugin verified end to end against production by applying it to :example: the declaration resolves, the asset is written, and assets/parsely-hosts.json is confirmed present in the built APK. A missing declaration, an empty one, an unknown site ID, and an unreachable Parse.ly (both with and without covering artifact) all behave as described above.

The plugin is not applied to :example on this branch. readme.yml builds :example:assembleDebug on every push, and a fresh CI checkout has no committed artifact to fall back on, so an outage would turn every push red. Happy to wire it up if you would rather have the demo app dogfood it.

Companion changes

The equivalent iOS change is Parsely/AnalyticsSDK-iOS#98. Both depend on the Parse.ly endpoint that serves the mapping, which is already live.

🤖 Generated with Claude Code

Wraps the site-ID-to-host map that build tooling bakes into the app asset,
and owns the partitioning of a flush batch by host. There is deliberately
no default host: a site ID missing from the map does not resolve, so its
events can be dropped rather than sent to a region that may be wrong.

Grouping is by resolved host rather than by site ID so that several site
IDs in the same region still share a single request, as they do today.
ROOT_URL is gone. RestClient takes the URL per call instead of capturing it
at construction, so one connection serves every host, and FlushQueue
partitions the stored queue by resolved host and sends one request per host.

Two bugs go with it. Events are now removed per group on that group's own
success, where a single remove() previously dropped events belonging to a
request that had failed. And the debug line logged the ROOT_URL constant
rather than the URL actually used.

Events whose site ID is not in the baked map are removed from storage after
being logged: the map cannot change while the app runs, so retaining them
would grow the disk-backed queue forever without them ever being sent.
@codecov

codecov Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.80328% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.94%. Comparing base (3f09f9d) to head (5f93821).

Files with missing lines Patch % Lines
...main/java/com/parsely/parselyandroid/PixelHosts.kt 86.84% 5 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #126      +/-   ##
==========================================
+ Coverage   73.46%   74.94%   +1.47%     
==========================================
  Files          23       24       +1     
  Lines         441      487      +46     
  Branches       52       61       +9     
==========================================
+ Hits          324      365      +41     
- Misses         99      104       +5     
  Partials       18       18              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Resolves each declared site ID against Parse.ly at the publisher's build
time and writes parsely-hosts.json into the variant's generated assets,
which the SDK reads at init. Nothing is ever written into src/.

The build fails on a missing or empty parsely-apikeys.json, on a site ID
Parse.ly does not recognise, and on any other HTTP error. A network failure
is the one deliberate exception: if the generated asset already covers every
declared site ID the build warns and continues, so a Parse.ly outage does
not break every customer's CI while still failing closed whenever data is
actually missing.

The plugin reacts to the Android plugin via pluginManager.withPlugin, so
the order of the publisher's plugins block does not matter.

It is not applied to :example. readme.yml builds :example:assembleDebug on
every push to every branch, and a fresh CI checkout has no generated asset
to fall back on, so an unreachable Parse.ly would turn a push red. It was
verified by applying it to :example locally instead.
The endpoint shipped at /api/<site id>/jess/sdk_config/, not the flat path
this plugin was written against. Verified end to end against production on
:example: the declaration resolves, the asset is written, and it lands in
the APK at assets/parsely-hosts.json. A missing declaration, an empty one,
an unknown site ID and an unreachable host all behave as intended.

The parsely.sdkConfigEndpoint property becomes parsely.apiBase, since the
override is now the API root rather than a full endpoint path.
@dhruvkb
dhruvkb force-pushed the build-time-pixel-host branch from 63a8a65 to 57b43dc Compare September 24, 2026 19:36
fromAssets had no test for the path that actually runs in a publisher's
app: only the missing-asset failure was covered, so nothing verified that a
baked asset is found and read. A Robolectric test now reads a fixture from
the unit-test asset source set.

The blank-host filter was untested too. An artifact entry with an empty
host must not resolve, so its events are dropped rather than sent to
"https:///mobileproxy".
@dhruvkb

dhruvkb commented Sep 24, 2026

Copy link
Copy Markdown
Member Author

Covered the two lines Codecov flagged in PixelHosts.kt — the fromAssets read path and the blank-host filter — in 4ad0100. PixelHosts.kt is now fully covered.

The functional tests reached MockWebServer by reflectively overwriting
ROOT_URL, which this branch deletes, so all six failed in setup with
NoSuchFieldException.

They now learn the server's address the way a real app does, from a baked
asset in androidTest/assets that maps the test site ID to localhost on a
fixed port. Because the SDK sends only to https, MockWebServer serves TLS
with a certificate generated per run and trusted on both sides; nothing
outside the test process trusts it, and no test-shaped seam is added to the
shipped SDK.

Verified as far as is possible without an emulator: the sources compile and
the fixture lands in the test APK at assets/parsely-hosts.json. The six
tests themselves run on CI.
customSiteIdIsAppliedToConcurrentEventsInEngagementSession tracks under a
SiteIdSource.Custom site ID, which the baked asset did not cover, so the SDK
dropped those events exactly as designed and the test saw no request.

Declaring it on the same host as the default site ID also gives the
coalescing rule an end-to-end check: two site IDs, one host, one request,
which is what the test already asserted.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant