Skip to content

Observe the audience collection, not just one audience - #91

Merged
gonzaloebiz merged 2 commits into
developfrom
feat/observe-the-audience-collection
Sep 14, 2026
Merged

gonzaloebiz merged 2 commits into
developfrom
feat/observe-the-audience-collection

Conversation

@gonzaloebiz

@gonzaloebiz gonzaloebiz commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Part 2 of the library-side telemetry ask — the denominator, the per-audience map, and the cap.

What was missing

observeList() fired only on lists/{id} and kept the first audience the process saw. A four-store-view install described one of its four, and nothing said how many existed. Coverage was a tautology — audiences we hold over audiences we hold — and the question that actually matters, has an audience we never saw changed, could not be asked.

The collection answers both halves at once

Checked against a live account before designing on it:

GET lists?count=100&include_total_contacts=true
  keys: lists, total_items, constraints, _links
  total_items = 24, lists returned = 24
  each entry: { id, stats: { member_count, unsubscribe_count, cleaned_count, total_contacts, … } }

So one response carries the count and every audience's stats. Nothing new is requested — the admin fetches this collection when it offers the audience dropdown.

Shape

  • observeListCollection() for lists, observeList() for lists/{id} — the guard added last week stays, and the collection gets its own entry point rather than loosening it.
  • Both record into a map keyed by audience id. A later reading completes an earlier one, which is what lets a single-audience response carrying total_contacts fill an entry the collection left without it.
  • The flat list_* fields are kept beside the map, not replaced by it. A consumer reading them predates this change and should not lose its value to it.

The cap, and what is deliberately not capped

MAX_AUDIENCES = 50 bounds the array. The count is never capped — an account with hundreds would otherwise push the report past MAX_BYTES and have it dropped whole, losing the denominator, which is the one figure nothing else can supply. A truncated array beside an intact count still answers the coverage question.

audiences_reported is the number offered, not the number carried, so truncation shows as audiences_reported > count(audiences_json) and there is no flag to forget to set.

Verified

case result
collection only, 24 real audiences audience_count=24, reported=24, array 24, envelope 2,760 bytes
entry shape {"id":"…","member_count":2,"unsubscribe_count":0,"cleaned_count":0,"total_contacts":6}
collection then single audience both present; flat fields latch to the single read, map holds all 24
140 synthetic audiences array capped at 50, audience_count=140, reported=140, envelope 5,303 against MAX_BYTES 8,192
lean report none of it — below the lean cut

tools/no-deprecated-calls.php: 64 files, 0 findings.

Two things still open

The key names, which are yours to set — I will match whatever the contract says.

It changes a decision on mc-magento2 #2366. I left Controller/Adminhtml/Lists/Get.php alone there,
arguing the beacon ignores collection responses so asking would add payload nobody reads. That argument
dies with this PR: the collection becomes the richest source, and without the parameter its entries
arrive without the contact total.

observeList() fired only on lists/{id} and kept the first audience the
process saw. An install with four store views therefore described one of
its four, and nothing said how many existed -- so coverage was a
tautology, audiences we hold over audiences we hold, and the question
that matters, whether an audience we never saw has changed, could not be
asked at all.

The collection answers both halves in one response: total_items is the
count, and each entry carries the same stats a single-audience read does.
Nothing new is requested -- the admin fetches the collection when it
offers the audience dropdown.

So this adds observeListCollection() for `lists`, keeps observeList() for
lists/{id}, and records both into a map keyed by audience id. A later
reading of the same audience completes an earlier one, which is what lets
a single-audience response carrying total_contacts fill an entry the
collection left without it.

The flat list_* fields are kept beside the map rather than replaced by
it: a consumer reading them predates the map and must not lose its value
to this change.

The count is never capped; only the array is, at MAX_AUDIENCES. An
account with hundreds would otherwise push the report past MAX_BYTES and
have it dropped whole -- losing the denominator, which is the one figure
nothing else can supply. audiences_reported counts every audience
offered rather than every one carried, so truncation is visible by
comparing it with the array's length and there is no flag to forget.

Measured: 24 real audiences produce a 2,760-byte report; 140 synthetic
ones cap at 50 and produce 5,303 against a MAX_BYTES of 8,192.
@gonzaloebiz gonzaloebiz self-assigned this Sep 14, 2026
@gonzaloebiz
gonzaloebiz requested a review from dipola September 14, 2026 19:17
@dipola

dipola commented Sep 14, 2026

Copy link
Copy Markdown

The design is right — the collection as the source, the map keyed by audience id so a later reading completes an earlier one, keeping the flat fields beside it rather than replacing them, and refusing to cap the count. All four are the calls I would have argued for.

But the key names are wrong, and that is on me. The table you worked from listed storage names, not the names the wire expects. They are not the same and I should have said which I was giving you. Here is the actual contract.

The wire contract

Two keys, both short:

emit is notes
la the roster map or array — both accepted
lac total_items off the collection account-wide, never summed, not capped

Inner keys are abbreviated too — mem, uns, cln, tot:

"la": { "3fd8ea8578": {"mem":1120,"uns":1,"cln":631,"tot":1767}, "c75ded2746": {"mem":0} }
"lac": 24

