Skip to content

Consolidate request timing and GPT diagnostics - #1261

Open
ChristianPavilonis wants to merge 55 commits into
mainfrom
feat/request-timing-console-combined
Open

ChristianPavilonis wants to merge 55 commits into
mainfrom
feat/request-timing-console-combined

Conversation

@ChristianPavilonis

Copy link
Copy Markdown
Collaborator

Draft and dependency blocker

This consolidates #1074 → #1076 → #1121 → #1154 against main, including the actual source tips and their newer main merges. All four originals remain open; no review threads have been resolved.

Not mergeable: the committed public EdgeZero pin does not build Fastly. All six direct pins use timing candidate ab444946, which has the collector but lacks the reusable lifecycle API required by Trusted Server main. Neither the current release nor that timing candidate satisfies both requirements.

The combined code passed local checks using a separate, unpushed EdgeZero integration candidate combining lifecycle base 683202c with timing head ab444946. This draft deliberately contains no absolute-path dependency patches. Publishing that dependency branch, repinning the six dependencies and validating the reachable git-backed graph are still pending. Do not treat the local results below as validation of this draft's public dependency pin.

The required timing API remains unreleased. Release-backed repinning and revalidation are merge gates. Newer EdgeZero main also removes the store API this TS base uses; adopting that store migration is outside this consolidation. No upstream branch was changed, merged or released.

Changes

  • One shared request clock, private-response Server-Timing, sampled post-delivery access telemetry, and auction offsets/UUID joins from the original stack.
  • GPT diagnostics with explicit collector provenance, completed-slot Prebid evidence and clearer delivery labels.
  • Review fixes for disabled sink secrets, CDN cache-header collisions, bounded route sections, truthful launch/collection/commit marks, single pre-snapshot sampling, disabled-path allocations, and route lookup.
  • Preserve launch evidence when backend bookkeeping fails after a request starts. Add planned-provider handler round trips for successful collection, transport failure and metadata loss.
  • Remove the obsolete config example and unused KV decorator interface; correct portable clocks, geo/config docs, logging, bind errors and exhaustive delivery fallback.
  • Keep disabled auctions unattempted with null access UUID, as specified; active consent-denied auctions now carry the skipped-summary join UUID.

Fixes and main integration are in e6d51e36; the four-source combination is ee66fbfb.

Checks

Rust results below used the local combined EdgeZero dependency candidate, not the committed public pin:

  • 2,956 native core tests passed.
  • Fastly, reusable Fastly, Axum, Cloudflare and Spin test suites passed. Fastly core: 2,950 passed, six panic tests intentionally ignored on WASI; 262 Fastly adapter tests passed.
  • CLI, parity, native build-digest tests/lint and all eight target-matched Clippy checks passed.
  • Native/WASM compilation checks and Rust formatting passed; one EdgeZero core and one adapter registry identity verified in the local graph.
  • JS build passed. Full suite initially passed 1,202 tests with one artifact-size failure; all six artifact tests passed after updating the reviewed compact shim bound to 43 KB for the 42.3 KB combined diagnostics shim. Earlier unchanged passing tests remain applicable. JS lint/format passed.
  • Two initial renderer security tests passed. Docs format/lint/build and markdown formatting passed.
  • All 30 Tinybird producer/schema/JSONPath/FORWARD_QUERY fields and both fixture rows align, including UUID and milestone-order checks.

Not verified: reachable combined git pin, release-backed dependency, GPT diagnostics Playwright runtime, deployed cross-adapter clock behavior, live Tinybird schema migration/ingestion, production providers, or fronting delivery-cache pass-through. Coordinate the schema cutover and cache checks before rollout.

Review topic dispositions

31 original open threads represent 30 topics, including one duplicate. These statuses describe implementation, not GitHub thread resolution.

Topic Original feedback Disposition
Snapshot sampling 1074, duplicate thread Implemented locally; original thread remains open
Inactive sink secrets 1074 Implemented locally; original thread remains open
CDN header cache safety 1074 Implemented locally; original thread remains open
Broken local config example 1074 Implemented locally; original thread remains open
Geo resolver rustdoc 1074 Implemented locally; original thread remains open
Body-size contract 1074 Implemented locally; original thread remains open
Unused PlatformKvStore impl 1074 Implemented locally; original thread remains open
Old secret-store wording 1074 Implemented locally; original thread remains open
Disabled-path allocations 1074 Implemented locally; original thread remains open
Route section validation 1074 Implemented locally; original thread remains open
Usable sampling example 1074 Implemented locally; original thread remains open
Router lookup/fallback 1074 Implemented locally; original thread remains open
Axum comment punctuation 1074 Implemented locally; original thread remains open
Success log level 1074 Implemented locally; original thread remains open
Axum bind-error address 1074 Implemented locally; original thread remains open
Publisher portable clock 1074 Implemented locally; original thread remains open
Routes timing-layer docs 1074 Implemented locally; original thread remains open
Consent-denied join key 1076 Implemented locally; original thread remains open
Zero-launch milestones 1076 Implemented locally; original thread remains open
Extension round-trip guards 1076 Implemented locally; original thread remains open
Resolved/uncommitted state 1076 Implemented locally; original thread remains open
Distinct auction-ID names 1076 Implemented locally; original thread remains open
EdgeZero release pin 1121 Blocked: integration/release pin required
Initial-document collector presence 1121 Implemented locally; original thread remains open
CodeQL useless conditional 1154 Implemented locally; original thread remains open
Generated Wrangler file 1074 Already absent/ignored in the combined stack
Poisoning docs 1074 Inherited correction; collector checks passed locally
Repeated lock handling 1074 Already removed by shared collector extraction
Axum upstream serve hook 1074 Upstream follow-up; no local refactor
Health exclusion policy 1121 Preserved; no behavior change requested

jevansnyc and others added 30 commits August 24, 2026 19:02
* Add request phase timing design spec (Server-Timing subtimings + access telemetry)

* Address review round 1: freeze point, template-cache naming, snapshot semantics, KV scope, geo carry, route template, sink confirmation, sampling and query model, config rollback

