@analitikisler/ai-tag-debugger 0.21.0 → 0.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +32 -3
- package/README.tr.md +31 -2
- package/package.json +4 -1
- package/src/agent.js +5 -3
- package/src/browser.js +111 -15
- package/src/checks.js +44 -12
- package/src/containers.js +164 -0
- package/src/discover.js +5 -2
- package/src/frames.js +90 -0
- package/src/html/scope.js +15 -1
- package/src/i18n.js +98 -0
- package/src/mcp.js +85 -10
- package/src/report.js +17 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.22.1 (2026-11-04)
|
|
4
|
+
|
|
5
|
+
- Runs can report different findings than 0.22.0 on the same site: an embedded widget's own hits no longer count as the site's, so a planned event only a widget sent is now missing, and the new `widget_tracking` and `iframe_container` (info) findings appear. A baseline written with 0.22.0 can then list stale or new entries; `--update-baseline` rewrites it.
|
|
6
|
+
- Fixed: hits sent from inside an iframe counted as the site's own tracking, so an embedded widget (a booking or chat widget, a payment form, a video) tracking to its vendor's own accounts could satisfy a planned event, be listed as the site's unplanned events, or move the scores. Hits, dataLayer pushes, vendor calls and container loads from an iframe now record its origin, the page it is embedded in and whether it is on the site's own site. A third-party iframe's hits to accounts the site's own tags don't use are the widget's: they are left out of plan matching, coverage, duplicates, unplanned events and the scores, and kept for the consent and personal data checks, whose findings name the widget. A plan drafted with `aitd plan` leaves them out too.
|
|
7
|
+
- New `widget_tracking` (info) finding: each embedded widget that tracked on its own, with its accounts and events. New `iframe_container` (info) finding: a container in a same-site iframe beside one in the page, which the page's dataLayer pushes and consent never reach.
|
|
8
|
+
- The Google containers card and `report.md` table say which containers ran in an iframe. MCP step results mark each hit from an iframe and whether the plan checks count it, and the JSON timeline lists widget hits per step under `widget_hits`.
|
|
9
|
+
|
|
10
|
+
## 0.22.0 (2026-11-04)
|
|
11
|
+
|
|
12
|
+
- Runs can report findings 0.21.0 did not on the same site (`container_duplicate`, `container_environment`, `datalayer_split`, `container_send_to_only`). A baseline written with 0.21.0 can then list new entries; `--update-baseline` rewrites it.
|
|
13
|
+
- New **Google containers** card in the Inventory section and a table in `report.md`: every GTM container and Google tag the pages loaded, with how it got there (a script tag in the HTML, the inline snippet, another script such as a consent platform, a Custom HTML tag or a Google tag in another container, a Zone or template, a `gtag('config')` command, an event's `send_to`, or a destination of the Google tag), the dataLayer it reads, whether it was served by Google or from the site's own domain, and the GTM environment when one is named. The source is read from the page as each script is added, not guessed from network requests. `capture.json` keeps them under `containerLoads`.
|
|
14
|
+
- MCP: the new `get_containers` tool lists the Google containers an open audit's pages loaded so far, with their sources. Step results and `get_step` list the containers each step loaded, `finish_audit` and `run_audit` end with them, the JSON timeline carries them per step, and `get_report` returns them with `part: "containers"`.
|
|
15
|
+
- Pushes to a dataLayer of another name that a container reads (`l=myDataLayer`) are recorded, marked with that name.
|
|
16
|
+
- New checks: `container_duplicate` (warning) for a container loaded more than once into one page, `container_environment` (warning) for a GTM environment loaded instead of Live, `datalayer_split` (info) for containers on one page reading different dataLayers, and `container_send_to_only` (info) for a destination only an event's `send_to` loaded.
|
|
17
|
+
- Development: the guide for coding agents is `AGENTS.md`, the name agents of any vendor read, instead of `CLAUDE.md`, and CONTRIBUTING.md points to it.
|
|
18
|
+
- Development: ESLint's `minimatch` is held at 11 through `overrides` in package.json, for the globparts denial-of-service advisory (GHSA-g3hv-75wg-m249) that no 3.x or 10.x release fixes; ESLint itself still ships an older one. The tool as installed from npm was never affected: `minimatch` is only a development dependency. `npm audit` reports none.
|
|
19
|
+
|
|
3
20
|
## 0.21.0 (2026-11-03)
|
|
4
21
|
|
|
5
22
|
- Runs can report findings 0.20.1 did not on the same site (hits with `ad_user_data` or `ad_personalization` granted before consent or after a reject, `gclid_not_stored`, `client_id_changed`, `consent_default_not_applied`, `page_blocked`, `page_crashed`, and tags sent with cookies when the visitor made no choice), and no longer reports `consent_default_missing` where a consent platform sets the default inside GTM. A baseline written with 0.20.1 can then list stale or new entries; `--update-baseline` rewrites it.
|
package/README.md
CHANGED
|
@@ -309,6 +309,34 @@ When the plan is not the site's first, the header compares it with the version s
|
|
|
309
309
|
|
|
310
310
|
Every report also has a **tag inventory**: each vendor the site sent requests to, marked first party when its requests went to the site's own domain, third party when they went to the vendor, or both. It shows how many pages the vendor was seen on and the cookies tied to it. Cookies that no vendor owns are listed apart, as first or third party. Only cookie names are recorded, never their values, with two exceptions that are derived rather than copied: the GA4 client id that `_ga` holds (GA4 hits carry it anyway), and whether `_gcl_aw` and `_fbc` hold the click ids the landing page was opened with. Each step also notes which cookies it added, changed or removed, by name, in its **Cookies** tab under **Journeys**. It is the **Inventory** section of `report.html` and a table in `report.md`.
|
|
311
311
|
|
|
312
|
+
### Google containers
|
|
313
|
+
|
|
314
|
+
Under the inventory, **Google containers** lists every GTM container (`gtm.js`) and Google tag (`gtag/js`) the pages loaded, and how each one got there. The browser notes every container script as it is added and, when a script added it, which scripts were running at that moment, so the source is read from the page itself rather than guessed from network requests:
|
|
315
|
+
|
|
316
|
+
| Source | Meaning |
|
|
317
|
+
|---|---|
|
|
318
|
+
| A script tag in the page's HTML | The `gtag.js` snippet, or `gtm.js` referenced directly |
|
|
319
|
+
| Inline code in the page | The GTM snippet, or a hand-written loader |
|
|
320
|
+
| Another script on the page | A consent platform, the site's bundle or another tag manager; its address is given |
|
|
321
|
+
| A Custom HTML tag in GTM-… | A Custom HTML tag in that container inserted it |
|
|
322
|
+
| A Google tag in GTM-… | A Google tag or template in that container, through GTM's `load_google_tags` |
|
|
323
|
+
| GTM-… itself | One GTM container loaded another: a Zone or a tag template |
|
|
324
|
+
| A `gtag('config')` command | The Google tag loaded the destination because the page configured it |
|
|
325
|
+
| An event's `send_to` with no config | The Google tag loaded the destination only because an event was sent to it |
|
|
326
|
+
| A destination of the Google tag | The Google tag loaded it with no command in the page, such as a linked destination |
|
|
327
|
+
|
|
328
|
+
Each row also gives the dataLayer the container reads (`l=`), whether it came from Google or from the site's own domain (the Google tag gateway or a server container), the GTM environment when the snippet names one, and the pages it loaded on. A container reading a dataLayer of another name has its pushes recorded too, marked with that name. `report.md` has the same table. Over MCP, step results and `get_step` list the containers each step loaded, `get_containers` lists them all while the audit is open, `finish_audit` and `run_audit` end with them, `get_report` returns them with `part: "containers"` and the JSON timeline carries them per step. `capture.json` keeps the loads under `containerLoads`. The environment token (`gtm_auth`) and the page's query string are never kept.
|
|
329
|
+
|
|
330
|
+
Four checks read it: `container_duplicate` (warning) for a container loaded more than once into one page, `container_environment` (warning) for a GTM environment (`gtm_preview`, `gtm_auth`) loaded instead of Live, `datalayer_split` (info) for containers on one page reading different dataLayers, and `container_send_to_only` (info) for a destination that only an event's `send_to` loaded, with no `gtag('config')` for it.
|
|
331
|
+
|
|
332
|
+
### Embedded widgets and iframes
|
|
333
|
+
|
|
334
|
+
Hits, dataLayer pushes, vendor calls and container loads sent from inside an iframe record which iframe: its origin, the page it is embedded in, and whether it is on the site's own site (a checkout or form on a subdomain) or someone else's (a booking or chat widget, a payment form, a video). A hit from a third-party iframe still counts as the site's when it goes to an account the site's own tags use: a GA4 ID, Ads conversion ID, pixel or container the page or a same-site iframe uses or configures, as when a booking engine is set up with the site's GA4 ID. Otherwise it is the widget's own tracking.
|
|
335
|
+
|
|
336
|
+
A widget's own hits are left out of everything that judges the site's tracking: plan matching, coverage, duplicates, unplanned events and the scores. A widget's `purchase` to its vendor's property can't pass for the site's, and its events aren't listed as the site's. They still count for consent and personal data, since the site answers for what the widgets it embeds collect; those findings begin with "In an embedded widget from …". Each widget that tracked is named once in a `widget_tracking` (info) finding with its accounts and events, and a container running in a same-site iframe beside one in the page is noted as `iframe_container` (info): the page's dataLayer pushes and consent never reach it. A widget's dataLayer pushes are kept apart too, so its consent commands don't stand in for the site's.
|
|
337
|
+
|
|
338
|
+
`capture.json` keeps the widget's hits under `widgetHits` and its pushes under `widgetDataLayerLog`, and every iframe entry has a `frame`. The Google containers card says which ran in an iframe. Over MCP, step results mark each hit from an iframe and whether the plan checks will count it, pushes from an iframe are prefixed with it, and the JSON timeline lists a widget's hits per step under `widget_hits`. Captures from older versions have no frames, so all of their hits stay the site's.
|
|
339
|
+
|
|
312
340
|
### Folder layout and plan IDs
|
|
313
341
|
|
|
314
342
|
Plans and runs are kept in `aitd/` in the folder you run the tool from:
|
|
@@ -604,12 +632,12 @@ Output:
|
|
|
604
632
|
- **Journeys**: one journey and viewport at a time. The steps on the left, named after the pages ("Open Home", "Add to cart") and grouped by page, with the hits each step produced; the selected step on the right with **Hits**, **Data Layer** and **Requests** tabs. Each hit shows its event, key parameters and consent state, and opens into the decoded parameters, item cards and the raw request. A batched GA4 request shows each of its events. It opens on the first step with a problem.
|
|
605
633
|
- **Consent**: one line per platform with what it received before a choice, after accept and after reject, the Consent Mode default and updates with their timing, and the [consent scenarios](#consent-scenarios) table.
|
|
606
634
|
- **Coverage**: whether each planned event was sent on each journey, with the parameters it still needs, conversion integrity, and, with `--against`, the [Plan vs. site](#plan-vs-site) buckets.
|
|
607
|
-
- **Inventory**: the [tag inventory](#tag-inventory), one line per platform, opening into its hosts, pages and cookies.
|
|
635
|
+
- **Inventory**: the [tag inventory](#tag-inventory), one line per platform, opening into its hosts, pages and cookies, and the [Google containers](#google-containers) with how each reached the page.
|
|
608
636
|
- **Run**: the facts of the run (site, version, AI provider and model, plan, journeys, viewports, platforms, timing, files) and, when the site has earlier runs, the **Trend**.
|
|
609
637
|
|
|
610
638
|
Every card starts collapsed except the Summary; each section has Expand all and Collapse all. The page carries one small script, allowed by its hash in the page's Content Security Policy, for the rail, the filters, the tabs and the deep links; without JavaScript the sections stack into one document, which is also the print layout. Keyboard: `[` folds the rail, `1`–`7` jump to a section. The logos are embedded, so nothing is loaded from the internet. `--no-branding` leaves out the Analitik İşler logo and credit line.
|
|
611
639
|
- `timeline.csv`: the same timeline as one row per hit, dataLayer push and network call, ready for Excel or Google Sheets.
|
|
612
|
-
- `report.md`: the report in Markdown for a pull request comment, a Slack message or a mail, around 100 lines and under 8 KB for a four-journey run. Its title is the verdict (`# shop.example: purchase tracking is broken on mobile`), then one meta line, the status with the data quality score, and the counts. Then, in this order: **Changed since last run** and **Trend** (when the site has earlier runs), **Plan vs. site** (with `--against`), **Priorities** (the to-do list per owner: Developer, Tag Manager, Marketing), **Findings** (up to five full blocks with Where, Impact, Cause, Evidence, Also fine on, Owner, Fix, How to verify and a link into `report.html`; the rest one line each under *Also*), **Consent** (the Consent Mode timing sentence and the scenario table with a reason in every cell), **What passed**, **Journeys** (one row per run, then only the steps that carry a problem), **Not checked** (what the run could not judge, with the config key that enables it)
|
|
640
|
+
- `report.md`: the report in Markdown for a pull request comment, a Slack message or a mail, around 100 lines and under 8 KB for a four-journey run. Its title is the verdict (`# shop.example: purchase tracking is broken on mobile`), then one meta line, the status with the data quality score, and the counts. Then, in this order: **Changed since last run** and **Trend** (when the site has earlier runs), **Plan vs. site** (with `--against`), **Priorities** (the to-do list per owner: Developer, Tag Manager, Marketing), **Findings** (up to five full blocks with Where, Impact, Cause, Evidence, Also fine on, Owner, Fix, How to verify and a link into `report.html`; the rest one line each under *Also*), **Consent** (the Consent Mode timing sentence and the scenario table with a reason in every cell), **What passed**, **Journeys** (one row per run, then only the steps that carry a problem), **Not checked** (what the run could not judge, with the config key that enables it), the **Tag inventory** and **Google containers**. The per-hit timeline is not in the file; `timeline.csv` has it.
|
|
613
641
|
- `capture.json`: everything captured, for your own analysis, including the tool `version`, the `ai` provider and the computed `metrics` (verdict, score, plan coverage, funnel, platform parity, consent compliance, conversion integrity, timing, priorities).
|
|
614
642
|
- `plan-check.json`: with `--against`, one entry per planned event with its bucket.
|
|
615
643
|
- `junit.xml`: the findings as JUnit test results, one suite per journey and viewport, for CI systems to show as test failures (see above).
|
|
@@ -637,7 +665,8 @@ Tools the server offers:
|
|
|
637
665
|
| `get_step` | Shows what an earlier `step` captured again, as `browser_action` showed it, including a page_view that came late and was added to it. |
|
|
638
666
|
| `finish_audit` | Checks everything against your measurement plan (JSON), a saved one given as `against` (`latest`, `first`, a plan ID, or on a local server a path), or the ready-made journey's events, optionally only the `events` you name, and writes the full HTML report with the timeline. The site comes from the audit. The result ends with a compact JSON timeline, one entry per step: the hits with their consent, consent commands, other tracking calls, the Google tag's consent when it changed, and the cookies and ids that changed. |
|
|
639
667
|
| `consent_matrix` | Opens a site's start page like an ad click once per consent choice (no choice and on to the next page, accept all, reject all, analytics only), each in a fresh browser, and returns which tags sent after each choice and the [consent matrix](#consent-scenarios). `campaign` takes the campaign parameters to check (as in `start_audit`), `concurrency` how many choices run at once (default 2, at most 4). About half a minute. |
|
|
640
|
-
| `
|
|
668
|
+
| `get_containers` | Lists every GTM container and Google tag the open audit's pages loaded so far, with how each reached the page (see [Google containers](#google-containers)), its dataLayer, host, GTM environment and the step it loaded at, as text and JSON. |
|
|
669
|
+
| `get_report` | Reads a report this server wrote, by the name `finish_audit` or `run_audit` gave: `part` `markdown` (default, `report.md`), `summary` (`summary.json`), `timeline` (the JSON timeline of every journey, with the containers each step loaded) or `containers` (every Google container with how it reached the page, as JSON). Only this server's own reports, over HTTP too. |
|
|
641
670
|
| `run_audit` | Runs a config file end to end. With `against`, it checks the run against a saved plan and, when the config has no journeys, walks the journeys derived from that plan. `concurrency` runs that many journeys at once, each in its own browser (default 1), and `baseline` names a file of accepted findings written with `--update-baseline` (default: the config's `baseline`; see [Accept known findings](#accept-known-findings)). Local only for file paths. |
|
|
642
671
|
|
|
643
672
|
`against` takes the same values as the CLI's `--against`. Over HTTP it takes only `latest`, `first` or a plan ID, never a path. Plans are the ones saved by `aitd plan --site <url>` or by `make_plan`. Ask for a plan when you want to know what to track, and for an audit when you want to know what the site sends.
|
package/README.tr.md
CHANGED
|
@@ -309,6 +309,34 @@ Kontrol edilen plan sitenin ilk planı değilse, rapor başlığı onu bir önce
|
|
|
309
309
|
|
|
310
310
|
Her raporda ayrıca bir **etiket envanteri** bulunur: sitenin istek gönderdiği her sağlayıcı; istekleri sitenin kendi alan adına gittiyse birinci taraf, sağlayıcıya gittiyse üçüncü taraf, ikisine de gittiyse ikisi olarak işaretlenir. Sağlayıcının görüldüğü sayfa sayısını ve ona bağlı çerezleri gösterir. Hiçbir sağlayıcıya ait olmayan çerezler ayrıca birinci ya da üçüncü taraf olarak listelenir. Yalnızca çerez adları kaydedilir, değerleri asla; kopyalanmayıp türetilen iki istisna dışında: `_ga`'nın tuttuğu GA4 istemci kimliği (GA4 istekleri onu zaten taşır) ve `_gcl_aw` ile `_fbc`'nin açılış sayfasının açıldığı tıklama kimliklerini tutup tutmadığı. Her adım ayrıca eklediği, değiştirdiği ya da sildiği çerezleri adlarıyla, **Yolculuklar** altındaki **Çerezler** sekmesinde not eder. Envanter `report.html` içindeki **Envanter** bölümünde, `report.md` içinde ise bir tablo olarak yer alır.
|
|
311
311
|
|
|
312
|
+
### Google container'ları
|
|
313
|
+
|
|
314
|
+
Envanterin altında **Google container'ları**, sayfaların yüklediği her GTM container'ını (`gtm.js`) ve Google etiketini (`gtag/js`) ve her birinin sayfaya nasıl geldiğini listeler. Tarayıcı her container betiğini eklendiği anda not eder ve onu bir betik eklediyse o anda hangi betiklerin çalıştığını kaydeder; böylece kaynak ağ isteklerinden tahmin edilmez, sayfanın kendisinden okunur:
|
|
315
|
+
|
|
316
|
+
| Kaynak | Anlamı |
|
|
317
|
+
|---|---|
|
|
318
|
+
| Sayfanın HTML'indeki bir script etiketi | `gtag.js` snippet'i ya da doğrudan eklenmiş `gtm.js` |
|
|
319
|
+
| Sayfadaki satır içi kod | GTM snippet'i ya da elle yazılmış bir yükleyici |
|
|
320
|
+
| Sayfadaki başka bir betik | Bir onay platformu, sitenin paketi ya da başka bir etiket yöneticisi; adresi verilir |
|
|
321
|
+
| GTM-… içindeki bir Özel HTML etiketi | O container'daki bir Özel HTML etiketi ekledi |
|
|
322
|
+
| GTM-… içindeki bir Google etiketi | O container'daki bir Google etiketi ya da şablonu, GTM'in `load_google_tags` API'siyle |
|
|
323
|
+
| GTM-… kendisi | Bir GTM container'ı başka birini yükledi: bir Zone ya da etiket şablonu |
|
|
324
|
+
| Bir `gtag('config')` komutu | Sayfa hedefi yapılandırdığı için Google etiketi onu yükledi |
|
|
325
|
+
| config olmadan bir olayın `send_to` değeri | Google etiketi hedefi yalnızca ona bir olay gönderildiği için yükledi |
|
|
326
|
+
| Google etiketinin bir hedefi | Google etiketi onu sayfada bir komut olmadan yükledi, örneğin bağlı bir hedef |
|
|
327
|
+
|
|
328
|
+
Her satır ayrıca container'ın okuduğu dataLayer'ı (`l=`), Google'dan mı yoksa sitenin kendi alan adından mı (Google etiket ağ geçidi ya da bir sunucu container'ı) geldiğini, snippet bir GTM ortamı adlandırıyorsa onu ve yüklendiği sayfaları verir. Başka adlı bir dataLayer okuyan container'ın push'ları da o adla işaretlenerek kaydedilir. `report.md` aynı tabloyu içerir. MCP'de adım sonuçları ve `get_step` her adımın yüklediği container'ları listeler, `get_containers` denetim açıkken hepsini listeler, `finish_audit` ve `run_audit` sonuçları onlarla biter, `get_report` onları `part: "containers"` ile döndürür ve JSON zaman akışı onları adım adım taşır. `capture.json` yüklemeleri `containerLoads` altında saklar. Ortam belirteci (`gtm_auth`) ve sayfanın sorgu dizesi asla saklanmaz.
|
|
329
|
+
|
|
330
|
+
Dört kontrol bunu okur: aynı sayfaya birden fazla kez yüklenen bir container için `container_duplicate` (uyarı), Live yerine yüklenen bir GTM ortamı (`gtm_preview`, `gtm_auth`) için `container_environment` (uyarı), aynı sayfada farklı dataLayer'ları okuyan container'lar için `datalayer_split` (bilgi) ve kendisi için `gtag('config')` olmadan yalnızca bir olayın `send_to` değeriyle yüklenen bir hedef için `container_send_to_only` (bilgi).
|
|
331
|
+
|
|
332
|
+
### Gömülü widget'lar ve iframe'ler
|
|
333
|
+
|
|
334
|
+
Bir iframe içinden gönderilen istekler, dataLayer push'ları, sağlayıcı çağrıları ve container yüklemeleri hangi iframe'den geldiklerini kaydeder: kaynağını, gömülü olduğu sayfayı ve sitenin kendi sitesinde mi (bir alt alan adındaki ödeme ya da form) yoksa başkasının mı (bir rezervasyon ya da sohbet widget'ı, bir ödeme formu, bir video) olduğunu. Üçüncü taraf bir iframe'den gelen istek, sitenin kendi etiketlerinin kullandığı bir hesaba gidiyorsa yine sitenindir: sayfanın ya da aynı sitedeki bir iframe'in kullandığı veya yapılandırdığı bir GA4 kimliği, Ads dönüşüm kimliği, piksel ya da container; örneğin sitenin GA4 kimliğiyle kurulmuş bir rezervasyon motoru. Aksi hâlde widget'ın kendi izlemesidir.
|
|
335
|
+
|
|
336
|
+
Bir widget'ın kendi istekleri, sitenin izlemesini değerlendiren her şeyin dışında tutulur: plan eşleştirme, kapsam, tekrarlar, planda olmayan olaylar ve puanlar. Widget'ın sağlayıcısının mülküne giden bir `purchase` sitenin yerine geçemez ve olayları sitenin olayları olarak listelenmez. Yine de onay ve kişisel veri için sayılırlar, çünkü site gömdüğü widget'ların topladıklarından sorumludur; bu bulgular "… kaynaklı gömülü bir widget içinde:" ile başlar. İzleme yapan her widget, hesapları ve olaylarıyla bir kez `widget_tracking` (bilgi) bulgusunda adlandırılır; sayfadakinin yanında aynı sitedeki bir iframe'de çalışan container ise `iframe_container` (bilgi) olarak not edilir: sayfanın dataLayer push'ları ve onayı ona hiç ulaşmaz. Widget'ın dataLayer push'ları da ayrı tutulur, böylece onay komutları sitenin komutlarının yerine geçmez.
|
|
337
|
+
|
|
338
|
+
`capture.json` widget'ın isteklerini `widgetHits`, push'larını `widgetDataLayerLog` altında saklar ve her iframe kaydında bir `frame` bulunur. Google container'ları kartı hangilerinin bir iframe'de çalıştığını söyler. MCP'de adım sonuçları iframe'den gelen her isteği ve plan kontrollerinin onu sayıp saymayacağını işaretler, iframe'den gelen push'ların başına iframe yazılır ve JSON zaman akışı bir widget'ın isteklerini adım adım `widget_hits` altında listeler. Eski sürümlerin kayıtlarında frame bilgisi yoktur, bu yüzden tüm istekleri sitenin olarak kalır.
|
|
339
|
+
|
|
312
340
|
### Klasör yapısı ve plan kimlikleri
|
|
313
341
|
|
|
314
342
|
Planlar ve çalıştırmalar, aracı çalıştırdığınız klasördeki `aitd/` içinde tutulur:
|
|
@@ -604,7 +632,7 @@ jq -r '.verdict.status + " " + (.score | tostring)' aitd/example.com/runs/*/summ
|
|
|
604
632
|
- **Yolculuklar**: her seferinde bir yolculuk ve ekran. Solda sayfalara göre adlandırılmış ("Ana sayfayı aç", "Sepete ekle") ve sayfaya göre gruplanmış adımlar, her adımın ürettiği isteklerle; sağda seçili adım, **İstekler**, **Data Layer** ve **Ağ** sekmeleriyle. Her istek event'ini, temel parametrelerini ve çerez onayı durumunu gösterir; çözülmüş parametrelere, ürün kartlarına ve ham isteğe açılır. Toplu bir GA4 isteği içindeki her event'i gösterir. Sorunlu ilk adımda açılır.
|
|
605
633
|
- **Onay**: her platform için bir satır: seçimden önce, kabulden sonra ve retten sonra ne aldığı; Consent Mode varsayılanı ve güncellemeleri zamanlamalarıyla; ve [onay senaryoları](#onay-senaryoları) tablosu.
|
|
606
634
|
- **Kapsam**: planlanan her event'in her yolculukta gönderilip gönderilmediği, hâlâ ihtiyaç duyduğu parametrelerle; dönüşüm bütünlüğü; ve `--against` ile [Plan ve site](#plan-ve-site) kovaları.
|
|
607
|
-
- **Envanter**: [etiket envanteri](#etiket-envanteri), platform başına bir satır; sunucularına, sayfalarına ve çerezlerine açılır.
|
|
635
|
+
- **Envanter**: [etiket envanteri](#etiket-envanteri), platform başına bir satır; sunucularına, sayfalarına ve çerezlerine açılır; ayrıca her birinin sayfaya nasıl geldiğiyle [Google container'ları](#google-containerları).
|
|
608
636
|
- **Çalıştırma**: çalıştırmanın künyesi (site, sürüm, yapay zekâ sağlayıcısı ve modeli, plan, yolculuklar, ekranlar, platformlar, süreler, dosyalar) ve sitenin önceki çalıştırmaları varsa **Eğilim**.
|
|
609
637
|
|
|
610
638
|
Özet dışında her kart kapalı başlar; her bölümde Tümünü aç ve Tümünü kapat düğmeleri vardır. Sayfada şerit, filtreler, sekmeler ve derin bağlantılar için, sayfanın Content Security Policy'sinde özetiyle izin verilmiş küçük bir betik bulunur; JavaScript olmadan bölümler tek bir belge olarak alt alta dizilir, yazdırma düzeni de budur. Klavye: `[` şeridi katlar, `1`–`7` bir bölüme atlar. Logolar gömülüdür, bu yüzden internetten hiçbir şey yüklenmez. `--no-branding`, Analitik İşler logosunu ve kredi satırını çıkarır.
|
|
@@ -637,7 +665,8 @@ Sunucunun sunduğu araçlar:
|
|
|
637
665
|
| `get_step` | Önceki bir adımın (`step`) yakaladıklarını `browser_action`'ın gösterdiği gibi yeniden gösterir; geç gelip o adıma eklenen page_view da dahil. |
|
|
638
666
|
| `finish_audit` | Her şeyi ölçümleme planınıza (JSON) ya da `against` olarak verilen kayıtlı bir plana (`latest`, `first`, bir plan kimliği ya da yerel bir sunucuda bir yol) ya da hazır yolculuğun event'lerine karşı kontrol eder; isterseniz yalnızca adını verdiğiniz `events` için, ve zaman akışıyla birlikte tam HTML raporunu yazar. Site denetimden alınır. Sonuç, adım başına bir kayıt içeren kısa bir JSON zaman akışıyla biter: onay durumlarıyla istekler, onay komutları, diğer takip çağrıları, değiştiğinde Google etiketinin onay durumu, değişen çerezler ve kimlikler. |
|
|
639
667
|
| `consent_matrix` | Bir sitenin başlangıç sayfasını her onay seçimi için (seçim yapmadan sonraki sayfa, tümünü kabul, tümünü reddet, yalnızca analitik) bir reklam tıklaması gibi, her seferinde yeni bir tarayıcıda açar; her seçimden sonra hangi etiketlerin gönderdiğini ve [onay matrisini](#onay-senaryoları) döndürür. `campaign` kontrol edilecek kampanya parametrelerini (`start_audit`'teki gibi), `concurrency` aynı anda kaç seçimin çalışacağını alır (varsayılan 2, en fazla 4). Yaklaşık yarım dakika sürer. |
|
|
640
|
-
| `
|
|
668
|
+
| `get_containers` | Açık denetimin sayfalarının şimdiye kadar yüklediği her GTM container'ını ve Google etiketini, her birinin sayfaya nasıl geldiğiyle ([Google container'ları](#google-containerları)), dataLayer'ı, sunucusu, GTM ortamı ve yüklendiği adımla birlikte metin ve JSON olarak listeler. |
|
|
669
|
+
| `get_report` | Bu sunucunun yazdığı bir raporu, `finish_audit` ya da `run_audit`'in verdiği adla okur: `part` `markdown` (varsayılan, `report.md`), `summary` (`summary.json`), `timeline` (her yolculuğun JSON zaman akışı; her adımın yüklediği container'larla) ya da `containers` (sayfaya nasıl geldiğiyle her Google container'ı, JSON olarak). Yalnızca bu sunucunun kendi raporları, HTTP üzerinden de. |
|
|
641
670
|
| `run_audit` | Bir ayar dosyasını baştan sona çalıştırır. `against` ile çalıştırmayı kayıtlı bir plana karşı kontrol eder; ayar dosyasında yolculuk yoksa, plandan türetilen yolculukları gezer. `concurrency` o kadar yolculuğu aynı anda, her birini kendi tarayıcısında çalıştırır (varsayılan 1); `baseline`, `--update-baseline` ile yazılmış kabul edilen bulgular dosyasını adlandırır (varsayılan: ayar dosyasındaki `baseline`; bkz. [Bilinen bulguları kabul edin](#bilinen-bulguları-kabul-edin)). Dosya yolları yalnızca yerelde çalışır. |
|
|
642
671
|
|
|
643
672
|
`against`, CLI'deki `--against` ile aynı değerleri alır. HTTP üzerinden yalnızca `latest`, `first` ya da bir plan kimliği kabul edilir, asla bir yol değil. Planlar, `aitd plan --site <url>` ya da `make_plan` ile kaydedilmiş olanlardır. Neyin takip edileceğini öğrenmek için plan, sitenin ne gönderdiğini öğrenmek için denetim isteyin.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@analitikisler/ai-tag-debugger",
|
|
3
3
|
"mcpName": "io.github.analitikisler/ai-tag-debugger",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.22.1",
|
|
5
5
|
"description": "Open-source AI agent that QA-tests your website's analytics, ad and consent tracking (GA4, GTM, Google Ads, Meta, TikTok). Bring your own model (Claude, OpenAI, Gemini) or use it from your chat app over MCP.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -67,5 +67,8 @@
|
|
|
67
67
|
"@eslint/js": "^9.39.5",
|
|
68
68
|
"eslint": "^9.39.5",
|
|
69
69
|
"globals": "^17.13.0"
|
|
70
|
+
},
|
|
71
|
+
"overrides": {
|
|
72
|
+
"minimatch": "^11.0.4"
|
|
70
73
|
}
|
|
71
74
|
}
|
package/src/agent.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { AuditSession } from "./browser.js";
|
|
3
|
+
import { splitRun } from "./frames.js";
|
|
3
4
|
import { withCampaignParams } from "./campaign.js";
|
|
4
5
|
import { messages } from "./i18n.js";
|
|
5
6
|
import { createProvider, PROVIDERS } from "./providers.js";
|
|
@@ -237,7 +238,7 @@ export const journeyError = (err) => `The journey stopped with an error: ${err?.
|
|
|
237
238
|
export function failedRun(journey, viewport, err, providers) {
|
|
238
239
|
return {
|
|
239
240
|
journeyId: journey.id, viewport, completed: false, note: journeyError(err), hits: [], steps: [], consentStep: null, rejectStep: null,
|
|
240
|
-
cookieLog: [], cookieChanges: [], dataLayerLog: [], network: [], providers, skipped: {}, bodyBudgetStep: null, startedAt: Date.now(), pageErrors: [],
|
|
241
|
+
cookieLog: [], cookieChanges: [], dataLayerLog: [], containerLoads: [], network: [], widgetHits: [], widgetDataLayerLog: [], providers, skipped: {}, bodyBudgetStep: null, startedAt: Date.now(), pageErrors: [],
|
|
241
242
|
};
|
|
242
243
|
}
|
|
243
244
|
|
|
@@ -246,7 +247,8 @@ export function collect(session, journey, viewport) {
|
|
|
246
247
|
journeyId: journey.id,
|
|
247
248
|
viewport,
|
|
248
249
|
...(journey.goal ? { goal: journey.goal } : {}),
|
|
249
|
-
hits
|
|
250
|
+
// The site's own hits and pushes; an embedded widget's are kept apart (see src/frames.js).
|
|
251
|
+
...splitRun(session),
|
|
250
252
|
steps: session.steps,
|
|
251
253
|
consentStep: session.consentStep,
|
|
252
254
|
rejectStep: session.rejectStep,
|
|
@@ -255,7 +257,7 @@ export function collect(session, journey, viewport) {
|
|
|
255
257
|
cmp: session.cmp,
|
|
256
258
|
// Hits let through to the real accounts (block_hits false, or a live MCP audit): the reports say so.
|
|
257
259
|
...(session.opts.blockHits === false ? { live: true } : {}),
|
|
258
|
-
|
|
260
|
+
containerLoads: session.containerLoads,
|
|
259
261
|
network: session.network,
|
|
260
262
|
providers: session.providers,
|
|
261
263
|
skipped: session.skipped,
|
package/src/browser.js
CHANGED
|
@@ -10,6 +10,8 @@ import { hostScope, isPrivateAddress, navigationError, privateHostChecker } from
|
|
|
10
10
|
import { startEgressProxy } from "./egress.js";
|
|
11
11
|
import { campaignParamsIn } from "./campaign.js";
|
|
12
12
|
import { detectCmpInPage } from "./cmp.js";
|
|
13
|
+
import { callersOf, containerOf } from "./containers.js";
|
|
14
|
+
import { frameInfo } from "./frames.js";
|
|
13
15
|
|
|
14
16
|
// Consent banners of the common consent platforms (CMPs), by their container. A cover that matches
|
|
15
17
|
// one of these always blocks the journey, and a reject click is checked against them afterwards.
|
|
@@ -34,7 +36,7 @@ export const CMP_BANNER_SELECTORS = `#onetrust-banner-sdk, #onetrust-consent-sdk
|
|
|
34
36
|
// Efilli draws its banner inside the open shadow root of <efilli-layout-dynamic>, so the checks below also look inside open shadow roots.
|
|
35
37
|
|
|
36
38
|
// Caps on what one audit records, so a looping page can't grow memory without limit.
|
|
37
|
-
const LOG_LIMITS = { hits: 5000, dataLayerLog: 5000, network: 10000 };
|
|
39
|
+
const LOG_LIMITS = { hits: 5000, dataLayerLog: 5000, network: 10000, containerLoads: 500 };
|
|
38
40
|
|
|
39
41
|
// Secrets the browser process has no use for. It gets the rest of the environment
|
|
40
42
|
// (PATH, HOME, DISPLAY...) so Chromium still starts normally.
|
|
@@ -60,8 +62,57 @@ const hostOf = (url) => {
|
|
|
60
62
|
}
|
|
61
63
|
};
|
|
62
64
|
|
|
65
|
+
// Runs inside INIT_SCRIPT: records each Google container script (gtm.js, gtag/js) the page adds, through
|
|
66
|
+
// window.__atdLoad, with how it got there. One set by a script carries that moment's call stack, whose script
|
|
67
|
+
// addresses tell who asked for it (see src/containers.js); one the HTML parser inserted never passes through
|
|
68
|
+
// the setters, so a MutationObserver reports it. A container reading another dataLayer gets it watched.
|
|
69
|
+
const CONTAINER_HOOK = `
|
|
70
|
+
const docUrl = location.href;
|
|
71
|
+
const doc = Math.random().toString(36).slice(2, 10);
|
|
72
|
+
const isContainer = (src) => /\\/(gtm\\.js|gtag\\/js)\\?/.test(String(src)) && /[?&]id=/.test(String(src));
|
|
73
|
+
const seen = new WeakSet();
|
|
74
|
+
const report = (el, src, how) => {
|
|
75
|
+
try {
|
|
76
|
+
const abs = new URL(String(src), document.baseURI).href;
|
|
77
|
+
if (!isContainer(abs) || !window.__atdLoad) return;
|
|
78
|
+
const layer = new URL(abs).searchParams.get("l") || "dataLayer";
|
|
79
|
+
watch(layer);
|
|
80
|
+
let frames = [];
|
|
81
|
+
if (how === "script") {
|
|
82
|
+
const limit = Error.stackTraceLimit;
|
|
83
|
+
Error.stackTraceLimit = 50;
|
|
84
|
+
const stack = String(new Error().stack || "");
|
|
85
|
+
Error.stackTraceLimit = limit;
|
|
86
|
+
// The first two frames are report() and the setter, both from this script; injected inline code
|
|
87
|
+
// (a Custom HTML tag) has no address and shows as <anonymous>.
|
|
88
|
+
frames = stack.split("\\n").filter((line) => /^\\s+at /.test(line)).slice(2)
|
|
89
|
+
.map((line) => (line.match(/(https?:\\/\\/[^\\s()]+?):\\d+:\\d+/) || [])[1] || (/<anonymous>/.test(line) ? "inline" : null))
|
|
90
|
+
.filter(Boolean);
|
|
91
|
+
}
|
|
92
|
+
window.__atdLoad({ src: abs, how, frames, documentUrl: docUrl, page: location.href, doc });
|
|
93
|
+
} catch {}
|
|
94
|
+
};
|
|
95
|
+
try {
|
|
96
|
+
const desc = Object.getOwnPropertyDescriptor(HTMLScriptElement.prototype, "src");
|
|
97
|
+
Object.defineProperty(HTMLScriptElement.prototype, "src", {
|
|
98
|
+
...desc,
|
|
99
|
+
set(v) { if (!seen.has(this)) { seen.add(this); report(this, v, "script"); } return desc.set.call(this, v); },
|
|
100
|
+
});
|
|
101
|
+
const setAttribute = Element.prototype.setAttribute;
|
|
102
|
+
Element.prototype.setAttribute = function (name, v) {
|
|
103
|
+
if (this instanceof HTMLScriptElement && String(name).toLowerCase() === "src" && !seen.has(this)) { seen.add(this); report(this, v, "script"); }
|
|
104
|
+
return setAttribute.call(this, name, v);
|
|
105
|
+
};
|
|
106
|
+
new MutationObserver((list) => {
|
|
107
|
+
for (const m of list) for (const n of m.addedNodes) {
|
|
108
|
+
if (n instanceof HTMLScriptElement && n.src && !seen.has(n)) { seen.add(n); report(n, n.src, "parser"); }
|
|
109
|
+
}
|
|
110
|
+
}).observe(document, { childList: true, subtree: true });
|
|
111
|
+
} catch {}
|
|
112
|
+
`;
|
|
113
|
+
|
|
63
114
|
const INIT_SCRIPT = `(() => {
|
|
64
|
-
const record = (entry) => {
|
|
115
|
+
const record = (entry, layer = "dataLayer") => {
|
|
65
116
|
let value = entry;
|
|
66
117
|
try {
|
|
67
118
|
// gtag() pushes an arguments object; store it as a plain array.
|
|
@@ -95,22 +146,32 @@ const INIT_SCRIPT = `(() => {
|
|
|
95
146
|
};
|
|
96
147
|
value = JSON.parse(JSON.stringify(copy(value, [])));
|
|
97
148
|
} catch { value = String(entry); }
|
|
98
|
-
if (window.__atdRecord) window.__atdRecord({ url: location.href, value });
|
|
149
|
+
if (window.__atdRecord) window.__atdRecord({ url: location.href, value, ...(layer === "dataLayer" ? {} : { layer }) });
|
|
99
150
|
};
|
|
100
|
-
const wrap = (dl) => {
|
|
101
|
-
if (!dl || dl.__atdWrapped) return dl;
|
|
102
|
-
dl.forEach(record);
|
|
151
|
+
const wrap = (dl, layer) => {
|
|
152
|
+
if (!dl || dl.__atdWrapped || typeof dl.push !== "function") return dl;
|
|
153
|
+
dl.forEach((e) => record(e, layer));
|
|
103
154
|
const push = dl.push.bind(dl);
|
|
104
|
-
dl.push = (...items) => { items.forEach(record); return push(...items); };
|
|
155
|
+
dl.push = (...items) => { items.forEach((e) => record(e, layer)); return push(...items); };
|
|
105
156
|
dl.__atdWrapped = true;
|
|
106
157
|
return dl;
|
|
107
158
|
};
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
159
|
+
const watched = new Set();
|
|
160
|
+
// A container can read a dataLayer of another name (gtm.js?l=myDataLayer); it is watched once one is seen.
|
|
161
|
+
const watch = (layer) => {
|
|
162
|
+
if (watched.has(layer) || !/^[A-Za-z_$][\\w$]*$/.test(layer)) return;
|
|
163
|
+
watched.add(layer);
|
|
164
|
+
try {
|
|
165
|
+
let current = layer === "dataLayer" ? wrap(window[layer] || [], layer) : wrap(window[layer], layer);
|
|
166
|
+
Object.defineProperty(window, layer, {
|
|
167
|
+
configurable: true,
|
|
168
|
+
get: () => current,
|
|
169
|
+
set: (v) => { current = wrap(v, layer); },
|
|
170
|
+
});
|
|
171
|
+
} catch { /* a property the page made unconfigurable */ }
|
|
172
|
+
};
|
|
173
|
+
watch("dataLayer");
|
|
174
|
+
${CONTAINER_HOOK}
|
|
114
175
|
})();`;
|
|
115
176
|
|
|
116
177
|
// Labels the interactive elements of one frame with numeric refs, starting at
|
|
@@ -486,6 +547,7 @@ export class AuditSession {
|
|
|
486
547
|
this.refusedScripts = new Map(); // step -> how many of the page's scripts were refused for coming back as HTML
|
|
487
548
|
this.errors = [];
|
|
488
549
|
this.dataLayerLog = [];
|
|
550
|
+
this.containerLoads = []; // each Google container script the page added, with how (see src/containers.js)
|
|
489
551
|
this.network = [];
|
|
490
552
|
this.providers = resolveProviders(this.opts.providers);
|
|
491
553
|
this.landing = null; // the first page: { step, requested, chain, final }
|
|
@@ -526,10 +588,13 @@ export class AuditSession {
|
|
|
526
588
|
}
|
|
527
589
|
const record = this.opts.recordHar ? { recordHar: { path: this.opts.recordHar, mode: "minimal" } } : {};
|
|
528
590
|
this.context = await this.browser.newContext({ ...device, locale: "en-US", ...record });
|
|
529
|
-
await this.context.exposeBinding("__atdRecord", (
|
|
591
|
+
await this.context.exposeBinding("__atdRecord", (source, entry) => {
|
|
592
|
+
const frame = frameInfo(source?.frame);
|
|
593
|
+
if (frame) entry = { ...entry, frame };
|
|
530
594
|
this.lastTrackingAt = Date.now();
|
|
531
595
|
this.record("dataLayerLog", { ...this.redactEntry(entry), step: this.currentStep, t: Date.now() });
|
|
532
596
|
});
|
|
597
|
+
await this.context.exposeBinding("__atdLoad", (source, entry) => this.recordLoad(entry, frameInfo(source?.frame)));
|
|
533
598
|
await this.context.addInitScript(INIT_SCRIPT);
|
|
534
599
|
this.page = await this.context.newPage();
|
|
535
600
|
this.page.on("pageerror", (err) => this.errors.push({ step: this.currentStep, message: err.message }));
|
|
@@ -557,9 +622,10 @@ export class AuditSession {
|
|
|
557
622
|
const hits = parseTrackingRequest(request.url(), request.postData());
|
|
558
623
|
if (!hits) return;
|
|
559
624
|
const recorded = [];
|
|
625
|
+
const frame = this.frameOf(request);
|
|
560
626
|
for (const hit of hits) {
|
|
561
627
|
if (!this.checks(hit.platform)) continue;
|
|
562
|
-
const entry = { ...this.redactHit(hit), step: this.stepForHit(hit), pageUrl: this.page.url(), t: Date.now() };
|
|
628
|
+
const entry = { ...this.redactHit(hit), step: this.stepForHit(hit), pageUrl: this.page.url(), t: Date.now(), ...(frame ? { frame } : {}) };
|
|
563
629
|
const late = this.lateFor(hit, entry);
|
|
564
630
|
if (late) Object.assign(entry, late);
|
|
565
631
|
if (this.record("hits", entry)) recorded.push(entry);
|
|
@@ -598,6 +664,7 @@ export class AuditSession {
|
|
|
598
664
|
...this.storeBody(request),
|
|
599
665
|
blocked: this.opts.blockHits && isCollectionEndpoint(request.url()),
|
|
600
666
|
status: null,
|
|
667
|
+
...((frame) => (frame ? { frame } : {}))(this.frameOf(request)),
|
|
601
668
|
};
|
|
602
669
|
if (this.record("network", call)) calls.set(request, call);
|
|
603
670
|
});
|
|
@@ -756,6 +823,35 @@ export class AuditSession {
|
|
|
756
823
|
return { postData: this.opts.keepBodies ? body : redactBody(body).text };
|
|
757
824
|
}
|
|
758
825
|
|
|
826
|
+
/**
|
|
827
|
+
* A container script the page added: its ID, dataLayer and host, whether the HTML parser inserted it or a
|
|
828
|
+
* script did and, for a script, the callers on the stack. Only addresses without their query are kept, so
|
|
829
|
+
* the environment token (gtm_auth) and user data in a page's query string never reach the capture.
|
|
830
|
+
*/
|
|
831
|
+
recordLoad(entry, frame = null) {
|
|
832
|
+
const container = containerOf(entry?.src);
|
|
833
|
+
if (!container) return;
|
|
834
|
+
this.record("containerLoads", {
|
|
835
|
+
...container,
|
|
836
|
+
how: entry.how === "parser" ? "parser" : "script",
|
|
837
|
+
...(entry.how === "parser" ? {} : { callers: callersOf(entry.frames, entry.documentUrl) }),
|
|
838
|
+
page: pagePath(entry.page),
|
|
839
|
+
doc: String(entry.doc ?? "").slice(0, 16),
|
|
840
|
+
step: this.currentStep,
|
|
841
|
+
t: Date.now(),
|
|
842
|
+
...(frame ? { frame } : {}),
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** The iframe a request came from, as frameInfo gives it; null for the page itself or a worker. */
|
|
847
|
+
frameOf(request) {
|
|
848
|
+
try {
|
|
849
|
+
return frameInfo(request.frame());
|
|
850
|
+
} catch {
|
|
851
|
+
return null; // service worker requests have no frame
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
759
855
|
/** Adds an entry to one of the logs, up to its limit. Returns false once the limit is reached. */
|
|
760
856
|
record(log, entry) {
|
|
761
857
|
if (this[log].length >= LOG_LIMITS[log]) {
|
package/src/checks.js
CHANGED
|
@@ -5,6 +5,8 @@ import { messages } from "./i18n.js";
|
|
|
5
5
|
import { isOwnAddress } from "./redact.js";
|
|
6
6
|
import { campaignParamsIn } from "./campaign.js";
|
|
7
7
|
import { CONSENT_SIGNALS } from "./parsers.js";
|
|
8
|
+
import { duplicateLoads, loadSources, splitLayers } from "./containers.js";
|
|
9
|
+
import { widgetsOf } from "./frames.js";
|
|
8
10
|
|
|
9
11
|
const platformName = (p) => ({ meta: "Meta", tiktok: "TikTok" })[p] ?? p;
|
|
10
12
|
const NON_GOOGLE_AD_PLATFORMS = new Set(["meta", "tiktok"]);
|
|
@@ -194,6 +196,11 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
|
|
|
194
196
|
if (skipped.length) findings.push({ ...base, severity: "info", check: "planned_out_of_scope", detail: d.plannedOutOfScope(skipped.map((e) => key(e.platform, e.name))) });
|
|
195
197
|
}
|
|
196
198
|
const stepLabel = (i) => run.steps[i]?.label ?? `step ${i}`;
|
|
199
|
+
// Embedded widgets' hits (run.widgetHits, see src/frames.js) count for consent and personal data only: the site
|
|
200
|
+
// answers for what the widgets it embeds collect, but they say nothing about its own tracking plan.
|
|
201
|
+
const legalHits = [...run.hits, ...(run.widgetHits ?? [])].sort((a, b) => (a.t ?? 0) - (b.t ?? 0));
|
|
202
|
+
const legalKey = (hit) => `${key(hit.platform, hit.name)}${hit.frame?.party === "third" ? `|${hit.frame.origin}` : ""}`;
|
|
203
|
+
const fromWidget = (hit, text) => ((run.widgetHits ?? []).includes(hit) ? d.fromWidget(hit.frame.origin, text) : text);
|
|
197
204
|
const planEventFor = (hit) => plan.events.find((e) => hitMatchesEvent(e, hit));
|
|
198
205
|
const planKeyOf = (hit) => { const e = planEventFor(hit); return e ? key(e.platform, e.name) : key(hit.platform, hit.name); };
|
|
199
206
|
|
|
@@ -366,6 +373,31 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
|
|
|
366
373
|
}
|
|
367
374
|
}
|
|
368
375
|
|
|
376
|
+
// Google containers and how each got onto the page (src/containers.js). Runs saved by older versions have none.
|
|
377
|
+
const ui = messages(lang).ui.containers;
|
|
378
|
+
const source = (l) => ui.kinds[l.kind](l.by);
|
|
379
|
+
for (const dup of duplicateLoads(run).filter((x, i, all) => all.findIndex((y) => y.id === x.id) === i)) {
|
|
380
|
+
findings.push({ ...base, severity: "warning", check: "container_duplicate", step: stepLabel(dup.loads[1].step), detail: d.containerDuplicate(dup.id, dup.loads.map(source), stepLabel(dup.loads[1].step)), evidence: dup.loads.map((l) => `${l.id} ${l.kind}${l.by ? ` ${l.by}` : ""} ${l.page ?? ""}`).join("\n") });
|
|
381
|
+
}
|
|
382
|
+
const loads = loadSources(run);
|
|
383
|
+
for (const l of loads.filter((x, i, all) => x.environment && all.findIndex((y) => y.environment && y.id === x.id) === i)) {
|
|
384
|
+
findings.push({ ...base, severity: "warning", check: "container_environment", step: stepLabel(l.step), detail: d.containerEnvironment(l.id, l.environment) });
|
|
385
|
+
}
|
|
386
|
+
const split = splitLayers(run)[0];
|
|
387
|
+
if (split) findings.push({ ...base, severity: "info", check: "datalayer_split", step: stepLabel(split.step), detail: d.dataLayerSplit(split.layers.map((x) => `${x.id} → ${x.layer}`), stepLabel(split.step)) });
|
|
388
|
+
for (const l of loads.filter((x, i, all) => x.kind === "send_to" && !all.some((y) => y.id === x.id && y.kind !== "send_to") && all.findIndex((y) => y.kind === "send_to" && y.id === x.id) === i)) {
|
|
389
|
+
findings.push({ ...base, severity: "info", check: "container_send_to_only", platform: l.id.startsWith("G-") ? "ga4" : "google_ads", step: stepLabel(l.step), detail: d.sendToOnly(l.id, l.by) });
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Widgets that tracked on their own, left out of the plan checks: said once per widget, so nothing goes missing silently.
|
|
393
|
+
for (const w of widgetsOf(run.widgetHits ?? [])) {
|
|
394
|
+
const first = (run.widgetHits ?? []).find((h) => h.frame.origin === w.origin);
|
|
395
|
+
findings.push({ ...base, severity: "info", check: "widget_tracking", step: stepLabel(first.step), detail: d.widgetTracking(w.origin, w.platforms) });
|
|
396
|
+
}
|
|
397
|
+
// A container in a same-site iframe beside one in the page: two documents, two dataLayers.
|
|
398
|
+
const iframeLoad = loads.find((l) => l.frame?.party === "site" && loads.some((t) => !t.frame && t.step <= l.step));
|
|
399
|
+
if (iframeLoad) findings.push({ ...base, severity: "info", check: "iframe_container", step: stepLabel(iframeLoad.step), detail: d.iframeContainer(iframeLoad.id, iframeLoad.frame.origin, iframeLoad.frame.parent) });
|
|
400
|
+
|
|
369
401
|
// One event ID on two different events: the platform keeps the first and drops the other as its
|
|
370
402
|
// duplicate, so a real event is lost (usually an ID set once per page instead of once per event).
|
|
371
403
|
const byId = new Map();
|
|
@@ -501,15 +533,15 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
|
|
|
501
533
|
if (regranted) findings.push({ ...base, severity: "broken", check: "reject_ignored", step: stepLabel(regranted.step), detail: d.rejectIgnored() });
|
|
502
534
|
else if (reject?.bannerOpenAfter) findings.push({ ...base, severity: "broken", check: "reject_button_failed", step: stepLabel(rejectStep), detail: d.rejectButtonFailed() });
|
|
503
535
|
const afterReject = new Map();
|
|
504
|
-
for (const hit of
|
|
536
|
+
for (const hit of legalHits) {
|
|
505
537
|
if (hit.step < rejectStep) continue;
|
|
506
538
|
if (NON_GOOGLE_AD_PLATFORMS.has(hit.platform) || claimsGranted(hit)) {
|
|
507
|
-
const k =
|
|
539
|
+
const k = legalKey(hit);
|
|
508
540
|
if (!afterReject.has(k)) afterReject.set(k, hit);
|
|
509
541
|
}
|
|
510
542
|
}
|
|
511
543
|
for (const [, hit] of afterReject) {
|
|
512
|
-
findings.push({ ...base, severity: "broken", check: "after_reject", platform: hit.platform, event: hit.name, step: stepLabel(hit.step), detail: d.afterReject(hit.platform, hit.name) });
|
|
544
|
+
findings.push({ ...base, severity: "broken", check: "after_reject", platform: hit.platform, event: hit.name, step: stepLabel(hit.step), detail: fromWidget(hit, d.afterReject(hit.platform, hit.name)) });
|
|
513
545
|
}
|
|
514
546
|
}
|
|
515
547
|
|
|
@@ -529,14 +561,14 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
|
|
|
529
561
|
}
|
|
530
562
|
|
|
531
563
|
const beforeConsent = new Map();
|
|
532
|
-
for (const hit of
|
|
564
|
+
for (const hit of legalHits) {
|
|
533
565
|
// Hits from the reject click on are covered by the after_reject check.
|
|
534
566
|
if (rejectStep !== null && hit.step >= rejectStep) continue;
|
|
535
567
|
const early = granted === null || hit.step < granted;
|
|
536
568
|
if (!early) continue;
|
|
537
569
|
const adPlatform = NON_GOOGLE_AD_PLATFORMS.has(hit.platform);
|
|
538
570
|
if (adPlatform || claimsGranted(hit)) {
|
|
539
|
-
const k =
|
|
571
|
+
const k = legalKey(hit);
|
|
540
572
|
if (!beforeConsent.has(k)) beforeConsent.set(k, hit);
|
|
541
573
|
}
|
|
542
574
|
}
|
|
@@ -548,26 +580,26 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
|
|
|
548
580
|
platform: hit.platform,
|
|
549
581
|
event: hit.name,
|
|
550
582
|
step: stepLabel(hit.step),
|
|
551
|
-
detail: granted === null ? d.beforeConsentNever(hit.platform, hit.name) : d.beforeConsent(hit.platform, hit.name),
|
|
583
|
+
detail: fromWidget(hit, granted === null ? d.beforeConsentNever(hit.platform, hit.name) : d.beforeConsent(hit.platform, hit.name)),
|
|
552
584
|
});
|
|
553
585
|
}
|
|
554
586
|
|
|
555
587
|
// Personal data in hits.
|
|
556
588
|
const piiSeen = new Set();
|
|
557
|
-
for (const hit of
|
|
589
|
+
for (const hit of legalHits) {
|
|
558
590
|
let url = hit.url;
|
|
559
591
|
try {
|
|
560
592
|
url = decodeURIComponent(hit.url);
|
|
561
593
|
} catch { /* malformed % sequence, e.g. "50%": search the raw URL */ }
|
|
562
594
|
const haystack = url + " " + JSON.stringify(hit.params);
|
|
563
|
-
if (piiSeen.has(
|
|
595
|
+
if (piiSeen.has(legalKey(hit))) continue;
|
|
564
596
|
// User data for ad matching is redacted at capture; userData.plain lists the fields that were not hashed.
|
|
565
597
|
if (hit.userData?.plain?.length) {
|
|
566
|
-
piiSeen.add(
|
|
567
|
-
findings.push({ ...base, severity: "risk", check: "pii", platform: hit.platform, event: hit.name, step: stepLabel(hit.step), detail: d.piiUnhashed(hit.platform, hit.name, hit.userData.plain) });
|
|
598
|
+
piiSeen.add(legalKey(hit));
|
|
599
|
+
findings.push({ ...base, severity: "risk", check: "pii", platform: hit.platform, event: hit.name, step: stepLabel(hit.step), detail: fromWidget(hit, d.piiUnhashed(hit.platform, hit.name, hit.userData.plain)) });
|
|
568
600
|
} else if (visitorEmailIn(haystack, hit)) {
|
|
569
|
-
piiSeen.add(
|
|
570
|
-
findings.push({ ...base, severity: "risk", check: "pii", platform: hit.platform, event: hit.name, step: stepLabel(hit.step), detail: d.pii(hit.platform, hit.name) });
|
|
601
|
+
piiSeen.add(legalKey(hit));
|
|
602
|
+
findings.push({ ...base, severity: "risk", check: "pii", platform: hit.platform, event: hit.name, step: stepLabel(hit.step), detail: fromWidget(hit, d.pii(hit.platform, hit.name)) });
|
|
571
603
|
}
|
|
572
604
|
}
|
|
573
605
|
|