Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/cli/src/server/schema/local-inserts.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"projectRevision": "12a7685236a4ebe4a8a40900e74c0239ab853afec09659d78a9b2f92811561fa",
"projectRevision": "5654f2527545c4b48718669fdf45e05c5a1b8de02c2f6d861a87c2c67bc19368",
"orgPlaceholder": "__ORG__",
"datasources": {
"traces": {
Expand Down
5 changes: 3 additions & 2 deletions apps/cli/src/server/schema/local-schema.sql
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
-- This file is generated by scripts/generate-clickhouse-schema-sql.ts
-- Do not edit manually. Run `bun run clickhouse:schema` to regenerate.
-- projectRevision: 12a7685236a4ebe4a8a40900e74c0239ab853afec09659d78a9b2f92811561fa
-- projectRevision: 5654f2527545c4b48718669fdf45e05c5a1b8de02c2f6d861a87c2c67bc19368
-- localSchemaVersion: 1

CREATE TABLE IF NOT EXISTS alert_checks (
Expand Down Expand Up @@ -642,7 +642,8 @@ CREATE TABLE IF NOT EXISTS session_replays (
EntryPath String DEFAULT '',
ExitPath String DEFAULT '',
Language LowCardinality(String) DEFAULT '',
LastActivityAt Nullable(DateTime64(9))
LastActivityAt Nullable(DateTime64(9)),
BillableStart UInt8 DEFAULT 0
)
ENGINE = ReplacingMergeTree
PARTITION BY toDate(StartTime)
Expand Down
10 changes: 5 additions & 5 deletions apps/ingest/src/clickhouse_insert_mappings.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
// This file is generated by scripts/generate-clickhouse-insert-mappings.ts
// Do not edit manually.

pub const PROJECT_REVISION: &str = "12a7685236a4ebe4a8a40900e74c0239ab853afec09659d78a9b2f92811561fa";
pub const PROJECT_REVISION: &str = "5654f2527545c4b48718669fdf45e05c5a1b8de02c2f6d861a87c2c67bc19368";
// Gate for BYO-ClickHouse ingest readiness — the migration version, NOT the
// Tinybird-coupled PROJECT_REVISION. Compared against
// org_clickhouse_settings.schema_version. See @maple/domain/clickhouse
// clickHouseSchemaVersion.
pub const SCHEMA_VERSION: &str = "12";
pub const SCHEMA_VERSION: &str = "13";
pub const ORG_PLACEHOLDER: &str = "__ORG__";

#[derive(Debug)]
Expand Down Expand Up @@ -64,9 +64,9 @@ pub const DATASOURCES: &[InsertMapping] = &[
InsertMapping {
datasource: "session_replays",
table: "session_replays",
columns: &["OrgId", "SessionId", "StartTime", "EndTime", "DurationMs", "Status", "UserId", "UrlInitial", "UserAgent", "BrowserName", "OsName", "DeviceType", "Country", "ServiceName", "PageViews", "ClickCount", "ErrorCount", "TraceIds", "ResourceAttributes", "Version", "VisitorId", "VisitorIsNew", "UserEmail", "UserName", "GroupId", "GroupName", "UserTraits", "Referrer", "ReferrerHost", "UtmSource", "UtmMedium", "UtmCampaign", "UtmTerm", "UtmContent", "Host", "EntryPath", "ExitPath", "Language", "LastActivityAt"],
selects: &["__ORG__", "session_id", "start_time", "end_time", "duration_ms", "status", "user_id", "url_initial", "user_agent", "browser_name", "os_name", "device_type", "country", "service_name", "page_views", "click_count", "error_count", "trace_ids", "resource_attributes", "version", "visitor_id", "visitor_is_new", "user_email", "user_name", "group_id", "group_name", "user_traits", "referrer", "referrer_host", "utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "host", "entry_path", "exit_path", "language", "last_activity_at"],
input_schema: "session_id String, start_time DateTime64(9), end_time Nullable(DateTime64(9)), duration_ms Nullable(UInt32), status LowCardinality(String), user_id String, url_initial String, user_agent String, browser_name LowCardinality(String), os_name LowCardinality(String), device_type LowCardinality(String), country LowCardinality(String), service_name LowCardinality(String), page_views UInt32, click_count UInt32, error_count UInt32, trace_ids Array(String), resource_attributes Map(LowCardinality(String), String), version UInt32, visitor_id String, visitor_is_new UInt8, user_email String, user_name String, group_id String, group_name String, user_traits Map(String, String), referrer String, referrer_host LowCardinality(String), utm_source LowCardinality(String), utm_medium LowCardinality(String), utm_campaign LowCardinality(String), utm_term String, utm_content String, host LowCardinality(String), entry_path String, exit_path String, language LowCardinality(String), last_activity_at Nullable(DateTime64(9))",
columns: &["OrgId", "SessionId", "StartTime", "EndTime", "DurationMs", "Status", "UserId", "UrlInitial", "UserAgent", "BrowserName", "OsName", "DeviceType", "Country", "ServiceName", "PageViews", "ClickCount", "ErrorCount", "TraceIds", "ResourceAttributes", "Version", "VisitorId", "VisitorIsNew", "UserEmail", "UserName", "GroupId", "GroupName", "UserTraits", "Referrer", "ReferrerHost", "UtmSource", "UtmMedium", "UtmCampaign", "UtmTerm", "UtmContent", "Host", "EntryPath", "ExitPath", "Language", "LastActivityAt", "BillableStart"],
selects: &["__ORG__", "session_id", "start_time", "end_time", "duration_ms", "status", "user_id", "url_initial", "user_agent", "browser_name", "os_name", "device_type", "country", "service_name", "page_views", "click_count", "error_count", "trace_ids", "resource_attributes", "version", "visitor_id", "visitor_is_new", "user_email", "user_name", "group_id", "group_name", "user_traits", "referrer", "referrer_host", "utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "host", "entry_path", "exit_path", "language", "last_activity_at", "billable_start"],
input_schema: "session_id String, start_time DateTime64(9), end_time Nullable(DateTime64(9)), duration_ms Nullable(UInt32), status LowCardinality(String), user_id String, url_initial String, user_agent String, browser_name LowCardinality(String), os_name LowCardinality(String), device_type LowCardinality(String), country LowCardinality(String), service_name LowCardinality(String), page_views UInt32, click_count UInt32, error_count UInt32, trace_ids Array(String), resource_attributes Map(LowCardinality(String), String), version UInt32, visitor_id String, visitor_is_new UInt8, user_email String, user_name String, group_id String, group_name String, user_traits Map(String, String), referrer String, referrer_host LowCardinality(String), utm_source LowCardinality(String), utm_medium LowCardinality(String), utm_campaign LowCardinality(String), utm_term String, utm_content String, host LowCardinality(String), entry_path String, exit_path String, language LowCardinality(String), last_activity_at Nullable(DateTime64(9)), billable_start UInt8",
},
InsertMapping {
datasource: "session_replay_events",
Expand Down
108 changes: 100 additions & 8 deletions apps/ingest/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2323,6 +2323,38 @@ async fn handle_replay_meta(
}
}

/// Whether one session-metadata row starts a visit Autumn should be charged for.
///
/// See the call site for why it takes both conditions. The `billable_start`
/// fallback is not defensive: customers pin SDK versions, so bundles that predate
/// the field keep posting for months, and treating their absence as "not
/// billable" would silently stop billing every one of those orgs. Absent means
/// "an older SDK that could not claim a visit", and the old `version == 1` rule
/// is the best answer available for it. Remove the fallback only once the
/// oldest SDK in the wild emits the field.
/// Whether the SDK could persist this visitor's id across the page load.
///
/// The SDK emits `maple.visitor.persisted: "false"` only when both the cookie and
/// localStorage were blocked, so an absent key means persisted — including for
/// older bundles that never emitted it.
fn visitor_id_is_persisted(obj: &serde_json::Map<String, serde_json::Value>) -> bool {
obj.get("resource_attributes")
.and_then(|v| v.as_object())
.and_then(|attrs| attrs.get("maple.visitor.persisted"))
.and_then(|v| v.as_str())
!= Some("false")
}

fn is_billable_visit_start(obj: &serde_json::Map<String, serde_json::Value>) -> bool {
if obj.get("version").and_then(|v| v.as_u64()) != Some(1) {
return false;
}
match obj.get("billable_start") {
Some(value) => value.as_u64() == Some(1),
None => true,
}
}

async fn handle_replay_meta_inner(
state: &AppState,
headers: &HeaderMap,
Expand Down Expand Up @@ -2371,16 +2403,27 @@ async fn handle_replay_meta_inner(
// NDJSON: one session-metadata object per line. The org_id is always taken
// from the authenticated key, never from the client-supplied body.
//
// Count session-start rows so we can meter one browser session per session to
// Autumn. The browser SDK posts a start row (`version: 1` / `status: "active"`)
// at session start and an end row (`version: 2`) at unload; counting only starts
// avoids double-counting. Caveat: an in-tab reload recreates the SDK session sink
// and re-posts a start row for the same SessionId, so reloads can slightly
// over-count — consistent with the at-least-once metering used for the
// logs/traces/metrics signals.
// Count billable visit starts so we meter one browser session per *visit* to
// Autumn. Two conditions, and both are load-bearing:
//
// `billable_start == 1` — the SDK claimed this visit against a store shared
// across tabs and subdomains (`visit.ts`). It is sticky: every row of a
// billable session carries it, so the row that survives the
// ReplacingMergeTree merge still records that the session was charged.
// `version == 1` — the first row of that session record. Since the
// flag is sticky, this is what keeps the ~1/minute heartbeats and the
// unload row from re-billing a visit already paid for.
//
// Billing used to be `version == 1` alone. That charged once per tab and once
// per origin, because the session record lives in sessionStorage, which is
// scoped to both — one person with four tabs open paid four times.
let country = derive_country(headers, state.config.trust_proxy_geo);
let mut rows: Vec<Vec<u8>> = Vec::new();
let mut session_starts: u64 = 0;
// Of those, the ones from a visitor whose id does not survive the page load —
// the tail that re-claims and re-bills on every navigation. Counted, not
// corrected: there is nowhere on such a browser to keep the claim.
let mut unpersisted_starts: u64 = 0;
for line in body.split(|&b| b == b'\n') {
if line.iter().all(u8::is_ascii_whitespace) {
continue;
Expand Down Expand Up @@ -2422,8 +2465,11 @@ async fn handle_replay_meta_inner(
// LowCardinality columns. Clamp before it reaches the warehouse — the
// SDK's own trimming ships in customer JavaScript.
sanitize_session_meta(obj);
if obj.get("version").and_then(|v| v.as_u64()) == Some(1) {
if is_billable_visit_start(obj) {
session_starts += 1;
if !visitor_id_is_persisted(obj) {
unpersisted_starts += 1;
}
}
rows.push(
serde_json::to_vec(&value).map_err(|e| {
Expand Down Expand Up @@ -2456,6 +2502,8 @@ async fn handle_replay_meta_inner(
if let Some(tracker) = &state.autumn_tracker {
if org_id != SENTINEL_ORG_ID && session_starts > 0 {
tracker.track(&org_id, "browser_sessions", session_starts as f64);
metrics::billed_browser_sessions(session_starts - unpersisted_starts, true);
metrics::billed_browser_sessions(unpersisted_starts, false);
}
}

Expand Down Expand Up @@ -6433,6 +6481,50 @@ mod tests {
));
}

fn meta_row(json: &str) -> serde_json::Map<String, serde_json::Value> {
match serde_json::from_str(json).expect("valid JSON") {
serde_json::Value::Object(map) => map,
other => panic!("expected an object, got {other}"),
}
}

#[test]
fn only_the_first_row_of_a_claimed_visit_is_billed() {
// The claiming session's first row: the one charge for this visit.
assert!(is_billable_visit_start(&meta_row(
r#"{"version":1,"billable_start":1,"status":"active"}"#
)));

// `billable_start` is sticky across the session's rows so the merged row
// still records the charge — which is exactly why `version` has to gate it.
// Heartbeats and the unload row must not re-bill a visit already paid for.
assert!(!is_billable_visit_start(&meta_row(
r#"{"version":2,"billable_start":1,"status":"active"}"#
)));
assert!(!is_billable_visit_start(&meta_row(
r#"{"version":9,"billable_start":1,"status":"ended"}"#
)));

// A second tab or a second subdomain: a real session, its own record, its
// own version 1 — and not a second charge, because the visit was claimed.
assert!(!is_billable_visit_start(&meta_row(
r#"{"version":1,"billable_start":0,"status":"active"}"#
)));
}

#[test]
fn a_row_without_the_field_falls_back_to_the_old_version_rule() {
// Customers pin SDK versions, so bundles predating `billable_start` keep
// posting for months. Reading their silence as "not billable" would stop
// billing those orgs entirely.
assert!(is_billable_visit_start(&meta_row(
r#"{"version":1,"status":"active"}"#
)));
assert!(!is_billable_visit_start(&meta_row(
r#"{"version":2,"status":"ended"}"#
)));
}

#[test]
fn a_browser_sessions_cap_blocks_replay_without_touching_other_signals() {
// Replay is metered as `browser_sessions`, so the cap has to reach it —
Expand Down
25 changes: 25 additions & 0 deletions apps/ingest/src/metrics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,13 @@ static AUTUMN_FLUSHES_TOTAL: LazyLock<Counter<u64>> = LazyLock::new(|| {
.build()
});

static BILLED_BROWSER_SESSIONS_TOTAL: LazyLock<Counter<u64>> = LazyLock::new(|| {
METER
.u64_counter("ingest_billed_browser_sessions_total")
.with_description("Browser sessions metered to Autumn, by how the SDK identified the visitor")
.build()
});

// --- Up/down counter ------------------------------------------------------

static REQUESTS_IN_FLIGHT: LazyLock<UpDownCounter<i64>> = LazyLock::new(|| {
Expand Down Expand Up @@ -664,6 +671,24 @@ pub fn metrics_summary_dropped() {
METRICS_SUMMARY_DROPPED_TOTAL.add(1, &[]);
}

/// Browser sessions charged to an org, split by whether the visitor id survives
/// the page load.
///
/// `visitor_persisted=false` means both the cookie and localStorage were blocked
/// (Safari ITP, incognito, a cookie-blocking extension). Those visitors cannot
/// hold a visit claim, so every page load they make re-claims and is charged
/// again — the one over-count the per-visit model does not fix. This exists so
/// the size of that tail is a number before anyone tries to price around it.
pub fn billed_browser_sessions(count: u64, visitor_persisted: bool) {
if count == 0 {
return;
}
BILLED_BROWSER_SESSIONS_TOTAL.add(
count,
&[KeyValue::new("visitor_persisted", visitor_persisted)],
);
}

/// An Autumn usage-tracking flush cycle completed (`status` is `ok` or `error`).
pub fn autumn_flush(status: &'static str, duration_secs: f64) {
AUTUMN_FLUSH_DURATION_SECONDS.record(duration_secs, &[]);
Expand Down
124 changes: 124 additions & 0 deletions packages/browser-session/src/cookie.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* First-party cookie plumbing, and the one decision every cookie this SDK writes
* has to agree on: which `Domain=` they are scoped to.
*
* Extracted from `visitor.ts` when a second consumer appeared. `visit.ts` needs
* the *same* domain the visitor id uses — a visit claim written host-only while
* the visitor id spans subdomains would silently stop deduplicating exactly
* where it matters most, on the marketing-site → app hop. Sharing the probe is
* what keeps the two from drifting.
*/

/** Scope cookies to the registered domain so subdomains share them. */
let crossSubdomainCookie = true
/** Explicit `Domain=` override; `""` forces a host-only cookie. */
let cookieDomainOverride: string | undefined
/** Memoized probe result. `undefined` = not resolved yet. */
let probedCookieDomain: string | undefined

/**
* Apply the host app's cookie configuration. Called from `configurePrivacy`, so
* both SDKs get it from the single call they already make.
*
* Like the consent gates, this only ever *tightens*: an app that initializes two
* SDKs, only one of which passes a `privacy` block, must not have the other's
* absent option widen the cookie back out to every subdomain.
*
* "Tighter" for `cookieDomain` means *narrower scope*, which is why this is not
* first-write-wins: `""` (host-only) is the tightest value there is, and a
* second SDK asking for it has to win over an earlier `"example.com"`. Between
* two non-empty domains the shorter one is the broader — `example.com` covers
* `app.example.com` and not the reverse — so the longer string wins.
*/
export function configureCookieScope(options: {
readonly crossSubdomainCookie?: boolean | undefined
readonly cookieDomain?: string | undefined
}): void {
if (options.crossSubdomainCookie === false) crossSubdomainCookie = false
if (options.cookieDomain !== undefined) {
cookieDomainOverride = tighterCookieDomain(cookieDomainOverride, options.cookieDomain)
}
probedCookieDomain = undefined
}

/** The narrower of two `Domain=` values, treating `undefined` as "unset". */
function tighterCookieDomain(current: string | undefined, next: string): string {
if (current === undefined) return next
// Host-only beats any domain-scoped cookie, whichever side asked for it.
if (current === "" || next === "") return ""
return next.length > current.length ? next : current
}

export function readRawCookie(name: string): string | undefined {
if (typeof document === "undefined") return undefined
try {
for (const part of document.cookie.split(";")) {
const raw = part.trim()
if (!raw.startsWith(`${name}=`)) continue
return decodeURIComponent(raw.slice(name.length + 1))
}
} catch {
// Cookies disabled entirely — indistinguishable from "not set".
}
return undefined
}

export function setRawCookie(name: string, value: string, domain: string, maxAgeSeconds: number): boolean {
if (typeof document === "undefined") return false
const attributes = [
`${name}=${encodeURIComponent(value)}`,
"path=/",
`max-age=${Math.max(0, Math.floor(maxAgeSeconds))}`,
"SameSite=Lax",
]
if (domain) attributes.push(`domain=.${domain}`)
// A `Secure` cookie is rejected over http, which is exactly the local-dev case.
if (typeof location !== "undefined" && location.protocol === "https:") attributes.push("Secure")
try {
document.cookie = attributes.join("; ")
return true
} catch {
return false
}
}

/**
* The broadest domain this browser will actually accept a cookie for, found by
* probing rather than by carrying a public-suffix list — the same trick
* posthog-js uses. Candidates start at the broadest (the last two labels) and
* narrow a label at a time, with the first that sticks winning — so
* `app.example.co.uk` tries the rejected `co.uk`, then lands on `example.co.uk`.
*
* Returns `""` (host-only cookie) for single-label hosts like `localhost` and
* for bare IPs, neither of which can carry a `Domain=` attribute.
*/
function probeCookieDomain(): string {
if (typeof document === "undefined" || typeof location === "undefined") return ""
const hostname = location.hostname
if (!hostname || /^[\d.]+$/.test(hostname) || hostname.includes(":")) return ""
const parts = hostname.split(".")
if (parts.length < 2) return ""
for (let i = parts.length - 2; i >= 0; i--) {
const candidate = parts.slice(i).join(".")
const probe = "__maple_probe"
if (setRawCookie(probe, "1", candidate, 60) && readRawCookie(probe) === "1") {
setRawCookie(probe, "", candidate, 0)
return candidate
}
}
return ""
}

export function cookieDomain(): string {
if (cookieDomainOverride !== undefined) return cookieDomainOverride
if (!crossSubdomainCookie) return ""
if (probedCookieDomain === undefined) probedCookieDomain = probeCookieDomain()
return probedCookieDomain
}

/** Test seam — drops the memoized domain and any configured scope. */
export function resetCookieScopeForTests(): void {
crossSubdomainCookie = true
cookieDomainOverride = undefined
probedCookieDomain = undefined
}
Loading
Loading