Files
obsidian-vault/work/projects/cpm-wake-init-detection-tech-design-2026-11-10.md
T

189 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags:
- work
- cpm
- tech-design
- pixel
- web-extension
updated: '2026-11-10'
status: draft
---
# TD: Pixel Definition: CPM Wake Init Detection (macOS/iOS + embedded web extension)
> Internal metrics, keep out of public PRs. Builds on [[cpm-permanent-breakage-metric-tech-design-2026-11-10]] (the pixel this design extends). Where we are: [[cpm-project-summary-2026-11-03]]. Wake background and what was ruled out: [[cpm-ios-idle-wake-investigation-2026-10-30]], [[cpm-post-release-analysis-2026-10-27]].
**Author:** Alex M
**Reviewer:** TBD (previous CPM pixel designs: Michal)
**Stakeholders:** Michal, Konrad, Russell (from the previous Tech Designs; confirm). Extension change: the privacy-extension owners.
**Project:** [CPM: root-cause the stuck embedded extension messaging (macOS/iOS)](https://app.asana.com/1/137249556945/task/1216761517055116)
**Task:** to create ("Extension lifecycle metrics", from the plan in [[cpm-project-summary-2026-11-03]])
**Previous Tech Designs:** [[cpm-readiness-telemetry-tech-design-2026-10-15]] (background readiness, removed), [[cpm-m3-telemetry-privacy-triage]]
**Privacy Triage:** to request together with [[cpm-permanent-breakage-metric-tech-design-2026-11-10]]
**Extension PR:** [duckduckgo-privacy-extension#3727](https://github.com/duckduckgo/duckduckgo-privacy-extension/pull/3727), branch `alex/cpm-init-progress`
## Background & Requirements
Failures after a background wake are the largest group that has no fix. On macOS 1.214 it is 39% of all failures and 36% of permanent ones (0.68% of DAU); on iOS 7.244, 22% and 15% (0.06% of DAU). The data excludes the usual causes:
- The background is ready: `bg_ready_after` was under one second in 93% of macOS wake episodes. The earlier readiness fix (P1) had no measurable effect on either platform.
- A bare idle-unload and wake does not lose messages in the real-WebKit harness: 0 of 1,500 wake navigations in 1,000 cycles on a fixture page.
- A live view, a responsive process and a loaded context describe the container, not whether the init message was processed.
What the app cannot see is where the startup message stops. The chain for one main-frame document is:
`content script sends init → message reaches the background listener → config → user setting → site check → initResp to the page + dashboard refresh to native`.
The app only sees the last step (dashboard refresh), and that step is a separate branch that does not prove the page received `initResp`. So today every one of these looks the same: no answer.
Requirements:
- Tell where the init chain stopped for a page that never got its answer.
- Know whether the page actually received `initResp`, not only whether the dashboard refreshed.
- No change in what the extension does for the page, and no extra work on the path that is suspected to be fragile beyond the marks themselves.
- Backward compatible in both directions: an older app must tolerate a newer extension, and a newer app an older extension.
## Problem Statement
For every document judged broken by the permanent breakage metric, record the furthest init step the extension reported for it. With that:
1. Separate "init never reached the background" from "background stalled on its own storage or native round trip" from "answer could not be delivered to the page".
2. Join the result with `background` (woken, warm) so wake failures are split from the rest.
3. Pick the next fix from data, not from the hypotheses list (H1–H5 and R1 in [[cpm-post-release-analysis-2026-10-27]]).
Collect no page content, URLs or persistent identifiers.
## Recommended Approach
The extension reports **progress marks** for each main-frame init as fire-and-forget native notifications. The app keeps the furthest mark per document, reports the page at once on `response_failed`, and adds `last_stage` to `web_extension_cpm_breakage`. `response_sent` is recorded but not counted as the page's answer until it is verified against real WebKit.
### Marks
| Stage | Reported by | When | Rank |
|---|---|---|---|
| `page_init_sent` | content script, relayed by the background | the content script is about to send `init` | 0 |
| `received` | background | first thing when `init` arrives, before any await | 1 |
| `config_read` | background | remote config available | 2 |
| `settings_checked` | background | user setting read from native | 3 |
| `site_checked` | background | per-site check answered by native | 4 |
| `response_sent` | background | `tabs.sendMessage(initResp)` resolved | 5 |
| `response_failed` | background | the same call rejected | 6 |
Marks are best effort: they carry `{ tabId, url, stage }`, never wait and never reject, never write to the diagnostics error list, and cannot change CPM state or what the page gets. The native side keeps the highest rank seen for the document; an out-of-order arrival never lowers it. Marks about a document the monitor cannot tie to exactly one tab are dropped.
### Native side
- `AutoconsentWebExtensionMessageHandler` gets a `cpmInitProgress` method. Unknown stage names are ignored without an error, so a newer extension cannot make an older app answer with failures.
- `CPMMessagingHealthEvent.initProgress` carries the mark; the monitor stores `lastStage` per document and clears it on a new navigation, a cancelled navigation and an extension reload.
- Only the dashboard refresh counts as the page's answer. `response_sent` is recorded as a stage; `response_failed` reports a pending page at once.
- The Debug "Simulate CPM Messaging Breakage" switch hides marks as well as dashboard responses.
### Pixel change
`web_extension_cpm_breakage` (defined in [[cpm-permanent-breakage-metric-tech-design-2026-11-10]]) gains one parameter:
| Parameter | Values |
|---|---|
| `last_stage` | `none` (no mark reached the app), `page_init_sent`, `received`, `config_read`, `settings_checked`, `site_checked`. `response_sent` can appear: the page got `initResp` but no dashboard refresh was attributed. `response_failed` appears when it was the reason. |
No new pixel. Names, frequency and the other parameters are unchanged. Both platforms' definitions and `params_dictionary.json5` get the parameter.
### How to read `last_stage`
The marks are relayed by the background, so a dead background cannot report. `none` therefore cannot tell a missing content script, a lost message to the background and a lost notification apart.
| `last_stage` on a breakage | What it says | Next action |
|---|---|---|
| `none` | nothing reached native: content script not injected, `init` and the mark lost on the way to the background, or the background cannot notify native | check injection (`tab_has_extension_user_scripts` is gone); WebKit-side loss on wake (R1, H2) |
| `page_init_sent` | the content script sent; the background never processed `init` | message lost between content script and worker listener after a wake: content-script retry of `init`, WebKit bug report |
| `received` | `init` arrived; remote config or session storage did not finish | H1 (unbounded config/storage reads, shared state queue): bound the awaits with ordering-safe semantics |
| `config_read` | stuck on the user setting round trip to native | native round-trip stall, H5: answer from cached config first |
| `settings_checked` | stuck on the site check | same, per-site call |
| `site_checked` | all checks passed, but neither `initResp` nor a dashboard refresh arrived | the page's tab is gone or unreachable (tab not connected) |
| `response_failed` | `tabs.sendMessage` rejected | tab not found or content script gone: the page's context changed under the answer |
A site or a setting that disables CPM legitimately ends the chain at `settings_checked` or `site_checked`, but then the extension still sends the dashboard refresh, so those pages are healthy and never appear here.
### Extension change
All changes are in `duckduckgo-privacy-extension`, branch `alex/cpm-init-progress`. Commit `b00cc227` (pushed, PR #3727) has `received`, `response_sent` and `response_failed`; the remaining marks are an uncommitted working-tree change that has not been tested.
| File | Change |
|---|---|
| `shared/js/background/components/cpm-embedded-messaging.js` | `notifyInitProgress(tabId, url, stage)`: `nativeMessaging.notify('cpmInitProgress', …)` inside the existing timeout helper; catches every error; never records a diagnostics error. |
| `shared/js/background/components/cookie-prompt-management.js` | `received` at the top of the main-frame `init` branch, before any await; `config_read` after the config is available; `settings_checked` after `checkAutoconsentSetting`; `site_checked` after `checkAutoconsentEnabledForSite`; `notifyInitResponseProgress` wraps `tabs.sendMessage(initResp)` to report `response_sent` or `response_failed`, and rethrows so the failure is still an unhandled rejection reported as `extensionException`. A page-side `cpmInitProgress` message is accepted only for the main frame and only for `page_init_sent`; unknown stages and sub-frames are ignored. |
| `shared/js/cpm.js` | `reportInitProgress('page_init_sent')` right before the content script sends `init`; wrapped in try/catch, never awaited, never delays init. |
| `unit-test/background/cookie-prompt-management.js` | order of marks, no await before `received`, `received` before the state reset, dispatch when the remote config is unavailable, a site that disables CPM ends at `site_checked`, `response_failed` keeps the rejection unhandled, sub-frame and unknown-stage marks ignored. |
Compatibility and sequencing:
- The embedded extension ships inside the app bundle, so the extension change reaches users only with an app release after the extension PR merges and the embedded files are updated. The native handler can ship first: it ignores a missing mark, and `last_stage=none` is the value for an app that has no marks yet.
- Older app, newer extension: the handler ignores unknown stages. An app without the `cpmInitProgress` handler is expected to answer with an error that `notifyInitProgress` swallows; to confirm against the real router before merge.
- `cpm.js` is one bundle for every build (`shared/js/cpm.js` goes to the Chromium, Chromium-embedded and embedded builds). `page_init_sent` therefore also reaches the desktop-extension background, where `notifyInitProgress` is absent and the message is dropped by an early return. Cost: one extra `runtime.sendMessage` per main-frame document in those builds. To confirm before merge: that this does not wake a sleeping MV3 worker earlier than the real `init` would, and whether the message should be limited to the embedded build.
- Rollout is by release channel: internal builds carry the new extension first, public builds follow. No remote switch inside the extension: `received` is sent before the config is read, so the config cannot gate it.
### Pros
- Turns "no answer" into a position in a known chain, which maps directly to the hypotheses and to concrete fixes.
- `response_sent` is closer to the real criterion (the page got `initResp`) than a dashboard refresh.
- Stays out of the page's way: fire-and-forget, no waits, no state changes.
- Both sides tolerate the other being older.
### Cons
- Marks pass through the same background whose failure we study, so the earliest failures collapse into `none`.
- Six notifications per main-frame document (five stages before the answer, then `response_sent` or `response_failed`), on top of the native round trips init already makes. The load on the path suspected to be fragile is not zero; its size has not been measured.
- The extension part reaches users only with an app release; the first data comes about two release cycles after the work is done (data in 1–2 release cycles, per the plan).
- A change to the page-visible content script (an extra message) on every build.
### Risks
- **The marks change the behavior they measure** (a heisenbug). Mitigation: internal builds first, compare breakage rates and the `last_stage` mix between releases with and without marks; if the cost is visible, drop `config_read` and `site_checked` in the next extension release.
- **`response_sent` is a false positive.** If `tabs.sendMessage` resolves without a listener (WebKit can resolve `undefined` for a message with no receiver, and the shipped router already returns `undefined` for a successful `runtime.sendMessage(init)`), a page without the content script would be counted healthy and breakage underestimated. Mitigation: a real-WebKit test with no listener must show `response_failed` or a missing `response_sent`; until that passes, `response_sent` is recorded but not counted (open question 1).
- **Marks arrive out of order or late.** Handled by rank; a document judged before its last mark arrives would carry an earlier `last_stage`. Marks arriving after the report are ignored.
- **A hostile page forges marks.** The content script runs in an isolated world; the page cannot call `chrome.runtime`. The background accepts a page-side mark only for `page_init_sent` and only from the main frame.
## Testing
- Extension: `make unit-test` (the cookie-prompt-management suite), `npm run tsc`, `npm run lint`. The uncommitted part has not been run.
- Native unit tests: stage ranking and not lowering; `response_sent` as healthy; `response_failed` as an immediate judgement; unknown stage ignored; a mark that cannot be tied to one tab dropped; `lastStage` cleared on a new navigation and on reload; `last_stage` in the pixel; simulation hides marks.
- Real WebKit (CPM repro harness, [PR #7378](https://github.com/duckduckgo/apple-browsers/pull/7378)): the full chain shows every mark in order on a healthy page; a page without the content script yields no `response_sent`; an injected stall at each await shows the matching `last_stage`; a delay on the native round trips for `config_read` and `settings_checked`. These runs have not been done.
- Pixel definitions pass both platforms' validators in CI.
- Privacy check of real payloads: the marks contain `url` on the extension-to-app channel only; the pixel carries the enum only.
## Additional Considerations (if applicable)
**Privacy**
One new enumerated parameter on a pixel that is itself new in [[cpm-permanent-breakage-metric-tech-design-2026-11-10]]. The marks carry the tab ID and the URL from the extension to the app inside the app (the dashboard refresh already does), but neither enters a pixel or a log (the monitor's log descriptions omit URLs). Privacy Triage to be requested once for both designs. Not requested yet.
**Security**
No new private WebKit selectors. A new native message method, `cpmInitProgress`, is read-only: it parses a stage name and a tab, never executes content and never answers with data. Page-origin spoofing is covered in Risks.
**Site Breakage**
No intended page-behavior change. The only extension behavior change is the notifications; the `initResp` call keeps its arguments and its rejection behavior (`notifyInitResponseProgress` rethrows).
**Experimentation**
None. The comparison is across releases and channels (see the heisenbug risk).
**Operational**
- Order of landing: native handler and `last_stage` (PR 4 in the stack of the companion design) can ship before the extension; the extension PR merges after review and enters the app through the usual embedded-files update.
- After the first public release with marks: `last_stage × background × platform` over the breakage count, and the share of breakages by stage, to pick the fix according to the table above. Data checks as in the companion design.
- The marks and the parameter are temporary investigation tools. Remove `config_read`, `settings_checked`, `site_checked` and `last_stage` when the wake group has a fix or is closed (project plan: close the project and reset the target if the new metrics show nothing actionable). The expiry date follows the pixel definition (2027-01-15).
- Not in scope: per-stage durations (a time bucket between marks), the background's own lifecycle metrics, and the idle-unload distance. They are the next candidates if `last_stage` leaves the wake group unexplained.
**Localization / Internationalization**
None.
## Open questions
1. Count `response_sent` as healthy in the first release, or only record it until the no-listener test proves it cannot be a false positive?
2. Six marks per page or fewer: is `config_read` and `site_checked` worth the extra notifications, or are `received`, `settings_checked` and the response enough for the first read?
3. Limit `page_init_sent` to the embedded build, or accept the extra message on every build?
4. Is an extension-side switch needed (a remote-config setting read before sending `received`), or is release-channel rollout enough?
5. Who owns the extension review, and can the embedded-files update be timed to the app release that carries the native handler?