diff --git a/web/src/utils/rum/__fixtures__/productAnalyticsProvenSql.ts b/web/src/utils/rum/__fixtures__/productAnalyticsProvenSql.ts
index 39cacb0af7..80e9940800 100644
--- a/web/src/utils/rum/__fixtures__/productAnalyticsProvenSql.ts
+++ b/web/src/utils/rum/__fixtures__/productAnalyticsProvenSql.ts
@@ -14,7 +14,7 @@
// along with this program. If not, see .
// Verbatim copy of design-v2/final_sql.txt, the SQL that ran on real data in sql-proof-v2.md.
-export const Q3_SUMMARY = String.raw`SELECT COUNT(DISTINCT CASE WHEN _timestamp >= {cs} THEN session_id END) AS sessions, COUNT(DISTINCT CASE WHEN _timestamp < {cs} THEN session_id END) AS prev_sessions, COUNT(DISTINCT CASE WHEN _timestamp >= {cs} AND type = 'view' THEN view_id END) AS views, COUNT(CASE WHEN _timestamp >= {cs} AND type IN ('view', 'action') THEN 1 END) AS va_rows, COUNT(DISTINCT CASE WHEN _timestamp >= {cs} AND session_type = 'synthetics' THEN session_id END) AS synthetic_sessions, MAX(_timestamp) AS data_through_us, COUNT(DISTINCT CASE WHEN _timestamp >= {cs} THEN NULLIF(usr_email, '') END) AS usr_email__values, COUNT(DISTINCT CASE WHEN _timestamp >= {cs} AND NULLIF(usr_email, '') IS NOT NULL THEN session_id END) AS usr_email__sessions FROM "_rumdata" WHERE {scope} LIMIT 1`;
+export const Q3_SUMMARY = String.raw`SELECT COUNT(DISTINCT CASE WHEN _timestamp >= {cs} AND (session_type IS NULL OR session_type <> 'synthetics') THEN session_id END) AS sessions, COUNT(DISTINCT CASE WHEN _timestamp < {cs} AND (session_type IS NULL OR session_type <> 'synthetics') THEN session_id END) AS prev_sessions, COUNT(DISTINCT CASE WHEN _timestamp >= {cs} AND type = 'view' THEN view_id END) AS views, COUNT(CASE WHEN _timestamp >= {cs} AND type IN ('view', 'action') THEN 1 END) AS va_rows, COUNT(DISTINCT CASE WHEN _timestamp >= {cs} AND session_type = 'synthetics' THEN session_id END) AS synthetic_sessions, MAX(_timestamp) AS data_through_us, COUNT(DISTINCT CASE WHEN _timestamp >= {cs} THEN NULLIF(usr_email, '') END) AS usr_email__values, COUNT(DISTINCT CASE WHEN _timestamp >= {cs} AND NULLIF(usr_email, '') IS NOT NULL THEN session_id END) AS usr_email__sessions FROM "_rumdata" WHERE {scope} LIMIT 1`;
export const Q4_PAGES_IDENTITY = String.raw`WITH v0 AS (SELECT session_id AS sid, type AS ty, CASE WHEN _timestamp >= {cs} THEN 1 ELSE 0 END AS cur, MIN(CASE WHEN type = 'view' THEN view_id END) AS vid, MIN(CASE WHEN type = 'view' THEN view_url END) AS url, first_value(NULLIF(usr_email, '') ORDER BY CASE WHEN NULLIF(usr_email, '') IS NULL THEN 1 ELSE 0 END, date) AS u0, MIN(CASE WHEN NULLIF(usr_email, '') IS NOT NULL THEN date END) AS ut FROM "_rumdata" WHERE {scope} AND (type = 'view' OR NULLIF(usr_email, '') IS NOT NULL) GROUP BY session_id, type, CASE WHEN type = 'view' THEN view_id END, CASE WHEN _timestamp >= {cs} THEN 1 ELSE 0 END),
v1 AS (SELECT sid, ty, cur, vid, url, FIRST_VALUE(u0) OVER (PARTITION BY sid ORDER BY CASE WHEN u0 IS NULL THEN 1 ELSE 0 END, ut) AS u FROM v0),
diff --git a/web/src/utils/rum/productAnalyticsQueries.spec.ts b/web/src/utils/rum/productAnalyticsQueries.spec.ts
index ff1b9d8cc6..2397ee902e 100644
--- a/web/src/utils/rum/productAnalyticsQueries.spec.ts
+++ b/web/src/utils/rum/productAnalyticsQueries.spec.ts
@@ -367,13 +367,29 @@ describe("scope-load builders (G2)", () => {
);
});
- it("Q3 summary equals the proven shape and keeps synthetic sessions countable", () => {
+ it("Q3 summary equals the proven shape, counts synthetic sessions and excludes them from sessions (P1-M2)", () => {
const sql = summarySql(s, CS);
const unscoped = "application_id = 'web' AND session_id IS NOT NULL";
expect(sql.split(unscoped).join("{scope}").split(String(CS)).join("{cs}")).toBe(
PROVEN.Q3_SUMMARY,
);
- expect(sql).not.toContain("<> 'synthetics'");
+ // `sessions`/`prev_sessions` must drop synthetics so the KPI matches the "N synthetic sessions
+ // excluded" caption, while `synthetic_sessions` still counts them via its own, unfiltered CASE.
+ expect(sql).toContain(
+ "CASE WHEN _timestamp >= {cs} AND (session_type IS NULL OR session_type <> 'synthetics') THEN session_id END) AS sessions".replace(
+ "{cs}",
+ String(CS),
+ ),
+ );
+ expect(sql).toContain(
+ "CASE WHEN _timestamp < {cs} AND (session_type IS NULL OR session_type <> 'synthetics') THEN session_id END) AS prev_sessions".replace(
+ "{cs}",
+ String(CS),
+ ),
+ );
+ expect(sql).toContain(
+ `CASE WHEN _timestamp >= ${CS} AND session_type = 'synthetics' THEN session_id END) AS synthetic_sessions`,
+ );
});
it("Q3 without session_type or candidates reports zero synthetics and no identity pairs", () => {
diff --git a/web/src/utils/rum/productAnalyticsQueries.ts b/web/src/utils/rum/productAnalyticsQueries.ts
index 133512d07a..8a2fd71dd0 100644
--- a/web/src/utils/rum/productAnalyticsQueries.ts
+++ b/web/src/utils/rum/productAnalyticsQueries.ts
@@ -322,12 +322,18 @@ export function facetOptionsSql(scope: AnalyticsScope, field: "env" | "version")
export function summarySql(scope: AnalyticsScope, currentStartUs: number): string {
const cs = Math.trunc(currentStartUs);
- const synthetic = has(scope, "session_type")
+ const hasSessionType = has(scope, "session_type");
+ const synthetic = hasSessionType
? `COUNT(DISTINCT CASE WHEN _timestamp >= ${cs} AND session_type = 'synthetics' THEN session_id END) AS synthetic_sessions`
: "0 AS synthetic_sessions";
+ // The caption says synthetics are excluded, so the sessions KPI (and its prior-period base) must
+ // actually drop them here too, not just report their count via `synthetic_sessions`.
+ const notSynthetic = hasSessionType
+ ? " AND (session_type IS NULL OR session_type <> 'synthetics')"
+ : "";
const cols = [
- `COUNT(DISTINCT CASE WHEN _timestamp >= ${cs} THEN session_id END) AS sessions`,
- `COUNT(DISTINCT CASE WHEN _timestamp < ${cs} THEN session_id END) AS prev_sessions`,
+ `COUNT(DISTINCT CASE WHEN _timestamp >= ${cs}${notSynthetic} THEN session_id END) AS sessions`,
+ `COUNT(DISTINCT CASE WHEN _timestamp < ${cs}${notSynthetic} THEN session_id END) AS prev_sessions`,
`COUNT(DISTINCT CASE WHEN _timestamp >= ${cs} AND type = 'view' THEN view_id END) AS views`,
`COUNT(CASE WHEN _timestamp >= ${cs} AND type IN ('view', 'action') THEN 1 END) AS va_rows`,
synthetic,
diff --git a/web/src/views/RUM/AppAnalytics.spec.ts b/web/src/views/RUM/AppAnalytics.spec.ts
index ec6d10dd8f..336f6c516d 100644
--- a/web/src/views/RUM/AppAnalytics.spec.ts
+++ b/web/src/views/RUM/AppAnalytics.spec.ts
@@ -773,6 +773,75 @@ describe("AppAnalytics shell", () => {
expect(router.currentRoute.value.query.period).toBe("30d");
});
+ it("a reload or shared link moves the already-mounted picker, not just the model (P1-M1)", async () => {
+ const setSavedDate = vi.fn();
+ router = makeRouter();
+ await router.push("/product-analytics/overview?app=web&period=4w");
+ wrapper = mount(
+ { template: "" },
+ {
+ global: {
+ plugins: [router, store],
+ stubs: {
+ DateTimePickerDashboard: {
+ props: ["modelValue"],
+ setup: (_: unknown, { expose }: { expose: (e: object) => void }) => {
+ expose({ setSavedDate });
+ return {};
+ },
+ template: "
",
+ },
+ ShareButton: { template: "", props: ["url"] },
+ },
+ },
+ attachTo: document.body,
+ },
+ );
+ await flushPromises();
+ await flushPromises();
+
+ expect(useProductAnalytics().state.datetime).toMatchObject({
+ valueType: "relative",
+ relativeTimePeriod: "4w",
+ });
+ expect(setSavedDate).toHaveBeenCalledWith({ type: "relative", relativeTimePeriod: "4w" });
+ });
+
+ it("switching sub-tabs with a new range in the URL moves the mounted picker too (P1-M1)", async () => {
+ const setSavedDate = vi.fn();
+ router = makeRouter();
+ await router.push("/product-analytics/overview?app=web&period=7d");
+ wrapper = mount(
+ { template: "" },
+ {
+ global: {
+ plugins: [router, store],
+ stubs: {
+ DateTimePickerDashboard: {
+ props: ["modelValue"],
+ setup: (_: unknown, { expose }: { expose: (e: object) => void }) => {
+ expose({ setSavedDate });
+ return {};
+ },
+ template: "",
+ },
+ ShareButton: { template: "", props: ["url"] },
+ },
+ },
+ attachTo: document.body,
+ },
+ );
+ await flushPromises();
+ await flushPromises();
+ setSavedDate.mockClear();
+
+ await router.push("/product-analytics/funnels?app=web&period=4w");
+ await flushPromises();
+ await flushPromises();
+
+ expect(setSavedDate).toHaveBeenCalledWith({ type: "relative", relativeTimePeriod: "4w" });
+ });
+
it("mounting inside keep-alive enters once, as a plain mount does (F6)", async () => {
await mountAt("/product-analytics/overview?app=web&period=7d");
await new Promise((r) => setTimeout(r, 30));
diff --git a/web/src/views/RUM/AppAnalytics.vue b/web/src/views/RUM/AppAnalytics.vue
index b867d10dc3..7c79b45149 100644
--- a/web/src/views/RUM/AppAnalytics.vue
+++ b/web/src/views/RUM/AppAnalytics.vue
@@ -71,6 +71,7 @@ along with this program. If not, see .
@update:model-value="onVersionChange"
/>
(null);
const org = computed(() => store.state.selectedOrganization?.identifier ?? "");
@@ -470,6 +472,12 @@ const sameRange = (a: AnalyticsDateTime, b: AnalyticsDateTime) =>
? a.relativeTimePeriod === b.relativeTimePeriod
: a.startTime === b.startTime && a.endTime === b.endTime);
+// The picker reads this shape into an already-mounted DateTime (see DateTimePickerDashboard.setSavedDate).
+const toPickerSavedDate = (dt: AnalyticsDateTime) =>
+ dt.valueType === "absolute"
+ ? { type: "absolute", startTime: dt.startTime, endTime: dt.endTime }
+ : { type: "relative", relativeTimePeriod: dt.relativeTimePeriod };
+
const onDateChange = (value: AnalyticsDateTime) => {
if (sameRange(value, state.datetime)) return;
pa.setScope({ datetime: { ...value } });
@@ -512,6 +520,9 @@ const resumesScope = () =>
const enter = async () => {
if (!resumesScope()) {
pa.initFromRoute(route.query, { keepFunnel: route.name === PA_ROUTES.funnels });
+ // The picker reads its range only on mount, so a reload or a shared link (both land here
+ // after it has already mounted with the default range) must move the mounted picker too.
+ dateTimePickerRef.value?.setSavedDate?.(toPickerSavedDate(state.datetime));
}
enteredOrg = org.value;
invalidDismissed.value = false;