Chris Tam · Last updated 19 October 2026
Astro sends finished HTML and adds JavaScript only where you ask for it, so a script in <head> can change a page before anyone sees it. Web Experiment lets you A/B test headlines, buttons, and layouts from a visual editor, without a code change or a deploy.
In this tutorial, you'll load the script in your layout, keep variants applied across navigations, and launch your first experiment. It builds on the Astro product analytics page.
What you'll build
An Astro site that loads API_KEY.experiment.js synchronously at the top of <head> in src/layouts/Layout.astro, applies a variant to the home page headline on full page loads and on <ClientRouter /> navigations, and sends [Experiment] Impression with [Experiment] Flag Key and [Experiment] Variant through the analytics SDK you already run. Project Created from the product analytics page becomes the experiment's goal. By the end you'll see an impression in your own event stream in Amplitude.
Prerequisites
- An Astro 6 or later site with Amplitude already initialized in
src/scripts/amplitude.ts,<Amplitude />rendered insrc/layouts/Layout.astro, andreset()called only from your sign-out form. If you don't have that yet, start with How to add product analytics to an Astro site. - The
/projects/newpage from the product analytics page. The home page's Create a project link and the Verify steps go there. - A home page at
src/pages/index.astrothat uses your shared layout.npm create astro@latestcreates one. - Your Amplitude project API key, the
PUBLIC_AMPLITUDE_API_KEYyou already have in.env. In Amplitude, it's under Settings > Projects > your project > General. Web Experiment uses the same project key as your analytics. - Your site running at a URL you can open in Chrome, since the Visual Editor loads your real page.
Either output mode works. Prerendered pages and pages rendered on demand load the script the same way.
Astro 6 needs Node.js 22.12 or later, and Astro 7.4 and later need 22.22 or later.
No Amplitude account yet? A free account is enough for this tutorial; the docs list A/B testing and the Visual Editor on all plans, and this page doesn't use custom code actions, which need Growth or Enterprise. Sign up for free to get your project API key.
1. Load the experiment script first in your layout
Web Experiment ships as a script tag, not an npm package. The Browser Unified SDK docs list @amplitude/unified from npm as including Feature Experiment, the code-based flags product, and not Web Experiment. Installing more packages won't give you the Visual Editor.
Two script tags contain Web Experiment, and on an Astro site with the npm SDK only one is right. Implement Web Experiment documents the first, and the Unified SDK docs document the second:
https://cdn.amplitude.com/script/API_KEY.experiment.jsloads Web Experiment only. Use it when you already initialize analytics from npm, assrc/scripts/amplitude.tsdoes.https://cdn.amplitude.com/script/API_KEY.js, the Unified Script, loads analytics, session replay, and Web Experiment together. Use it only on a site with no Amplitude SDK installed.
Use .experiment.js. The docs say it tracks impressions through the Browser SDK already on your site. Adding the Unified Script next to @amplitude/unified puts a second analytics SDK on the page.
The script URL takes your project API key. The key is public and its scope is limited to the minimum needed to ingest data, so it's expected in your HTML; keep it in .env anyway. Astro inlines import.meta.env.PUBLIC_AMPLITUDE_API_KEY at build time in server code too, so the layout's frontmatter can build the URL.
If your site needs cookie consent, configure consent handling before you add the script (Cookies and consent management).
Add two constants to your layout's frontmatter and the tag at the top of <head>, right after <meta charset="utf-8" /> and above everything else, including <ClientRouter /> if you use it. If your layout has no frontmatter yet (the create astro starter's doesn't), add the --- fences at the top of the file.
---
// src/layouts/Layout.astro (frontmatter: add these constants; keep the rest)
// Keep lines other Astro pages added here: the Amplitude import and <Amplitude />
// as the first thing in <body> (/build/astro-product-analytics); the AmpMask
// import, MASKED_PATHS, the masked <slot /> wrapper, and the .masked-section
// style (/build/astro-session-replay); the FeedbackButton import and
// <header><FeedbackButton /></header> after <Amplitude />
// (/build/astro-guides-surveys).
const apiKey = import.meta.env.PUBLIC_AMPLITUDE_API_KEY;
// EU projects: https://cdn.eu.amplitude.com/script/
const experimentSrc = apiKey
? `https://cdn.amplitude.com/script/${apiKey}.experiment.js`
: undefined;
---
<!-- In <head>, right after <meta charset="utf-8" />: -->
<!-- Amplitude Web Experiment. Keep it first, synchronous, and is:inline. -->
{experimentSrc && <script is:inline src={experimentSrc}></script>}Why. Astro processes every <script> tag that has no attributes other than src. With a src built from a variable, as here, Astro 7 drops the tag from the output without a warning. Its docs say to add is:inline to load a script from a CDN. is:inline leaves the tag exactly where and as you wrote it, which is what a remote, render-blocking script needs. The docs ask for the script "as high as possible" in <head> and call the synchronous tag the best experience, so don't add async, defer, or type="module", and don't import it from src/scripts/amplitude.ts, which Astro loads as a module after the page is parsed. Render it from the shared layout, not from a single page, so every page has it.
The script grows with every experiment in your project, so check its size in the Network tab. The browser serves it from cache for 60 seconds across page loads. Archive finished experiments so it doesn't keep growing.
If you can't ship a blocking script
If your performance budget forbids synchronous third-party scripts, the docs offer an async loader with an anti-flicker mask. It hides the page with a style rule, loads the script asynchronously, and clears the mask when Amplitude applies changes or when the timeout expires. Use it in place of the tag above, in the same spot. define:vars passes the key from the frontmatter and also makes the script inline:
<!-- src/layouts/Layout.astro (async alternative; replaces the experiment <script> line in <head>) -->
{apiKey && (
<script define:vars={{ apiKey }}>
(function (d, h) {
// Milliseconds before the mask clears even if the script is slow.
var timeout = 1000;
var id = "amp-exp-css";
try {
if (!d.getElementById(id)) {
var st = d.createElement("style");
st.id = id;
st.innerText =
"* { visibility: hidden !important; background-image: none !important; }";
h.appendChild(st);
window.setTimeout(function () {
st.remove();
}, timeout);
var sc = d.createElement("script");
// EU projects: "https://cdn.eu.amplitude.com/script/"
sc.src = "https://cdn.amplitude.com/script/" + apiKey + ".experiment.js";
sc.async = true;
sc.onerror = function () {
st.remove();
};
h.insertBefore(sc, d.currentScript || h.lastChild);
}
} catch (e) {
console.error(e);
}
})(document, document.head);
</script>
)}The trade is visible: every visitor sees a blank page for up to timeout milliseconds instead of a flash of the original. The synchronous tag is the better default. Don't load Web Experiment through Google Tag Manager in production; the docs warn that tag managers load it asynchronously and cause flicker.
If you gate storage on cookie consent
The docs gate the experiment script's storage and impressions on consent through a window.experimentConfig object set above the script tag. In Astro, that's one more inline script directly above the tag from step 1:
<!-- src/layouts/Layout.astro (only if you need consent gating; directly above the experiment <script>) -->
<script is:inline>
window.experimentConfig = {
consentOptions: {
consentRequired: true,
},
};
</script>When the visitor answers your banner, call window.webExperiment?.setConsentStatus("granted") or "denied" from your consent platform's callback. Consent gating covers the Web Experiment script only; analytics consent is handled separately in the Browser SDK.
2. Keep analytics in one module, and keep the device ID stable
The experiment script sends impressions through the analytics SDK you already initialize, so there's no second initAll and no second key. src/scripts/amplitude.ts stays as it is, including the masking options from the Astro session replay page if you added them. That config records every session in development and about 1% in production with session replay; set sessionReplay.sampleRate to 0 if you don't want replays.
Check one thing that matters more for experiments than for analytics. The docs say most web experiments bucket by device ID, and reset() issues a new device ID. The base page's <amplitude-identity> element returns early when there's no user, and only the sign-out form's submit handler in SignOutButton.astro calls reset(). Keep it that way. If you've added a reset() call anywhere that runs for signed-out visitors, every anonymous visit gets a fresh device ID and a fresh roll of the dice.
Optional: event-triggered pages and behavioral targeting
If an experiment uses the On Event Tracked page trigger or real-time behavioral targeting, the experiment client needs to hear your analytics events. The docs say that when analytics and Web Experiment don't load together through the Unified Script, you register the experiment plugin on the analytics instance with window.amplitude.add(window.webExperiment.plugin()). With the npm SDK, make the same call on your imported instance, in the module that already calls initAll:
// src/scripts/amplitude.ts (optional; add below the amplitudeReady export and keep the rest of the file)
// Keep lines other Astro pages added here: pageViews: { trackHistoryChanges:
// "pathOnly" } (/build/astro-web-analytics); debugMode and privacyConfig
// (/build/astro-session-replay); the amplitude-cookies import, the device ID
// handoff lines, the deviceId spread, and the experiment block
// (/build/astro-feature-flags); the engagement block with renderCssInDom
// (/build/astro-guides-surveys).
// Lets On Event Tracked triggers and behavioral targeting see your events.
type WebExperimentGlobal = {
plugin: () => Parameters<typeof amplitude.add>[0];
};
const webExperiment = (window as Window & { webExperiment?: WebExperimentGlobal })
.webExperiment;
if (apiKey && webExperiment) {
amplitude.add(webExperiment.plugin());
}The synchronous tag in <head> has already run by the time Astro's bundled module scripts execute, so window.webExperiment exists. With the async loader the script almost always loads after your bundled modules, so the if skips the plugin without warning. If an experiment needs On Event Tracked triggers or behavioral targeting, use the synchronous tag. With <ClientRouter />, the module runs once per visit, so the plugin registers once. Skip this block entirely if your experiments use the default Immediately trigger.
3. Give tested elements stable selectors
The Visual Editor records each change against a CSS selector, and the script applies it to whatever matches that selector in the live DOM. The docs warn that the editor picks the shortest unique path, which can include generated names that change between builds. Astro's scoped styles add a generated data-astro-cid- attribute to your markup, so don't let a selector depend on it. A data- attribute you write yourself doesn't change. Add attributes to the elements you already render on your home page, rather than replacing the file:
<!-- src/pages/index.astro (add to your existing home page markup inside <Layout>) -->
<!-- Keep lines other Astro pages added here: data-guide attributes (/build/astro-guides-surveys). -->
<!-- Add data-exp to the static elements you plan to test, for example: -->
<h1 data-exp="home-headline">Plan your next project in minutes</h1>
<a href="/projects/new" data-exp="home-cta">Create a project</a>/projects/new is the page the product analytics page created. In the editor's Selector field, replace the generated path with [data-exp="home-headline"]. Put the attribute on HTML elements in your .astro markup. On your own Astro components, data-exp is just a prop, and it only reaches the HTML if the component spreads its props onto an element.
Why. Markup in an .astro file is plain HTML in the browser. Nothing hydrates it and nothing re-renders it, so a variant the script applies to static Astro markup stays applied, with no hydration warning to suppress. That's the advantage over a React or Vue app. Islands are the exception. A component with a client: directive hydrates in the browser, and its framework owns that markup from then on, so a change to text the island renders can be overwritten on hydration or the next render. A client:only island renders nothing on the server, so its elements appear after the script has evaluated. Test static Astro markup, use the When Element Appears trigger from step 4 for elements an island renders, and move tests on content driven by data or state to a feature flag.
4. Keep variants through ClientRouter navigations
Skip this step if your layout doesn't render <ClientRouter />. Without it, every link is a full page load, the experiment script runs again from the browser cache, and it evaluates the new page from scratch.
With <ClientRouter /> in your layout, navigation works differently, and three things follow from it.
- The script runs once per visit. On each navigation the router swaps
<head>and replaces<body>, but it skips a script whosesrcalready ran during the visit, sowindow.webExperimentcarries over from the first page. Keep the tag in the shared layout so every page includes it. - Link clicks keep working. The router replaces
<body>with the new page first and then pushes the new URL to history. The docs say Web Experiment evaluates on the initial page load and every time history state is pushed or popped, reverting what it applied and then reapplying for the new URL. The new page is already in place when the URL changes, so the default Immediately trigger finds your elements. - Back and forward work too. The browser changes the URL and fires
popstatebefore the router swaps in the page you're returning to, so the script evaluates while the old page is still showing. Its changes are tied to selectors, not to elements, so when the router swaps in the page, the script applies them to the new elements right away. That's true of Element Changes. Custom code runs once, when the URL changes, so make it wait for its element withutils.waitForElement, or it runs against the page you're leaving.
Leave each Page on the default trigger. Use When Element Appears only for an element that a framework island renders after load (step 3). It waits on a debounced observer, so on static markup it shows the original for about 100 ms first. In the experiment, go to Settings > Pages, select the Page, and change its trigger. Page triggers are in Early Access.
Why. Astro's router does the work a full page load would do, but on its own schedule: body swap, then history update on a link click, and history update first on the Back button. Web Experiment evaluates on the history change and keeps watching its selectors, so the variant lands whichever order the swap and the URL change happen in. Don't add an astro:page-load listener that tries to re-apply variants yourself; the script reverts and reapplies on its own, and a second pass from your code fights it.
5. Open your headers to the script and editor
Skip this step if your site doesn't send a Content Security Policy or a Cross-Origin-Opener-Policy. Astro adds neither unless you turn on security.csp in astro.config.mjs (a CSP <meta> tag) or set headers in middleware, and your host may add them. Per the docs, two changes are required.
Add *.amplitude.com and 'unsafe-inline' to your script-src directive, so the script and the Visual Editor can load. Remove Cross-Origin-Opener-Policy, or set it to unsafe-none, so the Visual Editor can open your site. If your host or middleware sends the policy, add them to the script-src it already sends, and to script-src-elem if your policy has one. Confirm with whoever owns your security policy before you loosen it.
If you use Astro's built-in security.csp, the same two values go in scriptDirective.resources, next to 'self'. That policy also has to cover the rest of @amplitude/unified: the analytics SDK starts a blob: worker, and guides and surveys apply inline styles, so a policy that lists only the Web Experiment sources logs errors for those products. Follow the CSP guidance in Implement Web Experiment and in the docs for each product you've turned on, and check the result with npm run build and npm run preview, since Astro's CSP doesn't apply in npm run dev. Astro writes its CSP <meta> after the experiment tag, so that policy governs what the script and the editor load afterwards; a header policy covers the tag too.
unsafe-none turns off cross-origin isolation, so if your site relies on COOP and COEP for SharedArrayBuffer, send it only on the pages you test. The editor's Mobile Simulator loads your site in an iframe, so X-Frame-Options: DENY or frame-ancestors 'none' blocks it.
6. Create the experiment in the Visual Editor
Run npm run dev or deploy, then create the experiment in Amplitude, following Set up a web experiment:
- In Amplitude Experiment, go to Experiments > Create Experiment > Web Experiment.
- Name the experiment, enter the URL of your home page, and select your project. Amplitude uses the URL to create your first Page.
- When the Visual Editor opens your page, click Element Changes, click the headline, and edit its text. Open the Selector and set it to
[data-exp="home-headline"]. Changes apply to the current variant as you go. - Click Continue. Leave the Page targeting on your home page URL and the audience on all users.
- Define your goals. Choose
Project Created, the event the product analytics page tracks, as the primary metric, then click Save and Close. - Click Test & Preview and open the preview link to check the treatment, then start the experiment.
If Amplitude opens a Site Setup panel instead of the editor, it didn't find the script on that URL, or an ad blocker in your browser stopped it. After you save changes, allow up to 60 seconds for caches to refresh before you check the live page.
MCP. Working in Cursor or Claude Code? Amplitude's MCP server lets your agent read your project's existing event taxonomy, so when you pick a goal it points at Project Created instead of inventing project_created.
Verify it worked
Open your home page in a normal Chrome window with ad blockers off, click Create a project, use the browser's Back button, then check in this order:
- Network tab. Filter for
amplitude.com. You should seeAPI_KEY.experiment.jsfromcdn.amplitude.comreturn 200 with your real key in the URL, listed before Astro's bundled scripts. Then look for the analytics SDK's POST toapi2.amplitude.com/2/httpapireturning 200. Read event contents in the Instrumentation Explorer Chrome extension, not the Network tab: if you're in the experiment, it shows an event withevent_type$impression, the form the script sends before Amplitude transforms it. With<ClientRouter />, confirm the headline still shows the variant after Back. - Ingestion Debugger. In Amplitude, click Data, select Sources, then open the Ingestion Debugger tab. The successful requests chart should show your uploads. It shows counts, not individual events.
- User lookup. Go to Users > User Profiles and search your
user-prefixed test ID or your device ID, which the Instrumentation Explorer shows asdevice_id. Within ten seconds to a minute the event stream shows[Experiment] Impression, with[Experiment] Flag Keynaming your experiment and[Experiment] Variantnaming the variant you saw.
Check a production build too: run npm run build and npm run preview, then repeat step 1. Prerendered pages carry the script URL in their HTML from the build, so this is where an empty key shows up.
No impression and no variant? You may be in control or outside the rollout. A private window gets a new device ID and a new roll. The Amplitude Chrome extension shows page and audience targeting and exposure status for each web experiment on the page, and Preview Variant lets you force a variant for your own session.
Troubleshooting
Custom code does nothing, but impressions say treatment
The synchronous tag runs custom code before your page's <body> is parsed, so a direct document.querySelector finds nothing. Wrap DOM work in utils.waitForElement('[data-exp="home-headline"]').then(...), as Web Experiment actions shows. With <ClientRouter />, that also makes the code wait for the page the router swaps in on Back.
The original headline flashes before the variant
The script is loading late. It must be an is:inline <script src> at the top of <head> in src/layouts/Layout.astro, right after <meta charset> and above <ClientRouter />, not imported from src/scripts/amplitude.ts, added by a tag manager, or marked async, defer, or type="module".
There's no request for experiment.js, or it goes to /script/.experiment.js
PUBLIC_AMPLITUDE_API_KEY was empty when the page was built, so the layout rendered no tag or a URL without the key. Restart the dev server after editing .env, set the variable in your build environment, and rebuild; prerendered pages keep the URL from the last build. If the key is set, check that the tag has is:inline and that the page uses your shared layout.
The variant applies on a full load but not after clicking a link
The element isn't on the new page when the script evaluates, usually because an island renders it. First confirm the selector matches: run document.querySelector('[data-exp="home-headline"]') in the DevTools console. If it does, set that Page's trigger to When Element Appears.
The variant disappears after the Back button
Check that the element has the same data-exp attribute on every page that renders it, and that the change's selector uses it. If an island renders the element, set that Page's trigger to When Element Appears.
The variant flashes in, then the original comes back
The element is inside a framework island that hydrated or re-rendered, or a script of yours rewrites it. Test static Astro markup instead, or move the test to a feature flag.
The variant worked yesterday and stopped after a deploy
The selector included a generated class or a data-astro-cid- attribute that changed when the component or its styles changed. Edit the change in the Visual Editor and set its selector to the data-exp attribute.
Anonymous visitors see a different variant on every visit
Something calls amplitude.reset() while the user is signed out, which issues a new device ID. Reset only in the sign-out form's submit handler, and keep the early return in the identity element.
Page views or sessions doubled after adding the script
You loaded the Unified Script, API_KEY.js, next to the npm SDK. Replace it with API_KEY.experiment.js.
Full code reference
Every file from this tutorial in one block, the version to hand to an agent. It uses the synchronous tag; swap in the async alternative from step 1 only if you must. The src/scripts/amplitude.ts lines are the optional plugin registration from step 2. 3 files.
// -- src/layouts/Layout.astro (frontmatter: add these constants; keep the rest) --
---
// Keep lines other Astro pages added here: the Amplitude import and <Amplitude />
// as the first thing in <body> (/build/astro-product-analytics); the AmpMask
// import, MASKED_PATHS, the masked <slot /> wrapper, and the .masked-section
// style (/build/astro-session-replay); the FeedbackButton import and
// <header><FeedbackButton /></header> after <Amplitude />
// (/build/astro-guides-surveys).
const apiKey = import.meta.env.PUBLIC_AMPLITUDE_API_KEY;
// EU projects: https://cdn.eu.amplitude.com/script/
const experimentSrc = apiKey
? `https://cdn.amplitude.com/script/${apiKey}.experiment.js`
: undefined;
---
<!-- In <head>, right after <meta charset="utf-8" />: -->
<!-- Amplitude Web Experiment. Keep it first, synchronous, and is:inline. -->
{experimentSrc && <script is:inline src={experimentSrc}></script>}
// -- src/pages/index.astro (add to your existing home page markup inside <Layout>) --
<!-- Keep lines other Astro pages added here: data-guide attributes (/build/astro-guides-surveys). -->
<!-- Add data-exp to the static elements you plan to test, for example: -->
<h1 data-exp="home-headline">Plan your next project in minutes</h1>
<a href="/projects/new" data-exp="home-cta">Create a project</a>
// -- src/scripts/amplitude.ts (optional; add below the amplitudeReady export and keep the rest of the file) --
// Keep lines other Astro pages added here: pageViews: { trackHistoryChanges:
// "pathOnly" } (/build/astro-web-analytics); debugMode and privacyConfig
// (/build/astro-session-replay); the amplitude-cookies import, the device ID
// handoff lines, the deviceId spread, and the experiment block
// (/build/astro-feature-flags); the engagement block with renderCssInDom
// (/build/astro-guides-surveys).
// Lets On Event Tracked triggers and behavioral targeting see your events.
type WebExperimentGlobal = {
plugin: () => Parameters<typeof amplitude.add>[0];
};
const webExperiment = (window as Window & { webExperiment?: WebExperimentGlobal })
.webExperiment;
if (apiKey && webExperiment) {
amplitude.add(webExperiment.plugin());
}
// Unchanged from earlier pages; keep your versions: .env, src/components/Amplitude.astro, src/components/SignOutButton.astro, src/components/CreateProjectForm.astro, src/middleware.ts, src/env.d.ts, src/lib/auth.ts, src/pages/projects/new.astro.Hand it to your agent
Prefer to have Cursor, Claude Code, or Codex CLI do the wiring? This prompt points the agent at this page and pins the decisions that matter.
Complete https://amplitude.com/build/astro-product-analytics first, or
confirm this Astro 6+ site already has its setup: src/scripts/amplitude.ts,
<Amplitude /> in src/layouts/Layout.astro, the src/lib/auth.ts stub, and
src/pages/projects/new.astro. Create any of those that are missing. Then
add Amplitude Web Experiment by following
https://amplitude.com/build/astro-web-experiment exactly. Merge into
existing shared files and never delete lines another Amplitude Build page
added; each file's first comment on that page names them. In the
frontmatter of src/layouts/Layout.astro (add the --- fences if it has
none), build
https://cdn.amplitude.com/script/<key>.experiment.js (not the Unified
Script) from import.meta.env.PUBLIC_AMPLITUDE_API_KEY, and render it as
<script is:inline src={experimentSrc}></script> at the top of <head>,
right after <meta charset="utf-8" /> and above <ClientRouter /> and
everything else, rendering nothing when the key is empty. Never load it
without is:inline, with async, defer or type="module", from
src/scripts/amplitude.ts, from a single page, or from a tag manager. Don't
add a second initAll; impressions go through the existing analytics SDK.
Only if I tell you an experiment uses On Event Tracked triggers or
behavioral targeting, add the WebExperimentGlobal type and register
window.webExperiment.plugin() with amplitude.add() below the
amplitudeReady export in src/scripts/amplitude.ts, changing nothing else
there. Keep reset() only in the sign-out form's submit handler. Add
data-exp attributes to the static elements in src/pages/index.astro that
I plan to test, without replacing the file, and tell me which of them are
rendered by a framework island (client:*), since those can be overwritten
on hydration; for those, remind me to set the Page's trigger to When
Element Appears, and leave every other Page on the default trigger. If
astro.config.mjs enables security.csp, or a host or middleware sends a
Content-Security-Policy, don't change it; tell me to add *.amplitude.com
and 'unsafe-inline' to its script-src, and to set
Cross-Origin-Opener-Policy to unsafe-none. Don't hardcode the key.
Report every file you changed.What this unlocks
- Session replay, on at the sample rate from the product analytics page, shows sessions where visitors saw the treatment headline and
Project Creatednever fired, so you can watch why. - Heatmaps are built from session replays, so they compare where people click on the home page hero in each variant.
- Feature Experiment, already in
@amplitude/unified, puts the winning headline insrc/pages/index.astrobehind a flag when you want a gradual rollout, since the docs recommend moving a winner into code rather than leaving the web experiment at 100%. - Guides and surveys can ask visitors who saw the treatment what they expected after clicking
home-cta.
One integration; everything else reads from the same events.
Next steps
Reference docs
Amplitude is the leading all-in-one analytics platform for building better products. Product and engineering teams use Amplitude to connect product analytics and web analytics with session replay, heatmaps, experimentation, feature flags, and guides and surveys in one unified workflow, with AI agents working across all of it. Learn how Amplitude is building self-improving products with Wave, helping teams turn product data into PRs.