The array form is equally valid and reads the id off the entry:

"la": [ {"id":"3fd8ea8578","mem":1120,"uns":1,"cln":631,"tot":1767} ]

Prefer the map. JSON object keys are unique, so a duplicate id is impossible by construction rather than resolved by a rule — in the array form a repeated id is dropped, first-wins.

Any of the four counts may be absent: a missing one is simply unknown for that audience and does not cost you the entry. An entry without a usable id is dropped, so the id is the only mandatory part. Ids are bounded at 32 characters, matching list_id.

Two bounds to change

Cap the roster at 12, not 50. GET /lists defaults to count=10, so an unpaged observation yields at most ten and twelve leaves headroom. Your instinct to leave the count uncapped is exactly right and is why the low roster cap is affordable — coverage stays honest when the array does not.

Also keep the serialised roster under 4,096 bytes. Over that it is dropped whole rather than stored oversized, so the cap is what protects you from the all-or-nothing case you were already designing against with MAX_BYTES.

Drop audiences_reported. The distinction you drew — offered versus carried — is the right one, and it already exists: lac is the offered count and the roster length is the carried one, so the comparison works without a third key. Sending it adds nothing.

You are right about Lists/Get.php

I reviewed the other change and said leaving that call site alone was correct. Your argument retires mine: if the collection becomes the richest source then its entries arriving without the contact total is precisely the gap, and the reasoning I accepted no longer holds.

Add it there, and add it to that same change rather than a later one — the sequencing argument on that thread was that the cron and the other call sites want to reach a tag together, and this is one more reason not to split them.

One thing in the description

Not the code. The verification block prints a real audience id, and the closing line describes behaviour on our side of the wire. Neither belongs in a public repository, and a description is editable where several things here are not.

Could you take both out, the same as on the other one? The rest is worth keeping in public — the shape you chose and why is the useful part. Specifics separately rather than repeated here.

Raised in review: the names I worked from were storage names rather than
the ones the wire expects.

The roster goes as `la`, keyed by audience id, with `mem`, `uns`, `cln`
and `tot` inside. The account-wide count goes as `lac`. A map rather than
a list because object keys are unique, so a repeated id is impossible by
construction instead of resolved by a rule nobody reads.

audiences_reported is dropped. The distinction it drew -- offered against
carried -- already exists: `lac` is the offered count and the roster's
size is the carried one, so a third key adds nothing and gives something
else to forget to set.

The cap falls from fifty to twelve. An unpaged GET /lists answers ten, so
twelve carries a default observation whole and leaves headroom, and the
receiver refuses an oversized roster rather than truncating it -- which
would cost the count as well, and that is the figure nothing else can
supply. A byte bound backs the cap up for ids longer than any seen, and
trims one entry at a time, because carrying fewer audiences is strictly
better than carrying none.

Measured: ten audiences serialise to 472 bytes and twelve to 570, against
a bound of 4,096.

@dipola dipola left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved. Checked against the contract rather than the description: la and lac on the envelope, mem / uns / cln / tot inside, mapped from Mailchimp's own longer names at the point the response is read — which is the right place for the translation to live. Cap down to twelve, audiences_reported gone.

The 4,096-byte guard was not something I asked for and it is the best part of the change. An oversized roster is refused whole at the far end, so bounding it here is what keeps the count — the figure nothing else can supply — from going down with it.

@gonzaloebiz
gonzaloebiz merged commit e4bd43e into develop Sep 14, 2026
1 check passed
@gonzaloebiz
gonzaloebiz deleted the feat/observe-the-audience-collection branch September 14, 2026 19:47
@gonzaloebiz

Copy link
Copy Markdown
Contributor Author

Both merged — 8ef0c65e and e4bd43e8. develop now carries six commits over 3.0.50, and the
extension's develop-2.4 carries seven over 103.4.83, including all seven call sites.

Holding both tags until you answer one question, because your line on the collection PR has a
consequence I do not think either of us has followed all the way.

The order is not symmetric

The bad state you described — the collection observed, answering without contact totals — is
lib 3.0.51 with an extension older than 103.4.84. And that combination is not a corner case, it is
the likeliest one:

The installs that matter here are the ones running old extensions, and they pick up library releases
within days — your own figure, 19 of 21 on 3.0.48 inside six days — precisely because the floor lets
composer update resolve the library without touching the extension. They are the fastest to take
3.0.51 and the slowest to take 103.4.84.

So tagging the library first opens that window over exactly the population it hurts most. Tagging the
extension first does not close the window, but it does not aim it either.

The question

Does your console distinguish an entry with no tot from one with tot: 0?

You wrote that any of the four counts may be absent and that a missing one is simply unknown for that
audience. If that holds all the way to what is displayed — absent renders as unknown rather than as
zero, and is not summed into anything — then the window is harmless and the order does not matter; I
tag both today, in whichever order.

If absent collapses into zero anywhere on the read path, the order does matter, and I would tag
103.4.84 first and leave 3.0.51 until you say the display is ready.

Nothing is blocked on my side: both releases are prepared and I will cut them as soon as you answer.
It seemed worth one question rather than picking an order and hoping.

@gonzaloebiz gonzaloebiz mentioned this pull request Sep 14, 2026
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.

2 participants