Releases: Automattic/newspack-event-logger-nodes
Release list
v0.90.0
Security
log_queriescaptured SQL literals, and the aggregator replicates them to a hub. A literal is a token, an email, a nonce or an id, andquery_start()recorded the statement whole — host annotation stripped, nothing else.App\Core::without_literals()now replaces every literal with?before the span is opened: quoted strings (escape-aware, single and double),INlists collapsed to one placeholder before the general rules run,LIMIT/OFFSET, and bare numeric predicates. Ported from the gyroscope pipeline's own anonymizer, which carried four copies of these rules inDN::Nodes::InstrumentalityGrailuntil a trim dropped them; the numeric-predicate rule is new, because that is where ids live. Replacing literals is also what makes the capture aggregate — two statements differing only in their literals are one query asked twice — so a shape is now countable where a stream of unique statements was not. Comment BODIES are still uncovered;without_host_annotation()only strips a trailing one.
Changed
- Two read-only Partition handles no longer arm a write-side index formatter.
Flame_Builder_Node::mirror_partition()andPerformance_CI_Node's index scan each calledwith_index()on a handle they only ever read from, becauselocate_by()andscan_index()gated on the formatter being installed. Substrate 2.51.0 makes both read-side and refuseswith_index()on a partition that has not claimed sole writer, so the calls are gone; the formatter NAME is still checked throughFormatters::resolve(), since an unregistered one means the writer that should produce the index is not in the graph.
v0.89.1
Security
$matches before a trailing newline, and none of this plugin's gates wereD-anchored.Job_Router_Node::HANDLER_NAME_PATTERN— what stops an aggregated spoke string from reaching jobs.log as a dispatch key — let"work\n"through, and jobs.log is line-oriented; the substrate carries the other two copies. The same sweep tookPerformance_CI_Node's two url_hash gates, where the hash is client-supplied and becomes a memcache key throughrow(), andMCP_Controller'sBearer <handle>.<key>gate, where it becomes a session lookup.Flame_Tree::PATTERN_{START,COMPLETE}andRequest_Builder_Node's method line are deliberately left alone: both PARSE a line rather than validate a token, so the trailing newline is incidental andDwould lose the capture.
Fixed
- A rehydrated fine bucket was warmed for the whole retention window. 0.89.0 had the mirror seam report
Stats_Store::window_remaining()so an evictedurls_hcould rebuild from the fine buckets it derives from; what it did not do is bound that by the bucket's own storage ROLE.ttl_url_fine()is a memcache FOOTPRINT — 24 buckets a shard rather than 288, because decision 17's coarse tier answers for everything behind the edge — so a rehydrate put all 288 back in the cache that tier exists to keep out, for up to twelve times its footprint.window_remaining()now returns the smaller of the two, and its parameter is documented as the Table-RELATIVE key the seam actually passes, whose first segment is the namespace. - Decision 11 and two comments still cited substrate ADR-18 as "an expired frame is a miss, not a resurrection", which 0.89.0 and substrate 2.50.0 replaced. The CHECKPOINT carry still drops a spent frame, and now says why in its own terms: a held frame is an unmerged delta, not a copy of anything durable.
FINE_TTL_SECONDSreads 7200 in the debugging skill and the architecture guide's schema table. Both still said 14400.hub-control.tslsays that each per-spoke egress needsallow_replies_to discovery-collectorbeside its settings-sync declaration, or the discovery replies are dropped. Substrate 2.50.0 made that mandatory and onlysettings-sync.tslmentioned it.
v0.89.0
Security
- The cache-cozy warm secret reached the firehose unredacted.
01-newspack-cache-cozy.phpbuilds?cache_cozy_warm=<32 hex>withadd_query_arg, generated once and never rotated.$request->urlstrips the query string, but therequestlog line records the URL whole — andaggregator.tslreplicates a spoke's raw firehose to the hub, so a hub operator holding only the least-privilegehub-useraccount reads every spoke's warm secret. Added toURL_REDACT_PATTERNin both producers, sincetools/check-firehose-parity.pyholds the PHP and Perl copies to one list. This closes the instance, not the class: the pattern is a name list anchored at[?&], so it can only ever match a name someone remembered to add, and it will be wrong again the next time a credential goes in a URL.
Fixed
Ask_Assembler's class docblock asserted an invariant the code does not hold. It states that across every shaper the environment is dropped except an allowlist — "No headers, no IPs, no user agents, no cookies — the brief leaves the site."entry_shape()copies an entry'smverbatim, and anenvironment_v3entry'smIS the curated$_SERVERmap, so a brief carrying that entry carries REMOTE_ADDR and the user agent off-site. The docblock now says what the code does and marks the shaper's allowlist as an open question rather than a settled rule.- An evicted hour is rebuilt from fine buckets whose CACHE lifetime is spent.
ttl_url_fine()is two hours because the fine tier is the largest thing this schema puts in a 512MB cache — a footprint bound, not a statement that the data expired. The mirror keeps those buckets for twice the stats window, but the rehydrate seam reported what was left of the frame's CACHE ttl, so the substrate refused it and an evictedurls_hkey was unrecoverable. That is decision 17's premise for leaving the coarse tier unmirrored, so the premise did not hold. The seam now reportsStats_Store::window_remaining()— seconds until the bucket leaves the RETENTION window — and a bucket genuinely past retention is dropped rather than served. Needs substrate 2.50.0, and the loader's floor is raised to it: this is a BEHAVIOUR requirement, whichcheck-substrate-floor.shcannot see.
Changed
unfolded_hour_buckets()no longer trims the grace hour byttl_url_fine(). With the mirror answering past that TTL there is nothing to trim: all twelve of the grace hour's buckets are asked for, and the grace-hour bound alone keeps a coldurls --searchat 640 batches.
v0.88.2
Changed
- The fine tier is kept for two hours, and the readers stop reaching into it past the last hour. It has exactly two consumers:
RECENT_BUCKETS' twelve buckets, which are the last-hour rate, androll_up_hours(), which builds every coarse tier out of a closed hour's fine buckets. It is the window's EDGE and the fold's input — never a tier to read old hours from — and both of those need two hours, not four: the plan's fine span is just under two at the worst minute, and an hour folds within a re-probe of it closing.FINE_TTL_SECONDS14400 → 7200, which costs 24 buckets a shard against the coarse tier's 24 hours; the four hours it was, and the "288 buckets a shard" the old note costed it at, bought margin for a fallback that should not have been reading here at all. unfolded_hour_buckets()now reaches only the GRACE hour — the one immediately behind the fine tail, which the fold may simply not have caught yet — and filters even that to the buckets the TTL can still hold. Everything older is the coarse tier's alone, folded or not:roll_up_hours()reads the same keys, so an hour it never reached is gone from here for it too. Cold batch counts per poll, four partitions, against the 0.88.1 numbers:overview28 → 20,urls448 → 320,urls --search896 → 640,urls --include_workers896 → 640 — 5.4x off where this release series started, and the same factor off the worst-case mirror walks behind them.
v0.88.1
Fixed
- The readers asked for four times more fine buckets than the fine tier keeps. An hour the coarse tier cannot answer for is answered from its twelve fine buckets — the fallback that makes a fresh deploy and a cold-start backfill self-healing — but
buckets_in_hour()enumerates all twelve whatever their age, and the plan's hours reach back a whole retention window.urlsandurlnamesareROLE_URL_FINE, kept forttl_url_fine(), four hours. Everything behind that was a key memcache had already discarded: a certain miss, per shard, per chunk, per partition — and each miss is what walked the durable mirror's index in full.FINE_TTL_SECONDS's own docblock asserted "nothing reads a fine bucket behind that", and three readers did.
Stats_Store::unfolded_hour_buckets()is now the one spelling of that fallback, and it stops at the tier's TTL: the reader's horizon is the writer's. Cold batch counts per poll, four partitions:overview108 → 28,urls1,728 → 448,urls --search3,456 → 896,urls --include_workers3,456 → 896 — the same factor off the memcache round trips and off the worst-case index walks behind them. Nothing recoverable is lost:roll_up_hours()folds an hour from those same keys, so an hour past the horizon is gone from the fine tier for the fold as well as for the reader.
v0.88.0
Fixed
-
The dashboard walked the whole durable mirror index once per memcache miss, thousands of times per poll. This is what actually spent the 200-290 seconds; the three releases before it each removed a real cost, but none of them the dominant one.
Performance_CI_Node::stats_stores()arms every reader store againstflame-stats:partitionso an evicted bucket still answers, and a miss resolves throughPartition_Node::locate_by()— which has no early stop for a key that is ABSENT from the index, so it reads and splits every.idxline in the partition before it can say "not here". Measured on eve: 192ms per full pass over 150,000 index lines. Measured against the shipped read paths with a cold cache and four partitions:overviewissues 108 such batches,urls1,728, andurlswith a search term 3,456 — sixteen shards by four partitions by twenty-five bucket chunks, a geometry the per-shard fold (decision 14) requires for memory and so cannot shrink. 3,456 passes at 192ms is 663 seconds of index walking inside one 15-second poll, which is why the tail was violently bimodal (p50 187ms warm, p90 162.5s cold) and why cutting the URL count 6.7x changed nothing: the cost is the index's size times the batch COUNT, and neither is the URL count.
Two bounds, both symmetric with what the write path already does:Performance_CI_Node::dispatch()starts each VERB with a freshstats_mirror_read_budget_ms(new config key, default 1500,0off) andarm_stats_reader()stops consulting the mirror once it is spent, answering from memcache alone for the rest of that verb. The class docblock already held every other disk scan to a bound —scan_floor()by time,MAX_INDEX_ENTRIESby count — and the mirror read was the one with none.dispatch()rather thanfill()becauseMcp_Controllercalls it straight, with no Message behind it. What a spent budget costs is the EVICTED buckets of the partitions the fold had not reached yet — the outer loop is per store, so it drops later partitions whole rather than trimming each one's oldest — and memcache answers the rest, so the next poll is complete. The WORKER's seam is unbudgeted: it is restoring its own state, not answering a poll.rehydrate_seam()no longer looks for a namespaceSTATS_MIRROR_TOPNcaps at zero.buffer_mirror_write()refuses to storeurls_h,urlnames_handlb_h, so a read of one could only ever walk the index in full and find nothing — 256 of those 3,456 passes were guaranteed-futile.- Measured, one
urls --searchagainst a 150,000-line mirror on one partition: 149,877 ms unbounded, 1,661 ms budgeted. A whole-window locator PREFETCH was built and rejected on measurement — it makes the answer complete rather than clamped, but end-to-end it is 1,708 ms, becauseLog_Manager::url_hash()is a hand-rolled FNV-1a run twice per key and hashing a window costs ~740 ms across four partitions. The spec records the numbers so it is not re-derived.
-
The leaderboard read 288 buckets across four partitions on every poll, and each one carries a category per hook the site fires. 1,198 of them on a production hub, each with its own entry map — 1,152 keys and tens of millions of array elements unserialized and merged, per 15-second poll. That is the read that takes
overviewpast any answering deadline.lbnow has the coarse hourly tierurlshas had since decision 17, folded by the sameroll_up_hours()pass into the same shape a fine bucket holds, so onebuild_leaderboard()fold serves both tiers: 1,152 keys → 144, with each hour merged once at write time instead of 288 times per read. An hour the fold has not reached is still answered from its twelve fine buckets, so a fresh deploy and a cold-start backfill stay self-healing. Nothing is capped. The per-SERVER board keeps the fine path — a shard count is a constant the schema chooses, but the servers present in an hour cannot be enumerated from the keyspace.
v0.87.1
Fixed
- The
overviewreply was too big to finish, and its largest part was repetition.category_time_seriesspells each category's NAME once per bucket it appears in — 288 times across a retention window — and spends ~18 bytes of JSON key names on every three-number value. At 288 buckets by 50 categories that is 838KB of an ~1.1MB reply, and the browser reportedJSON.parse: end of dataat column 336,729 on one poll and 387,461 on another. The series now crosses the wire as a name TABLE plus positional[ nameIndex, t, c, n ]rows: 838KB → 292KB, 65% off, with every category and every bucket kept. This is decision 18's argument at the wire rather than in the store.url_detailemits the same shape, so one encoder serves the overview card and the URL modal alike.
v0.87.0
Fixed
- A URL search named every URL in the window, one memcache key at a time.
url_page()handsresolve_urls()the WHOLE shard index whenever a search term or aurlsort is set, and that function builds oneurlmap:{hash}key per row — 668,918 hashes across four partitions on a production hub. Measured there:/commandat p50 139ms and p95 28.5s, with a tail at 200-290 seconds and 350-510MB peak, answering 200 and then cut off mid-body, so the browser reportedJSON.parse: unexpected end of dataand the dashboard kept the previous poll's numbers.include_workersdoubled it again by doubling the shard set. The names now come from the shard:urlnames:{shard}:{bucket}, folded tourlnames_hby the same pass that foldsurls_h, read on the sameread_plan()— ~40 keys per shard however many URLs it holds. Nothing is capped or ranked; every URL stays searchable. - A search matched the hostname, so on a hub every row answered to the busiest host. The stored name was the absolute URL, and the host in it is a literal duplicate of the
ROW_SRVsplit's KEY — the axis the server dropdown is already built from.urlmapnow stores[ path, origin ], a search matches the path alone, andresolve_urls()joins the two back for display. - A folded hour could keep its rows and lose its names.
roll_up_hours()memoizes an hour as done from ONE probe, and that probe asked the row tier alone — so every hour folded by an earlier release would have read as settled forever, leaving its rows in the index with nothing to search them by for a full retention window. It now asks both derived tiers, still in one round trip (Stats_Store::url_hours_folded()). - A search could not reach an hour the fold had not caught up with.
load_index_default()answers an unfolded hour from its twelve fine buckets, which is what makes a fresh deploy and a cold-start backfill self-healing; the name read took the coarse tier only, so those rows arrived nameless and dropped out of every search. It takes the same fallback now, chunked on the sameINDEX_READ_CHUNK. Stats_Store::split_url()was lossless only for a URL with a path.https://host?q=1split to[ '/', 'https://host?q=1' ]— a display slash from nowhere and a haystack of one character. The authority now ends at the first/,?or#, and a round-trip test pinsorigin . path === url.- A flush made one un-batched round trip per URL.
mirror_url_stats()wrote each per-URL aggregate with its ownset, and the round-trip test only passed because the hour fold contributed a constant that padded the small-N side of its inequality. Both are batched now, and the test asserts zero single round trips rather than a ratio.
Changed
Stats_Store's four sharded readers collapse onto one.urls,urls_h,urlnamesandurlnames_hshare one key geometry, so they share one reader with four named wrappers.set_url_hour()andset_url_stats()lost their last production callers to the batching above and move to the test utilities, beside the sibling accessors that went the same way.
v0.86.3
Fixed
- The firehose cap now bounds what the wire carries.
MAX_DATA_SIZEwas measured against the caller's$data, butn,kandtsare stamped on afterwards, so a payload that cleared the cap on its own still shipped an oversized entry — 3895 bytes against a 3840 cap in the regression test.message()builds the entry first and fits THAT, and the last-resort floor keepsn,kandtsso a reader can still place the line. - Every failed Ask showed "An error occurred" instead of why.
useAskreports a reason STRING — the substrate types a reply'serroras?string, anderrorMessage()coerces every TM_ERROR payload beforeonDonesees it — but the page's handler readerr.message, which isundefinedon a string, so the real reason was discarded on both failure paths. The tests passed because they drove the handler with anErrorobject production never sends; they now use the string the runtime actually delivers, and fail against the old code.
Documentation
- The seven documents verified against source, then completed from it. Two passes held every existing claim to the code, the second re-deriving each rather than reviewing the first's reasoning. Every
file:linecitation became a symbol reference, because a cited line is right only until someone edits above it. Then all 931 non-test PHP and JS files across the six plugins were read to establish what these documents omit, and each resulting finding was verified against source again before being written — a claim that the Current-Request overlay passesAdmin::devtools_overlay_pages()as itspagevalue was dropped, sinceCurrent_Request_Overlay::enqueue_on_overlay_pages()never callsenqueue_react_pageat all — it gates on its ownis_overlay_page()and hand-rollswp_enqueue_script. - Every PHP and JS source file's documentation reviewed against its code. One agent per file, tests excluded: missing docblocks written,
@param/@returndrift corrected against the real signature, documentation for deleted behaviour removed, and the why recorded where the what was already plain. Comments describing history rather than the present are gone. - JSDoc types repaired where the sweep's prose broke the type gate. An
@typenamingLazyExoticComponentwith no type argument overrode correct inference and erasedPerformanceDashboard's whole props contract;@inside prose (@<priority>,@0) parses as a tag and is a syntax error; a%swas handed a number.
v0.86.2
Fixed
- A flame-graph tooltip stays inside the viewport, and stops growing past it. A frame's
detailis its raw message — aqueryframe carries the whole SQL, athe_contentframe the rendered HTML — so on a real record the tooltip ran to dozens of lines. Taller than the space below the cursor it flipped above, and taller than the space above it ran off the TOP, where nothing can scroll it back: the first lines were simply unreachable. The label is capped at 30 lines with a… N more linesmarker, the box takes amax-heightof the viewport and scrolls inside it, and the flip is clamped at both edges rather than only the bottom.