* Address review round 2: auction-wait placement modes, conservative private-only header emission, non-null sorting key with service identity, coarse publisher route template, telemetry snapshot and outage behavior, tinybird flag decoupling, adapter phase semantics

* Add request phase timing implementation plan

* Address engineer review: KV timing decorator, try_lock sampling, route metadata extension, adapter-derived env, typed template-cache state, adapter-owned emission context, per-mode delivery semantics, Axum outer wrapper
…ite-back in middleware

Three final-review fixes for access telemetry correctness:

- Normalize the HTTP method to an allowlist (GET/HEAD/POST/PUT/DELETE/
  PATCH/OPTIONS, else "other") inside access_event_row, so a client-
  controlled extension-method token can never inflate the LowCardinality
  method column, regardless of which adapter builds the row.
- Guard emit_access_telemetry_after_send against snapshots carrying a
  degraded sample_rate of 0.0 (captured on the app-state-build-failure
  fallback path), which could otherwise be sampled in by freshly reloaded
  settings and corrupt the sum(1.0/sample_rate) volume estimator.
- Mirror the geo lookup write-back from apply_entry_point_finalize_headers
  into FinalizeResponseMiddleware::handle, so a middleware-finalized
  response that resolved geo via fallback carries the resolved
  GeoLookupState for the access-telemetry snapshot instead of showing
  country "unknown".
std::time::Instant::now() panics on wasm32-unknown-unknown, so every
publisher request on the Cloudflare adapter trapped when the timing
collector was constructed, and the two auction-wait sites would trap
once an auction dispatched. web_time re-exports std's Instant on every
other target, so Fastly, Axum, and Spin behavior is unchanged.

