Skip to content

Releases: Automattic/newspack-event-logger-nodes

v0.90.0

Choose a tag to compare

@github-actions github-actions released this 08 Sep 14:27

Security

  • log_queries captured SQL literals, and the aggregator replicates them to a hub. A literal is a token, an email, a nonce or an id, and query_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), IN lists 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 in DN::Nodes::InstrumentalityGrail until 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() and Performance_CI_Node's index scan each called with_index() on a handle they only ever read from, because locate_by() and scan_index() gated on the formatter being installed. Substrate 2.51.0 makes both read-side and refuses with_index() on a partition that has not claimed sole writer, so the calls are gone; the formatter NAME is still checked through Formatters::resolve(), since an unregistered one means the writer that should produce the index is not in the graph.

v0.89.1

Choose a tag to compare

@github-actions github-actions released this 08 Sep 11:45

Security

  • $ matches before a trailing newline, and none of this plugin's gates were D-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 took Performance_CI_Node's two url_hash gates, where the hash is client-supplied and becomes a memcache key through row(), and MCP_Controller's Bearer <handle>.<key> gate, where it becomes a session lookup. Flame_Tree::PATTERN_{START,COMPLETE} and Request_Builder_Node's method line are deliberately left alone: both PARSE a line rather than validate a token, so the trailing newline is incidental and D would 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 evicted urls_h could 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_SECONDS reads 7200 in the debugging skill and the architecture guide's schema table. Both still said 14400.
  • hub-control.tsl says that each per-spoke egress needs allow_replies_to discovery-collector beside its settings-sync declaration, or the discovery replies are dropped. Substrate 2.50.0 made that mandatory and only settings-sync.tsl mentioned it.

v0.89.0

Choose a tag to compare

@github-actions github-actions released this 08 Sep 10:04

Security

  • The cache-cozy warm secret reached the firehose unredacted. 01-newspack-cache-cozy.php builds ?cache_cozy_warm=<32 hex> with add_query_arg, generated once and never rotated. $request->url strips the query string, but the request log line records the URL whole — and aggregator.tsl replicates a spoke's raw firehose to the hub, so a hub operator holding only the least-privilege hub-user account reads every spoke's warm secret. Added to URL_REDACT_PATTERN in both producers, since tools/check-firehose-parity.py holds 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's m verbatim, and an environment_v3 entry's m IS the curated $_SERVER map, 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 evicted urls_h key was unrecoverable. That is decision 17's premise for leaving the coarse tier unmirrored, so the premise did not hold. The seam now reports Stats_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, which check-substrate-floor.sh cannot see.

Changed

  • unfolded_hour_buckets() no longer trims the grace hour by ttl_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 cold urls --search at 640 batches.

v0.88.2

Choose a tag to compare

@github-actions github-actions released this 08 Sep 00:15

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, and roll_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_SECONDS 14400 → 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: overview 28 → 20, urls 448 → 320, urls --search 896 → 640, urls --include_workers 896 → 640 — 5.4x off where this release series started, and the same factor off the worst-case mirror walks behind them.

v0.88.1

Choose a tag to compare

@github-actions github-actions released this 07 Sep 23:59

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. urls and urlnames are ROLE_URL_FINE, kept for ttl_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: overview 108 → 28, urls 1,728 → 448, urls --search 3,456 → 896, urls --include_workers 3,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

Choose a tag to compare

@github-actions github-actions released this 07 Sep 23:42

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 against flame-stats:partition so an evicted bucket still answers, and a miss resolves through Partition_Node::locate_by() — which has no early stop for a key that is ABSENT from the index, so it reads and splits every .idx line 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: overview issues 108 such batches, urls 1,728, and urls with 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 fresh stats_mirror_read_budget_ms (new config key, default 1500, 0 off) and arm_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_ENTRIES by count — and the mirror read was the one with none. dispatch() rather than fill() because Mcp_Controller calls 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 namespace STATS_MIRROR_TOPN caps at zero. buffer_mirror_write() refuses to store urls_h, urlnames_h and lb_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 --search against 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, because Log_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 overview past any answering deadline. lb now has the coarse hourly tier urls has had since decision 17, folded by the same roll_up_hours() pass into the same shape a fine bucket holds, so one build_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

Choose a tag to compare

@github-actions github-actions released this 07 Sep 08:54

Fixed

  • The overview reply was too big to finish, and its largest part was repetition. category_time_series spells 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 reported JSON.parse: end of data at 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_detail emits the same shape, so one encoder serves the overview card and the URL modal alike.

v0.87.0

Choose a tag to compare

@github-actions github-actions released this 07 Sep 07:03

Fixed

  • A URL search named every URL in the window, one memcache key at a time. url_page() hands resolve_urls() the WHOLE shard index whenever a search term or a url sort is set, and that function builds one urlmap:{hash} key per row — 668,918 hashes across four partitions on a production hub. Measured there: /command at 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 reported JSON.parse: unexpected end of data and the dashboard kept the previous poll's numbers. include_workers doubled it again by doubling the shard set. The names now come from the shard: urlnames:{shard}:{bucket}, folded to urlnames_h by the same pass that folds urls_h, read on the same read_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_SRV split's KEY — the axis the server dropdown is already built from. urlmap now stores [ path, origin ], a search matches the path alone, and resolve_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 same INDEX_READ_CHUNK.
  • Stats_Store::split_url() was lossless only for a URL with a path. https://host?q=1 split 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 pins origin . path === url.
  • A flush made one un-batched round trip per URL. mirror_url_stats() wrote each per-URL aggregate with its own set, 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, urlnames and urlnames_h share one key geometry, so they share one reader with four named wrappers. set_url_hour() and set_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

Choose a tag to compare

@github-actions github-actions released this 07 Sep 04:24

Fixed

  • The firehose cap now bounds what the wire carries. MAX_DATA_SIZE was measured against the caller's $data, but n, k and ts are 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 keeps n, k and ts so a reader can still place the line.
  • Every failed Ask showed "An error occurred" instead of why. useAsk reports a reason STRING — the substrate types a reply's error as ?string, and errorMessage() coerces every TM_ERROR payload before onDone sees it — but the page's handler read err.message, which is undefined on a string, so the real reason was discarded on both failure paths. The tests passed because they drove the handler with an Error object 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:line citation 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 passes Admin::devtools_overlay_pages() as its page value was dropped, since Current_Request_Overlay::enqueue_on_overlay_pages() never calls enqueue_react_page at all — it gates on its own is_overlay_page() and hand-rolls wp_enqueue_script.
  • Every PHP and JS source file's documentation reviewed against its code. One agent per file, tests excluded: missing docblocks written, @param/@return drift 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 @type naming LazyExoticComponent with no type argument overrode correct inference and erased PerformanceDashboard's whole props contract; @ inside prose (@<priority>, @0) parses as a tag and is a syntax error; a %s was handed a number.

v0.86.2

Choose a tag to compare

@github-actions github-actions released this 02 Sep 23:10

Fixed

  • A flame-graph tooltip stays inside the viewport, and stops growing past it. A frame's detail is its raw message — a query frame carries the whole SQL, a the_content frame 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 lines marker, the box takes a max-height of the viewport and scrolls inside it, and the flip is clamped at both edges rather than only the bottom.