The publisher.rs sites are qualified locally because that module's
std Instant import still serves the pre-existing template-cache sites,
which are out of scope here.
The character allowlist alone does not bound identity: [a-z0-9_-] is
exactly the alphabet UUIDs, hex ids, reset tokens, and article slugs
are built from, and truncating to 32 characters still leaves a
globally unique prefix. A first segment now rejects whole to /other/*
when it exceeds 32 characters or carries more than 7 ASCII digits,
alongside the existing charset rejection. Year archives and
hyphenated section names still pass.

Extends the adversarial tests to the publisher-fallback path with
UUID, hex-id, token, and slug shapes, and fixes the stale event_date
reference in the row-builder doc.
- Gate building the access snapshot on tinybird.enabled and
  access_enabled, threaded through SendContext: a disabled deployment
  (the default) no longer pays env reads and String allocations on
  the pre-send path. DeliveryOutcome.snapshot becomes Option and the
  emitter treats None as nothing to send.
- Classify asset-fallback responses as route_class asset with the
  operator-configured route prefix as the template, instead of
  landing in the other/unknown bucket alongside 404s.
- Pin Phase::index() to PHASE_COUNT with a uniqueness-and-bounds
  test so a future variant fails the suite instead of panicking at
  runtime.
- Drop the tautological sampled-out emission test; the 0.0-rate
  behavior is covered by sampled_in_boundary_rates_are_unconditional.
- Clarify that the local dev config env var name genuinely triples
  trusted_server_config (prefix, store, key) rather than reading as
  a find/replace mistake.
Adds section 18 to the request phase timing spec: three first-call-wins
T0 offsets (auction dispatched, resolved, committed) on RequestTimings,
emitted as additive nullable columns on access_logs_raw with auction_id
as the join key to the per-bidder auction dataset. Answers the
overlap-proof questions the two existing clocks cannot: when the
auction started relative to request entry, when the final bid landed,
and when targeting was committed toward GAM.
Implements spec section 18: three first-call-wins marks on
RequestTimings (dispatched at the DispatchAuctionOutcome::Dispatched
arm, resolved after collect at both sites, committed after
write_bids_to_state at both sites), carried through TimingSnapshot into
four additive access_logs_raw columns: auction_dispatched_ms,
auction_resolved_ms, auction_committed_ms, and auction_id as the join
key to the per-bidder auction dataset. Null offsets mean no auction
ran; a failed dispatch records nothing. FORWARD_QUERY fills the new
columns with typed defaults for pre-existing rows.

No header emission, no config surface, no adapter changes: the values
ride the existing snapshot and the tinybird.access_enabled gate.
The Cloudflare integration harness writes
wrangler.integration.generated.toml at test time; it was swept into the
previous commit by accident. Ignore it so local CI=1 runs cannot commit
it again.
The first path segment is only a section name when the path has depth:
under a /%postname%/ permalink structure every article is a
single-segment path, so keeping those segments verbatim put full
article slugs into the 30-day dataset, against spec section 9. Depth
is now required for a named template; single-segment paths, root
landing pages included, bucket to /other/*. Route slicing keeps
route_class and multi-segment templates like /news/*.
The bucket-quantized sampler truncated rates below one in a million to
a zero threshold (silently emitting nothing) and quantized other low
rates downward while rows still carried the configured rate, biasing
the sum(1.0 / sample_rate) volume estimator. Its no-rand premise was
also wrong: rand::thread_rng() is WASI-backed on this target and the
EC generation path already relies on it. The sampler is now a direct
uniform-roll comparison, and the roll gates on the rate stored in the
snapshot itself, so emission probability and the row's sample_rate
column cannot diverge; the divergence guard and its tests are removed.

Also per review: the settings-reload fallback in the post-send path
could never emit (no snapshot exists when settings were absent) and is
removed; the dead_code allow on DeliveryOutcome narrows to the one
collected-but-unemitted field; and the post-send ordering test is
narrowed to the leg it actually proves, that request_elapsed is
stamped when send returns.
On origin failure with a dispatched auction, the origin span guard
stayed alive through the emit_abandoned_auction await, so ts-origin
and origin_ms absorbed Tinybird emission time. The span now closes
when the send resolves, before either branch, with an error-path
regression test.
- Pin HEADER_PHASES against Phase::header_name() in the phase-index
  test, closing the second hand-synced list.
- Add RouteClass::Asset to the snake_case rendering test; rename the
  lowercasing test to say what it does.
- Give the Axum adapter a named, fully configured construction path
  (TrustedServerApp::dev_server_service) so server_timing_enabled is
  never silently discarded; the tuple API is private now.
- Document that Server-Timing is client-visible when enabled, in the
  configuration guide's observability section.
- Replace stale event_date references in the spec, plan, and dashboard
  guidance with the toDate(event_ts) sorting-key expression, and state
  the single-segment rejection rule in spec section 9.
…ming

# Conflicts:
#	crates/trusted-server-adapter-fastly/src/app.rs
#	crates/trusted-server-adapter-fastly/src/main.rs
#	crates/trusted-server-adapter-fastly/src/middleware.rs
#	crates/trusted-server-core/src/publisher.rs
#	crates/trusted-server-core/src/settings.rs
#	docs/guide/configuration.md
#	trusted-server.example.toml
The access emitter carried the configured body limit without enforcing it, allowing oversized rows to bypass the intended transport safeguard.
jevansnyc and others added 23 commits September 8, 2026 11:29
Conflicts: publisher.rs (origin span now wraps the early-dispatch
pending-origin wait as well as the direct send, still dropping before
the abandonment-telemetry branch), main.rs (EdgeZero env parameter
threaded through the AppBuild span block and the finalize signature
gaining both mut ec_state and timings), app.rs (both sides' test-module
additions kept).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rface

Review round 3, the blocking route finding plus the config items:

- Publisher route templates now come from an operator allowlist
  (observability.route_sections, default empty): a first segment that
  matches an entry and has further depth emits the lowercased allowlist
  entry as /{section}/*; everything else emits /other/*. The shape
  heuristics (charset, length, digit bounds) are gone because they
  could not bound identity: depth-2 first segments are usernames under
  /{username}/posts shapes and single-segment paths are documents. The
  emitted value set is now fixed by configuration, so no
  request-derived byte reaches the row.
- Integration-proxy responses carry the registered route pattern
  verbatim (bounded, integration-defined) instead of a classifier
  output; the registry stores the pattern at registration.
- auction_enabled serializes only when false, so a pushed config
  cannot silently re-enable auction telemetry on rollback; with a
  serialization test alongside the observability one.
- The secret-store validator is renamed to validate_secret_store_key_name
  with a key_name parameter: it validates an identifier, never a
  credential, and the old name tainted the key name as a secret value
  in CodeQL, lighting up eleven pre-existing log sites.
- Docs: the tinybird table gains its three missing rows,
  max_body_bytes states the 1024 floor the code enforces, the rollback
  guidance now describes the real compatibility boundary (push a
  compat config first: drop [observability], access_enabled = false,
  and enabled = false for access-only deployments), and the fixture
  uses the example-domain convention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- The access sink streams the Tinybird response (the body is never
  consumed; buffered conversion materializes chunked bodies before the
  limit check) and newline-terminates rows to match the auction sink's
  NDJSON framing, with the recording client now asserting both.
- Poisoned RequestTimings locks recover via into_inner instead of
  silently dropping every subsequent sample: the guarded data are
  plain counters, so one panic cannot blank the header and row for the
  rest of the request. Module and spec docs updated to stop conflating
  poisoning with contention.
- The geo write-back skips 401 responses through a shared helper:
  resolve_geo_for_response short-circuits on 401 before consulting the
  carried state, so the old unconditional write downgraded a carried
  Resolved to Attempted and cost the row its country.
- TimedKvStore forwards exists, so decorating a store with a cheap
  metadata probe (Spin) no longer downgrades it to the get-and-discard
  default body; with a contradiction-stub delegation test.
- Post-send ordering is owned by run_post_send_steps, which both
  production sites route through, and the instrumented sequence test
  drives the real seam: elapsed stamped by send, then pull-sync, then
  telemetry.
- Axum: dev_server_service remains the standard path; new tests pin
  flag-off suppression and the extension round trip (a phase recorded
  in the handler must surface as ts-filter in the header); the
  outer-wrapper rationale is reworded to the terminal-freeze-point
  argument; the configuration guide notes the flag is read once at
  startup.
- The no-Cache-Control fail-closed case is pinned by a test, and t0's
  boundary (constructed after the adapter prologue) is documented.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…indings

The base branch moved the auction dispatch block behind a request head
snapshot, so the conflict in publisher.rs resolves to the base's relocated
block with the timeline mark re-applied there. request_timing.rs auto-merged
without a conflict but left the three new marks as the only lock-taking
methods that did not recover from a poisoned mutex; they now match the rest
of the collector.

Instrument all three auction sources. Only the publisher navigation path
marked before, so POST /auction and /_ts/page-bids emitted access rows that
claimed no auction ran while auction_events_raw held a full record for the
same request, and the documented join returned nothing for the route class
named after auctions. Both handlers now take the collector off the request
extensions and bracket run_auction.

Split the join key from the dispatch mark. set_auction_id is called where the
AuctionObservationContext is built, which happens on every auction-eligible
request, so skipped and dispatch-failed auctions stay joinable to the rows
they emit, and a dropped dispatch sample no longer takes the key with it.
A null offset now means "this milestone was not reached" rather than "no
auction ran", with auction_id separating the two; the abandoned-auction paths
make that distinction load-bearing.

Type auction_id as Nullable(UUID) to match auction_events_raw.auction_id.
As a String with a 'none' sentinel the join was a ClickHouse type error, and
casting the sentinel through toUUID throws.

Untrack the generated Cloudflare wrangler config. The .gitignore entry alone
was inert because the file is tracked on the base branch, so the merge
re-added it.

Also: extend the Tinybird fixture with the four columns (reusing the auction
fixture's UUID so the pair demonstrates the join) and a no-auction row; make
the first-call-wins test actually detect a restamp by sleeping between calls,
verified by injecting a last-call-wins regression; assert the marks at both
collect sites; correct the spec's interpretation ladder, which put
time_elapsed_ms last even though headers commit before the stream seam.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…wn entry

The status said the implementation was a follow-up PR; it is in this one.
The generated Cloudflare wrangler config was ignored under a comment about
defunct pre-rename crate directories, which it has nothing to do with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Resolve eleven conflict hunks across six files, plus two semantic
conflicts the merge did not mark.

Import and structural hunks are unions of both sides: the Fastly adapter
keeps TimedKvStore, RouteClass/RouteMetadata and response_builder
alongside main's StoreName, RuntimeStoreConfig and auction-plan symbols;
the integration registry keeps main's enabled_integration_ids loop with
the branch's route-path element added to the router value; the config
template keeps both the new [observability] leaf and main's auction
provider examples. main.rs keeps the AppBuild timing span wrapping
main's runtime-store-based config open and app build.

Port access telemetry onto main's resolved-secret model (#1036).
tinybird.access_token_secret becomes Option<Redacted<String>>, resolved
through config.rs as an optional secret reference like the auction
token, so the Fastly access emitter reads it from settings instead of
opening a Secret Store per emission; load_access_token and
validate_secret_store_key_name are gone. Secret-reference validation now
gates the auction token on auction_enabled so an access-only
configuration is expressible. Drop main's guard rejecting
access_enabled, which this branch wires the emitter for.

Forward EcKvStore::key_exists through TimedKvStore, added to the trait
by main's Edge Cookie withdrawal hardening, and restore
validate_tinybird_secret for the resolved-value checks.
Co-authored-by: prk-Jr <49094961+prk-Jr@users.noreply.github.com>
Keep the diagnostics evidence wording and request controls while adopting
feature/ts-console-improvements' shared server-request timing origins.
Align overlay assertions and the label dictionary with those origins.
Resolve five conflicting files plus the semantic conflicts the textual
merge could not mark.

Adopt main's removal of the legacy consent KV path (#903): the Fastly
adapter no longer opens a consent store per request, so the branch's
timed wrapper around it, its route call sites, and the test asserting
consent-store reads land in ts-kv are dropped. TimedKvStore keeps both
trait impls; its module doc and the design spec no longer describe a
consent-store read.

Forward the EcKvStore::list_keys_with_prefix method main added for EID
write-conflict reduction (#1157) through TimedKvStore with the same
ts-kv span. Give main's new admin cache-purge named route (#1150) a
RouteClass::Other classification so access telemetry carries a template
for it.

Union the remaining hunks: the Axum adapter keeps dev_server_service and
routes_with_server_timing_flag beside main's
routes_with_settings_and_services; the Fastly route tests keep the
RouteMetadata assertions beside main's EID sync-source dispatch tests,
with the short-circuit test renamed to main's name; the configuration
guide's section table takes main's layout with the observability row and
the access-telemetry wording restored.

Update tests main added that build branch-extended structs:
AuctionCollectDeps initializers gain timings and placement, the Settings
debug-redaction fixture gains auction_enabled, and the EC finalize
freeze-point test marks its context as a navigation now that returning-
user EID persistence is gated on the request source.

@aram356 aram356 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Summary

This consolidates the request-timing, access-telemetry and GPT-diagnostics stack, and the code is in good shape against the EdgeZero combination it was developed on: with 683202c + ab444946 patched in locally, clippy-fastly, test-fastly (262 adapter + 2,950 core), test-fastly-reuse, all five non-Fastly clippy gates, the Axum/Cloudflare/Spin suites, 1,199 vitest tests and the docs build all pass. What blocks it: the committed EdgeZero pin cannot build Fastly, the Tinybird forward query targets a schema main never shipped, one dispatch-accounting bug fabricates auction offsets, a join-key contract that differs by route, mutation-proven test gaps on the new timeline wiring, and operator docs that still say access telemetry is rejected.

12 of the inline comments carry a one-click GitHub suggestion, each verified in isolation (rustfmt, patched clippy with -D warnings, the affected test suites, prettier/eslint/vitest/build-all.mjs for JS, prettier for docs). The rest describe the fix in prose because it spans files or lines outside the diff.

Blocking

🔧 wrench

  • EdgeZero pin cannot build the Fastly adapter: see inline at Cargo.toml:59
  • FORWARD_QUERY selects columns main's live access_logs_raw lacks: see inline at tinybird/datasources/access_logs_raw.datasource:41
  • A provider that sends nothing is counted as launched: see inline at crates/trusted-server-core/src/auction/orchestrator.rs:1794
  • Disabled-auction join key differs by route: see inline at crates/trusted-server-core/src/publisher.rs:5106
  • Navigation timeline and stream placements untested (mutation-proven): see inline at crates/trusted-server-core/src/publisher.rs:5149
  • JS auction-classification outputs survive mutation: see inline at crates/trusted-server-js/lib/src/integrations/gpt/index.ts:84
  • Fastly freeze-point ordering and geo write-back untested: see inline at crates/trusted-server-adapter-fastly/src/main.rs:1003
  • CI is red and operator docs still say access telemetry is rejected: see below

Non-blocking

🤔 thinking / ♻️ refactor / 📝 note / ⛏ nitpick

  • Route identity lost on fallback short-circuits: see inline at crates/trusted-server-adapter-fastly/src/app.rs:837
  • private="field" counts as conclusively private: see inline at crates/trusted-server-core/src/request_timing.rs:345
  • Post-send access emission blocks the callback: see inline at crates/trusted-server-adapter-fastly/src/main.rs:826
  • ts-appbuild on reused sandboxes: see inline at crates/trusted-server-adapter-fastly/src/main.rs:382
  • ts-origin on the early-send path: see inline at crates/trusted-server-core/src/publisher.rs:5360
  • R - D is not auction duration on streamed pages: see inline at docs/superpowers/specs/2026-08-24-request-phase-timing-design.md:729
  • Diagnostics code in the always-served Prebid shim: see inline at crates/trusted-server-js/lib/test/prebid-artifact-integration.test.mjs:328
  • First-impression fallback drops auction facts: see inline at crates/trusted-server-js/lib/src/integrations/gpt/index.ts:1408
  • ts_version is the Fastly service version: see inline at crates/trusted-server-core/src/access_telemetry.rs:203
  • Unused delivery-result groundwork: see inline at crates/trusted-server-adapter-fastly/src/main.rs:860
  • Spec and plans describe a different design: see inline at docs/superpowers/specs/2026-08-24-request-phase-timing-design.md:3
  • [tinybird] env overrides are no-ops against the example: see inline at docs/guide/configuration.md:3076
  • Cloudflare/Spin ignore both flags: see inline at docs/guide/configuration.md:2988
  • Stale run_auction comment: see inline at crates/trusted-server-core/src/publisher.rs:7342
  • TimingService calls an un-readied clone: see inline at crates/trusted-server-adapter-axum/src/timing.rs:79
  • Disabled path still parses cache headers: see inline at crates/trusted-server-core/src/request_timing.rs:342
  • Test unwrap() and missing assertions: see inline at crates/trusted-server-core/src/auction/endpoints.rs:1411
  • expect message form: see inline at crates/trusted-server-core/src/publisher.rs:6196
  • -- punctuation thread still open in code: see inline at crates/trusted-server-adapter-axum/src/timing.rs:13
  • .gitignore names the wrong template: see inline at .gitignore:67
  • Badge selection always opens history: see inline at crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts:523
  • Dictionary link without noreferrer: see inline at crates/trusted-server-js/lib/src/integrations/gpt_diagnostics/overlay.ts:724

Cross-cutting / body-level findings

  • 🔧 CI is red, all from the EdgeZero pin. cargo fmt (required; it fails in its clippy-fastly step, so every later clippy step for axum, cloudflare native/wasm, spin native/wasm, cli and codegen was skipped), cargo test (required), cargo test (axum native) (its Axum build and tests pass; only "Verify Fastly WASM release build" fails) and prepare integration artifacts all fail with cannot find 'lifecycle' in 'edgezero_adapter_fastly', so integration tests, integration tests (Fastly EC lifecycle) and browser integration tests were skipped. The Playwright gpt-diagnostics.spec.ts has therefore never run at this head.
  • 🔧 Operator docs still describe access telemetry as rejected/reserved, and nothing describes the new 30-day dataset. docs/guide/telemetry.md:44-47 says access_enabled = true "is rejected because no access-log emitter is wired" and that access_token_secret is deprecated and normalized away; tinybird/README.md:13-14 says access_logs_raw is reserved and not emitted, and :20-23 only covers the auction APPEND token; docs/guide/configuration.md:1307-1308 ("access-log emission is not wired") sits right above the rewritten table, and :77-79 / :111 still call the token deprecated. This PR validates and requires the token and emits rows from Fastly. telemetry.md has a privacy boundary section for auction telemetry only; the access row needs the same: its columns (route class and bounded template, country, pop, service id, publisher domain, env, normalized method, status, phase timings, response bytes), that auction_id joins it to auction_events_raw (device class, browser family), uniform per-response sampling with sample_rate stored per row, Fastly-only emission, and the post-send ingest wait. Also add the ts_access_ingest token step to the README.
  • 📝 PR description and history. The four source PRs were closed on 2026-10-09 ("Superseded by #1261"), so "All four originals remain open" is stale, and their 31 unresolved threads (and CHANGES_REQUESTED reviews) now sit on closed PRs with the table as the only tracker; a reply on each linking its disposition would close the loop. "Keep disabled auctions unattempted with null access UUID" holds only for /auction (see the inline on publisher.rs:5106). The review fixes live inside the merge commit e6d51e36c: about 1,190 inserted and 626 deleted lines beyond a clean auto-merge of ee66fbfb9 and da31a215e (the EdgeZero repin, the /auction dispatch/collect split, +332 lines in publisher.rs, config_payload.rs, settings.rs), visible only via git show --cc. A clean merge commit plus a separate fix commit would make them reviewable. The October RC (#1228) still carries the pre-fix heads of the four source PRs.
  • 📝 No CHANGELOG entry, though this changes operator-facing behavior with rollback hazards: [observability] is rejected by older binaries (Settings is deny_unknown_fields); older binaries ignore auction_enabled = false; access_logs_raw is replaced (deploy ordering); the Axum dev server runs its own serve loop; and the diagnostics export now includes winning bidder names and bucketed hb_pb.
  • 📝 auction_events_raw contract changes on failure paths. /auction and page-bids moved from run_auction to dispatch plus collect: on DispatchFailed, ExecutionFailed now carries the launch-failure provider responses (one row becomes N+1; the endpoints.rs:1311-1319 test change confirms it), /auction's summary elapsed_ms now comes from the orchestrator instead of observation.elapsed_ms(), and a fatal admission error loses the "Planned auction admission failed" context. Probably improvements, but Tinybird consumers should hear about it.
  • 📌 Follow-ups the dispositions imply but nobody filed: the EdgeZero Axum service/layer hook (listed as "Upstream follow-up", but no issue exists on stackpop/edgezero); CI validation that tinybird/ SCHEMA, FORWARD_QUERY, fixtures and producer agree (requested on #1076; the 30-field check is manual); the duplicated Sanitize/Finalize/Auth middlewares in the Cloudflare and Spin adapters (requested on #1121); and transport-completion capture for the auction (see the R - D inline).
  • ♻️/⛏ Smaller items (no inline comment, to stay under the comment cap):
    • SendContext is built three times with identical fields (main.rs:562, :603, :642); build it once after the finalize block. require_identity_graph_with_timing (main.rs:1307-1321) is identity_graph_with_timing(...).ok_or_else(...).
    • endpoints.rs:376-454 and publisher.rs:7401-7450 re-implement run_planned_auction's outcome mapping, keyed on settings.auction.providers instead of the planned set and hand-building OrchestrationResult instead of no_bid(). Equivalent today (plan compilation is 1:1), but three copies will drift; consider returning (result, launched) from the orchestrator.
    • The diagnostics seam branch s(b,a,d) (publisher.rs:6259-6276) is unreachable: the seam is built only for template-authorized responses, which require !requires_private_no_store, while active diagnostics force private.
    • collect_non_html_auction stamps committed with no injection point (publisher.rs:4374-4380); page-bids synthesizes auctionWaitMs = R - D with pre_header for the browser while the access row for the same request has auction_wait_ms null.
    • RouteMetadata.route_template: String allocates on every routed response even with access telemetry off; Cow<'static, str> (borrowed for named routes, /, /other/*) avoids it.
    • The AdBidsState doc block (publisher.rs:3281-3294) now documents BrowserAuctionDiagnostics; move the new items above it.
    • /verify-signature and /_ts/admin/keys/* are classified RouteClass::Ec; they are request-signing routes.
    • handle_page_bids now runs gpt_diagnostics::prepare_request (which can fail on config) before the CSRF gate whose comment says it runs "before any other work".
    • route_sections accepts entries that can never match a percent-encoded path (spaces, non-ASCII, ?, #, *; * would emit /*/*); restrict to unreserved ASCII. Docs say "case-insensitively" (configuration.md:2963); it is ASCII-only.
    • max_body_bytes >= 1024 does not guarantee one access row fits (bounded inputs reach 1,074 bytes); raise the floor when access_enabled or soften the doc claim at settings.rs:1879-1882.
    • every_phase_index_is_unique_and_in_bounds's comment claims a compile-time guarantee the hand-written array doesn't give; #[repr(usize)] plus PHASE_COUNT = Phase::Stream as usize + 1 would. rendered_names_never_include_vendor_terms cannot fail (the names are static literals).
    • Fixture row 2's template_cache_state: "bypass" is not a producible value (asset rows emit unknown), and row 1 shows time_elapsed_ms > auction_resolved_ms on a streamed row, against the documented ordering.
    • Axum: main.rs:44 has an unresolved intra-doc link to RouterService (cargo doc warns); "Listening on" is logged before the bind succeeds; tower 0.4 is now a normal dependency next to tower 0.5 from axum 0.8 (bumping the workspace to tower = "0.5" passes test-axum and drops 0.4 from the lock).
    • Cloudflare excludes /health from timing but has no /health route, so a real publisher path loses its collector; Fastly short-circuits only GET /health while the others exclude every method.
    • Each adapter starts T0 at a different point (Fastly before config-store open, Axum at TimingService::call, Cloudflare/Spin after a per-request app build), so GPT-diagnostics offsets are not comparable across adapters; worth a table in spec §8a and a sentence in gpt-diagnostics.md. Spec §8a also says the freeze point is inside AxumDevServer (it isn't) and that Axum stream_ms measures write-out (Axum never records Stream); axum/src/timing.rs:27-30 still says Fastly builds state per request.
    • Cloudflare/Spin timing tests: deleting RequestTimingMiddleware from either adapter's router leaves test-cloudflare/test-spin green, and forcing server_timing_enabled = false in dev_server_service leaves the Axum suite green. The new Cloudflare and Spin middleware tests are byte-identical, exercise EdgeZero's middleware on a private router rather than TS's build_router, and rustfmt gave up on their long closures.
    • Cargo.lock rewrites unrelated edges (windows-sys 0.61.2 -> 0.48.0, hashbrown 0.17.1 -> 0.16.1); restore them when repinning.
    • JS: currency has no producer anywhere (delete until one exists); "Compatibility field" describes auctionWinner, which is new here; publisher-initiated Prebid auctions are never classified client_side (only the synthetic refresh records one), so the docs read broader than the code; gpt-diagnostics.md:381-383 says the badge adds Competing paths for competing while badges.ts:118 suppresses it; store.test.ts's "does not infer an SPA auction from navigation generation alone" overstates what is tested; the browser spec captures the closed shadow root twice and not.toContain("1×1") passes vacuously on empty text; a lazy-loaded slot rendering more than 30 s after its auction drops bidWon silently (plausible, unprobed).
    • Docs: the two [tinybird] tables (configuration.md:1312-1323 and :3040-3048) disagree on defaults/types and access_dataset is marked required though it defaults; "(host, store, credentials)" at :3042 refers to the ignored secret_store; the key-sections row for [observability] omits route_sections; the dictionary lists a GPT-reported creative label the UI never renders; the rollback warning says an older binary reads an access-only config as auction telemetry on, but without an auction token it fails startup instead.
    • Style: rand sits out of alphabetical order in the Fastly Cargo.toml, and use rand::Rng as _; sits between std imports.

CI Status

  • cargo fmt: FAIL (required)
  • cargo test: FAIL (required)
  • cargo test (axum native): FAIL
  • prepare integration artifacts: FAIL
  • format-typescript: PASS (required)
  • format-docs: PASS (required)
  • vitest: PASS
  • cargo check (cloudflare native + wasm32-unknown-unknown): PASS
  • cargo check/build/test (spin native + wasm32-wasip1): PASS
  • cargo test (cross-adapter parity): PASS
  • cargo test (ts CLI, native) (macos-latest): PASS
  • cargo test (ts CLI, native) (ubuntu-latest): PASS
  • CLAUDE.md symlink guard: PASS
  • CodeQL: PASS
  • Analyze (actions): PASS
  • Analyze (javascript-typescript): PASS
  • Analyze (python): PASS
  • Analyze (rust): PASS
  • browser integration tests: SKIPPED
  • integration tests: SKIPPED
  • integration tests (Fastly EC lifecycle): SKIPPED

Comment thread Cargo.toml
Comment on lines +59 to +66
# Temporary integration pin for EdgeZero PR #389. Before merging TS into main,
# replace all six pins with the approved EdgeZero release tag and revalidate.
edgezero-adapter-axum = { git = "https://github.com/stackpop/edgezero", rev = "ab444946fcacb1d44c020c6079abb6ba29231402", default-features = false }
edgezero-adapter-cloudflare = { git = "https://github.com/stackpop/edgezero", rev = "ab444946fcacb1d44c020c6079abb6ba29231402", default-features = false }
edgezero-adapter-fastly = { git = "https://github.com/stackpop/edgezero", rev = "ab444946fcacb1d44c020c6079abb6ba29231402", default-features = false }
edgezero-adapter-spin = { git = "https://github.com/stackpop/edgezero", rev = "ab444946fcacb1d44c020c6079abb6ba29231402", default-features = false }
edgezero-cli = { git = "https://github.com/stackpop/edgezero", rev = "ab444946fcacb1d44c020c6079abb6ba29231402" }
edgezero-core = { git = "https://github.com/stackpop/edgezero", rev = "ab444946fcacb1d44c020c6079abb6ba29231402", default-features = false }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔧 wrench: The pinned EdgeZero rev cannot build the Fastly adapter.

ab444946 (feat/shared-request-timing, EdgeZero #389, still open with changes requested and not mergeable) has the request-timing collector but not edgezero_adapter_fastly::lifecycle, which main needs since #1179: it is 2 commits ahead of and 1 behind main's pin 683202c. Every Fastly build fails with cannot find 'lifecycle' in 'edgezero_adapter_fastly' at sandbox.rs:61, main.rs:118 and main.rs:139, which is the root of all four red checks (see the CI section in the review body).

For review I rebuilt the combination locally (683202c merged with ab444946 merges cleanly) and patched it in. clippy-fastly, test-fastly (262 adapter + 2,950 core), test-fastly-reuse, and the Axum/Cloudflare/Spin clippy and test aliases all pass against it, so the code is sound on that base. Nothing reachable from this PR builds it, though.

Merge gate, as the comment above already says: repin all six crates to an EdgeZero release, or at least a pushed commit that contains both #379 and #389, and get CI green including the integration and browser jobs. When regenerating the lock, use a scoped update and restore the unrelated edges this one rewrote (windows-sys 0.61.2 -> 0.48.0 in two places, hashbrown 0.17.1 -> 0.16.1). Until then, consider converting the PR to draft so it can't be merged by accident.

Comment on lines +40 to +41
FORWARD_QUERY >
SELECT event_ts, method, status, time_elapsed_ms, sample_rate, service_id, publisher_domain, env, route_class, route_template, body_mode, auction_wait_placement, appbuild_ms, filter_ms, geo_ms, kv_ms, origin_ms, template_cache_ms, auction_wait_ms, stream_ms, request_elapsed_ms, resp_bytes, template_cache_state, country, ts_version, pop, CAST(NULL AS Nullable(UInt32)) AS auction_dispatched_ms, CAST(NULL AS Nullable(UInt32)) AS auction_resolved_ms, CAST(NULL AS Nullable(UInt32)) AS auction_committed_ms, CAST(NULL AS Nullable(UUID)) AS auction_id

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔧 wrench: This FORWARD_QUERY selects columns that main's live access_logs_raw does not have.

Main's schema (git show da31a215e:tinybird/datasources/access_logs_raw.datasource) is event_ts, method, path, status, time_elapsed_ms, cache_state, country, sample_rate, event_date. The query selects service_id, publisher_domain, env, route_class, route_template, body_mode, auction_wait_placement, the phase columns, resp_bytes, template_cache_state, ts_version and pop, none of which exist there. They come from the intermediate schema on the stacked branch (72d5755, 7cf7d86), which is presumably the workspace the spec's tb --cloud deploy --check ran against.

Tinybird executes the forward query against the live datasource during deploy, and main's tinybird/README.md tells operators to tb deploy the whole project, so any workspace deployed from main has the 9-column version live. There the deploy fails and the datasource never migrates, and the README's schema-before-code ordering blocks the rollout. (I couldn't run tb here; tb deploy --check against a workspace deployed from main would confirm.)

Options: project from main's columns, e.g. CAST(time_elapsed_ms AS Nullable(UInt32)) AS time_elapsed_ms, ifNull(cache_state, 'unknown') AS template_cache_state, 'unknown' / 'other' / 'none' literals for the new dimensions and CAST(NULL AS Nullable(...)) for the new metrics; or ship a versioned datasource, as spec §9 and rollout step 4 already describe for the deployed-reserved case. Either way, document removing the forward query after promotion: left in place, the CAST(NULL ...) AS auction_* projections would null those columns on a later backfill.

❓ Which workspace was --check run against, and has main's reserved datasource been deployed anywhere?

Comment on lines 1794 to 1798
Ok(ProviderRequestOutcome::Immediate(response)) => {
immediate_response_count += 1;
provider_launch_count += 1;
completed_responses.push(response);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔧 wrench: A provider that sends nothing is counted as launched.

In the planned path, the only Immediate a GenericOpenRtbProvider returns is the OpenRtbBuildOutcome::NoImpressions skip (provider.rs:300-304, tagged routing.skipped_no_usable_demand), so no request leaves the edge. Counting it sets has_provider_launch(), and all three call sites then stamp dispatched, resolved and committed (navigation publisher.rs:5149, page-bids publisher.rs:7407, /auction endpoints.rs:383); page-bids and active diagnostics documents also hand auctionDispatchedMs to the browser. That contradicts mark_auction_dispatched's own contract (request_timing.rs:206-208: routing-only skipped outcomes do not stamp it). It is reachable with a PBS or APS provider whose augment_request drops every impression.

Probe (orchestrator test: one PBS provider, one slot with {"trustedServer":{"storedRequest":false,"bidderParams":{"alpha":{}}}}, then dispatch and collect): requests_sent=0 has_provider_launch=true.

Suggested change
Ok(ProviderRequestOutcome::Immediate(response)) => {
immediate_response_count += 1;
provider_launch_count += 1;
completed_responses.push(response);
}
Ok(ProviderRequestOutcome::Immediate(response)) => {
// Planned providers return `Immediate` only for the routing-only
// `skipped_no_usable_demand` skip, so no request left the edge.
immediate_response_count += 1;
completed_responses.push(response);
}

The "produced an immediate outcome or started a request" wording at orchestrator.rs:87 and :92, request_timing.rs:206 and the spec's "or returned an immediate result" should follow, and the probe is worth keeping as a regression test (both outside this hunk). Verified alone: rustfmt, clippy-fastly, the 2,956 core tests, the 262 Fastly adapter tests and the Axum suite pass.

Comment on lines +5101 to +5106
// T0-anchored timeline (spec section 18): stamp the join key here
// rather than on dispatch, because every branch below emits an
// `auction_events_raw` row under this id — completed, dispatch
// failed, and skipped alike. Stamping it on dispatch would leave the
// failed and skipped rows unjoinable.
timings.set_auction_id(observation.auction_id);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔧 wrench: A disabled auction produces a different access row depending on the route.

Navigation (here) and page-bids (:7346) stamp auction_id before branching, so auction.enabled = false with matching slots yields a non-null auction_id joined to an auction_disabled skip row. /auction's disabled branch (endpoints.rs:191-222, unchanged) builds an observation and emits the same Skipped { reason: "auction_disabled" } row under observation.auction_id, but never calls timings.set_auction_id, so its access row has auction_id = null and the skip row can't be joined. The spec table (§18, "null: no auction was attempted (assets, EC endpoints, disabled)") and the PR description ("Keep disabled auctions unattempted with null access UUID") match only /auction.

Pick one contract and apply it on all three routes. The smaller change, and the one that matches the comment above ("every branch below emits an auction_events_raw row under this id"), is to add timings.set_auction_id(observation.auction_id); after endpoints.rs:206 and reword the spec row to "no auction observation was built (assets, EC endpoints, no matched slots)". If null-for-disabled is the intent, skip the stamp here and at :7346 when !orchestrator.is_enabled() and accept unjoinable skip rows. Either way, add a test: nothing covers the disabled branch's join key today.

{
DispatchAuctionOutcome::Dispatched(dispatched) => {
// The outcome can also carry skipped-provider diagnostics;
// only a real provider result/request establishes dispatch.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔧 wrench: The navigation timeline and the stream placements are not covered by any test.

With these mutations applied together, all 2,956 core tests still pass:

  • if provider_launched here → if true (dispatch stamped for routing-only dispatches)
  • the collect_stream_auction guards at :4433 and :4451 → provider_launched || true
  • delete timings.set_auction_id(observation.auction_id) at :5106 (the navigation join key is never asserted)
  • InStream → PreHeader at :2542 (template-hit streaming) and :2660 (non-HTML streaming)
  • delete && timings.snapshot().auction_dispatched_ms.is_some() at :7459
  • delete drop(origin_span); at :5390

Why they survive: no test drives handle_publisher_request with an attached collector; the collect-site tests use immediate_no_bid_for_test, which hard-codes provider_launched = true; active_page_bids_omits_timings_when_no_provider_dispatches (:24629) never attaches a collector, so it passes on the has_request_timings gate alone; and origin_span_is_recorded_when_the_origin_send_fails (:9626) passes slots: &[], so the abandonment await it says it guards never runs, and origin_ms.is_some() would hold even if the span lived to the end of the function.

Also in core: the TimedKvStore tests (platform/timed_kv.rs:86-109, ec/kv.rs:1721-1740) only assert kv_ms.is_some(), which a single span satisfies, and key_exists, list_keys_with_prefix, count_keys_with_prefix and delete are never exercised.

Suggested tests: navigation with an attached collector across Dispatched / Skipped / DispatchFailed, asserting snapshot.auction_id equals the summary row's id; a collect from empty_for_test asserting resolved and committed stay None; placement assertions for both streaming paths; the page-bids test with a collector attached, asserting auctionDiagnostics is absent; an auction behind a delaying telemetry sink, asserting origin_ms stays below the delay; and an inner KV store that sleeps per call, asserting kv_ms grows per method.

// fall back to a plain assignment, where no SPA hook exists to race with.
if let Some(auction_diagnostics) = auction_diagnostics {
let diagnostics = serde_json::to_string(auction_diagnostics)
.expect("BrowserAuctionDiagnostics should serialize");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⛏ nitpick: expect messages use the "should ..." form (AGENTS.md). Same at :6262.

Suggested change
.expect("BrowserAuctionDiagnostics should serialize");
.expect("should serialize browser auction diagnostics");

Comment on lines +13 to +15
//! freeze point: by the time a response reaches this layer -- after
//! `RouterService::oneshot` inside `EdgeZeroAxumService::call` has
//! converted any dispatch error into a plain response -- every response is

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⛏ nitpick: The -- punctuation from the #1074 thread (r4186272563) is still here and at :340 ("NotFound branch -- exactly the"); the disposition table lists it as implemented.

Suggested change
//! freeze point: by the time a response reaches this layer -- after
//! `RouterService::oneshot` inside `EdgeZeroAxumService::call` has
//! converted any dispatch error into a plain response -- every response is
//! freeze point: by the time a response reaches this layer (after
//! `RouterService::oneshot` inside `EdgeZeroAxumService::call` has
//! converted any dispatch error into a plain response), every response is

Comment thread .gitignore
Comment on lines +67 to +68
# Wrangler config generated by the Cloudflare integration-test harness from
# wrangler.toml at run time; regenerated on every run.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⛏ nitpick: The harness renders this file from wrangler.ci.toml (crates/trusted-server-integration-tests/tests/environments/cloudflare.rs:26-27), not wrangler.toml.

Suggested change
# Wrangler config generated by the Cloudflare integration-test harness from
# wrangler.toml at run time; regenerated on every run.
# Wrangler config generated by the Cloudflare integration-test harness from
# wrangler.ci.toml at run time; regenerated on every run.

selectRequest(runtimeSlotNumber: number, requestNumber: number): void {
if (this.destroyed) return;
this.selectedRequest = { runtimeSlotNumber, requestNumber };
this.historySlotToOpenOnce = runtimeSlotNumber;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⛏ nitpick: Badges always point at the latest request, so this opens "Request history" on every badge click, while gpt-diagnostics.md:451 says only selecting an earlier request opens it. Probe: after two requests, selectRequest(1, 2) (the latest) opens the history.

Suggested change
this.historySlotToOpenOnce = runtimeSlotNumber;
const requests = this.store
.snapshot()
.slots.find((slot) => slot.runtimeSlotNumber === runtimeSlotNumber)?.requests;
const isLatestRequest = requests?.[requests.length - 1]?.requestNumber === requestNumber;
this.historySlotToOpenOnce = isLatestRequest ? undefined : runtimeSlotNumber;

Prettier, eslint, the gpt_diagnostics suites and build-all.mjs pass with this change; no existing test covers the latest-request case, so one is worth adding.

dictionaryLink.href =
'https://iabtechlab.github.io/trusted-server/guide/integrations/gpt-diagnostics-dictionary';
dictionaryLink.target = '_blank';
dictionaryLink.rel = 'noopener';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⛏ nitpick: Without noreferrer, a publisher page with Referrer-Policy: unsafe-url sends its full URL, query included, to github.io when someone opens the dictionary.

Suggested change
dictionaryLink.rel = 'noopener';
dictionaryLink.rel = 'noopener noreferrer';

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants