From 8e9268f24fddf8f8d2cfc8b7e68be9b979938986 Mon Sep 17 00:00:00 2001 From: Leo Date: Mon, 13 Jul 2026 22:42:34 -0700 Subject: [PATCH 1/3] feat!: remove manual lifecycle and billing events --- .../remove-authoritative-lifecycle-billing.md | 7 + README.md | 27 +- crates/outlit/CHANGELOG.md | 4 + crates/outlit/README.md | 41 +- crates/outlit/src/builders.rs | 200 +------- crates/outlit/src/client.rs | 234 +-------- crates/outlit/src/lib.rs | 9 +- crates/outlit/src/types.rs | 90 ---- crates/outlit/tests/integration.rs | 111 ----- crates/outlit/tests/payload_compatibility.rs | 44 +- docs/api-reference/ingest.mdx | 86 +--- docs/api-reference/introduction.mdx | 2 +- docs/concepts/customer-context-graph.mdx | 2 +- docs/concepts/customer-journey.mdx | 66 +-- docs/openapi.json | 58 --- docs/tracking/browser/angular.mdx | 18 +- docs/tracking/browser/astro.mdx | 12 +- docs/tracking/browser/npm.mdx | 18 +- docs/tracking/browser/nuxt.mdx | 14 +- docs/tracking/browser/react.mdx | 39 +- docs/tracking/browser/script.mdx | 30 +- docs/tracking/browser/sveltekit.mdx | 19 +- docs/tracking/browser/vue.mdx | 37 +- docs/tracking/how-it-works.mdx | 4 +- docs/tracking/server/nodejs.mdx | 132 +---- docs/tracking/server/rust.mdx | 44 +- examples/pi-agents/README.md | 6 +- .../lib/activation-pretriage-data.ts | 6 +- .../pi-agents/lib/activation-pretriage.ts | 23 +- .../skills/outlit-growth-agents/SKILL.md | 2 +- .../tests/activation-pretriage.test.ts | 9 +- .../pi-agents/tests/pretriage-utils.test.ts | 8 +- packages/browser/README.md | 2 +- .../browser/__tests__/e2e/api-surface.spec.ts | 24 + .../browser/__tests__/e2e/browser-sdk.spec.ts | 14 +- .../e2e/fixtures/form-all-sensitive.html | 5 +- .../fixtures/form-auto-identify-disabled.html | 5 +- .../e2e/fixtures/form-credit-card.html | 5 +- .../e2e/fixtures/form-email-first-last.html | 5 +- .../e2e/fixtures/form-email-name.html | 5 +- .../e2e/fixtures/form-email-no-name.html | 5 +- .../e2e/fixtures/form-email-only.html | 5 +- .../e2e/fixtures/form-file-inputs.html | 5 +- .../__tests__/e2e/fixtures/form-multiple.html | 5 +- .../__tests__/e2e/fixtures/form-no-email.html | 5 +- .../__tests__/e2e/fixtures/form-no-id.html | 5 +- .../fixtures/form-password-variations.html | 5 +- .../__tests__/e2e/fixtures/form-ssn.html | 5 +- .../test-page-consent-persistence.html | 5 +- .../e2e/fixtures/test-page-consent.html | 5 +- .../fixtures/test-page-custom-denylist.html | 5 +- .../e2e/fixtures/test-page-links.html | 5 +- .../e2e/fixtures/test-page-no-forms.html | 5 +- .../e2e/fixtures/test-page-no-pageviews.html | 5 +- .../e2e/fixtures/test-page-queued.html | 8 +- .../e2e/fixtures/test-page-session.html | 5 +- .../e2e/fixtures/test-page-timer.html | 5 +- .../__tests__/e2e/fixtures/test-page.html | 5 +- packages/browser/__tests__/e2e/global.d.ts | 20 - .../__tests__/e2e/stage-methods.spec.ts | 462 ------------------ .../__tests__/unit/react-hooks.test.tsx | 75 +-- .../browser/__tests__/unit/tracker.test.ts | 27 - .../__tests__/unit/vue-composables.test.ts | 80 +-- packages/browser/src/index.ts | 7 +- packages/browser/src/react/hooks.ts | 102 +--- packages/browser/src/react/index.ts | 4 +- packages/browser/src/script.ts | 81 +-- packages/browser/src/tracker.ts | 160 +----- packages/browser/src/vue/composables.ts | 36 +- .../src/__tests__/customer-identity.test.ts | 8 +- packages/core/src/__tests__/types.test.ts | 19 +- packages/core/src/index.ts | 8 - packages/core/src/payload.ts | 59 --- packages/core/src/types.ts | 50 +- packages/core/src/utils.ts | 15 - packages/node/README.md | 2 +- packages/node/src/__tests__/client.test.ts | 54 +- packages/node/src/client.ts | 108 +--- packages/node/src/index.ts | 3 +- 79 files changed, 253 insertions(+), 2687 deletions(-) create mode 100644 .changeset/remove-authoritative-lifecycle-billing.md create mode 100644 packages/browser/__tests__/e2e/api-surface.spec.ts delete mode 100644 packages/browser/__tests__/e2e/stage-methods.spec.ts diff --git a/.changeset/remove-authoritative-lifecycle-billing.md b/.changeset/remove-authoritative-lifecycle-billing.md new file mode 100644 index 00000000..cd387535 --- /dev/null +++ b/.changeset/remove-authoritative-lifecycle-billing.md @@ -0,0 +1,7 @@ +--- +"@outlit/browser": major +"@outlit/core": major +"@outlit/node": major +--- + +Remove the manual `user.activate()`, `user.engaged()`, `user.inactive()`, `customer.trialing()`, `customer.paid()`, and `customer.churned()` APIs together with the `StageEvent`, `BillingEvent`, `ExplicitJourneyStage`, `BillingStatus`, and `CustomerIdentifier` ingest types. Send ordinary identity and product events with `identify()` and `track()`; Outlit Core derives activation from the customer-selected ordinary activation event, derives engagement and inactivity from activity, and receives billing status from verified integrations. diff --git a/README.md b/README.md index 646c309a..03341c7e 100644 --- a/README.md +++ b/README.md @@ -89,17 +89,15 @@ outlit.track('button_clicked', { page: '/homepage', }) -// Mark billing status on a customer -outlit.customer.trialing({ - customerId: 'cust_123', // Your app's account/workspace/customer ID - properties: { plan: 'pro' }, -}) +// Track meaningful product activity. Core derives lifecycle stages from +// ordinary events, including your selected activation event. +outlit.track('onboarding_completed', { flow: 'self_serve' }) ``` #### Using the singleton API ```typescript -import { init, track, user, customer } from '@outlit/browser' +import { init, track, user } from '@outlit/browser' // Initialize once at app startup init({ publicKey: 'pk_xxx' }) @@ -110,10 +108,7 @@ user().identify({ email: 'user@example.com', customerId: 'cust_123', // Your app's account/workspace/customer ID }) -customer().paid({ - customerId: 'cust_123', // Your app's account/workspace/customer ID - properties: { plan: 'pro' }, -}) +track('subscription_upgraded', { plan: 'pro' }) ``` #### Using with React @@ -132,10 +127,10 @@ function App() { // Use in components function MyComponent() { - const { track, user } = useOutlit() + const { track } = useOutlit() return ( - ) @@ -173,9 +168,11 @@ outlit.user.identify({ customerTraits: { plan: 'pro' }, }) -// Mark customer billing status -outlit.customer.paid({ - customerId: 'cust_123', // Your app's account/workspace/customer ID +// Track ordinary product events. Billing status comes from verified +// integrations such as Stripe, not authoritative SDK commands. +outlit.track({ + customerId: 'cust_123', + eventName: 'subscription_upgraded', properties: { plan: 'pro' }, }) diff --git a/crates/outlit/CHANGELOG.md b/crates/outlit/CHANGELOG.md index 94aa5bba..ec16ba86 100644 --- a/crates/outlit/CHANGELOG.md +++ b/crates/outlit/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Removed + +- Remove manual lifecycle and billing builders and their `stage`/`billing` payload variants. Use ordinary `track()` events for product activity and verified integrations for billing state. + ## [0.2.2](https://github.com/OutlitAI/outlit-sdk/compare/outlit-v0.2.1...outlit-v0.2.2) - 2026-04-14 ### Other diff --git a/crates/outlit/README.md b/crates/outlit/README.md index 009496be..f9983edc 100644 --- a/crates/outlit/README.md +++ b/crates/outlit/README.md @@ -42,16 +42,10 @@ async fn main() -> Result<(), outlit::Error> { .send() .await?; - // User journey stages - client.user().activate(email("user@example.com")) - .property("onboarding_completed", true) - .send() - .await?; - - // Customer billing - client.customer().paid("acme.com") - .customer_id("cust_123") - .stripe_customer_id("cus_xxx") + // Track the ordinary event selected as your activation signal. + // Outlit Core derives activation from this event. + client.track("onboarding_completed", email("user@example.com")) + .property("flow", "self_serve") .send() .await?; @@ -119,30 +113,13 @@ client.identify(email("user@example.com")) .await?; ``` -### Activation - -```rust -client.user().activate(email("...")).send().await?; -``` - -Outlit handles engagement and inactivity automatically from tracked product activity. +### Lifecycle and billing -### Customer Billing - -```rust -client.customer().trialing("domain.com").send().await?; -client.customer().paid("domain.com") - .customer_id("cust_123") - .stripe_customer_id("cus_xxx") - .send() - .await?; -client.customer().churned("domain.com") - .property("reason", "pricing") - .send() - .await?; -``` +Use `track()` for product activity. Outlit Core derives activation from the customer-selected +ordinary activation event and derives engagement and inactivity from activity. Billing status +comes from verified integrations such as Stripe. -### Lifecycle +### Client lifecycle ```rust // Force flush pending events diff --git a/crates/outlit/src/builders.rs b/crates/outlit/src/builders.rs index fe27f911..eeab6503 100644 --- a/crates/outlit/src/builders.rs +++ b/crates/outlit/src/builders.rs @@ -1,9 +1,6 @@ //! Event builders for fluent API. -use crate::types::{ - BillingEventData, BillingStatus, CustomEventData, IdentifyEventData, JourneyStage, - StageEventData, TrackerEvent, -}; +use crate::types::{CustomEventData, IdentifyEventData, TrackerEvent}; use crate::{Email, Fingerprint, UserId}; use serde_json::{json, Value}; use std::collections::HashMap; @@ -248,157 +245,6 @@ impl IdentifyBuilder { } } -// ============================================ -// STAGE BUILDER -// ============================================ - -/// Builder for stage events. -#[derive(Debug)] -pub struct StageBuilder { - stage: JourneyStage, - identity: Identity, - additional_email: Option, - additional_user_id: Option, - additional_fingerprint: Option, - properties: HashMap, -} - -impl StageBuilder { - pub(crate) fn new(stage: JourneyStage, identity: impl Into) -> Self { - Self { - stage, - identity: identity.into(), - additional_email: None, - additional_user_id: None, - additional_fingerprint: None, - properties: HashMap::new(), - } - } - - /// Add email (if identity was user_id or fingerprint). - pub fn email(mut self, email: impl Into) -> Self { - self.additional_email = Some(email.into()); - self - } - - /// Add user_id (if identity was email or fingerprint). - pub fn user_id(mut self, user_id: impl Into) -> Self { - self.additional_user_id = Some(user_id.into()); - self - } - - /// Add fingerprint (device identifier) to link this event to a device. - pub fn fingerprint(mut self, fingerprint: impl Into) -> Self { - self.additional_fingerprint = Some(fingerprint.into()); - self - } - - /// Add a property. - pub fn property(mut self, key: impl Into, value: impl Into) -> Self { - self.properties.insert(key.into(), value.into()); - self - } - - /// Build the event. - pub(crate) fn build(self) -> TrackerEvent { - let email = self - .identity - .email() - .map(String::from) - .or(self.additional_email); - let user_id = self - .identity - .user_id() - .map(String::from) - .or(self.additional_user_id); - let fingerprint = self - .identity - .fingerprint() - .map(String::from) - .or(self.additional_fingerprint); - - let mut properties = self.properties; - // Include identity in properties for server-side resolution - properties.insert("__email".into(), json!(email)); - properties.insert("__userId".into(), json!(user_id)); - properties.insert("__fingerprint".into(), json!(fingerprint)); - - TrackerEvent::Stage(StageEventData { - timestamp: now_ms(), - url: server_url(email.as_deref(), user_id.as_deref(), fingerprint.as_deref()), - path: "/".into(), - stage: self.stage, - properties: if properties.is_empty() { - None - } else { - Some(properties) - }, - }) - } -} - -// ============================================ -// BILLING BUILDER -// ============================================ - -/// Builder for billing events. -#[derive(Debug)] -pub struct BillingBuilder { - status: BillingStatus, - domain: String, - customer_id: Option, - stripe_customer_id: Option, - properties: HashMap, -} - -impl BillingBuilder { - pub(crate) fn new(status: BillingStatus, domain: impl Into) -> Self { - Self { - status, - domain: domain.into(), - customer_id: None, - stripe_customer_id: None, - properties: HashMap::new(), - } - } - - /// Set customer ID. - pub fn customer_id(mut self, id: impl Into) -> Self { - self.customer_id = Some(id.into()); - self - } - - /// Set Stripe customer ID. - pub fn stripe_customer_id(mut self, id: impl Into) -> Self { - self.stripe_customer_id = Some(id.into()); - self - } - - /// Add a property. - pub fn property(mut self, key: impl Into, value: impl Into) -> Self { - self.properties.insert(key.into(), value.into()); - self - } - - /// Build the event. - pub(crate) fn build(self) -> TrackerEvent { - TrackerEvent::Billing(BillingEventData { - timestamp: now_ms(), - url: format!("server://{}", self.domain), - path: "/".into(), - status: self.status, - customer_id: self.customer_id, - stripe_customer_id: self.stripe_customer_id, - domain: Some(self.domain), - properties: if self.properties.is_empty() { - None - } else { - Some(self.properties) - }, - }) - } -} - #[cfg(test)] mod tests { use super::*; @@ -518,48 +364,4 @@ mod tests { panic!("Expected identify event"); } } - - #[test] - fn test_stage_builder() { - let event = StageBuilder::new(JourneyStage::Activated, email("user@example.com")) - .property("source", "onboarding") - .build(); - - if let TrackerEvent::Stage(data) = event { - assert!(matches!(data.stage, JourneyStage::Activated)); - } else { - panic!("Expected stage event"); - } - } - - #[test] - fn test_stage_builder_with_fingerprint_identity() { - let event = - StageBuilder::new(JourneyStage::Activated, fingerprint("device_abc123")).build(); - - if let TrackerEvent::Stage(data) = event { - assert!(matches!(data.stage, JourneyStage::Activated)); - let props = data.properties.unwrap(); - assert_eq!(props.get("__fingerprint").unwrap(), "device_abc123"); - } else { - panic!("Expected stage event"); - } - } - - #[test] - fn test_billing_builder() { - let event = BillingBuilder::new(BillingStatus::Paid, "acme.com") - .customer_id("cust_123") - .stripe_customer_id("cus_xxx") - .property("plan", "enterprise") - .build(); - - if let TrackerEvent::Billing(data) = event { - assert!(matches!(data.status, BillingStatus::Paid)); - assert_eq!(data.domain, Some("acme.com".into())); - assert_eq!(data.customer_id, Some("cust_123".into())); - } else { - panic!("Expected billing event"); - } - } } diff --git a/crates/outlit/src/client.rs b/crates/outlit/src/client.rs index fb06587c..680ef183 100644 --- a/crates/outlit/src/client.rs +++ b/crates/outlit/src/client.rs @@ -1,10 +1,10 @@ //! Outlit client implementation. -use crate::builders::{BillingBuilder, IdentifyBuilder, StageBuilder, TrackBuilder}; +use crate::builders::{IdentifyBuilder, TrackBuilder}; use crate::config::{Config, OutlitBuilder}; use crate::queue::EventQueue; use crate::transport::HttpTransport; -use crate::types::{BillingStatus, IngestPayload, JourneyStage, SourceType}; +use crate::types::{IngestPayload, SourceType}; use crate::{Email, Error, Fingerprint, UserId}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -203,24 +203,6 @@ impl Outlit { } } - // ============================================ - // USER STAGES - // ============================================ - - /// User journey stage methods. - pub fn user(&self) -> UserMethods<'_> { - UserMethods { client: self } - } - - // ============================================ - // CUSTOMER BILLING - // ============================================ - - /// Customer billing methods. - pub fn customer(&self) -> CustomerMethods<'_> { - CustomerMethods { client: self } - } - // ============================================ // LIFECYCLE // ============================================ @@ -370,18 +352,6 @@ impl BuildEvent for IdentifyBuilder { } } -impl BuildEvent for StageBuilder { - fn build(self) -> crate::types::TrackerEvent { - self.build() - } -} - -impl BuildEvent for BillingBuilder { - fn build(self) -> crate::types::TrackerEvent { - self.build() - } -} - /// Sendable track event builder. pub struct SendableTrack<'a> { builder: TrackBuilder, @@ -461,203 +431,3 @@ impl<'a> SendableIdentify<'a> { self.client.enqueue_and_maybe_flush(self.builder).await } } - -/// Sendable stage event builder. -pub struct SendableStage<'a> { - builder: StageBuilder, - client: &'a Outlit, -} - -impl<'a> SendableStage<'a> { - /// Add email. - pub fn email(mut self, email: impl Into) -> Self { - self.builder = self.builder.email(email); - self - } - - /// Add user_id. - pub fn user_id(mut self, user_id: impl Into) -> Self { - self.builder = self.builder.user_id(user_id); - self - } - - /// Add fingerprint (device identifier). - pub fn fingerprint(mut self, fingerprint: impl Into) -> Self { - self.builder = self.builder.fingerprint(fingerprint); - self - } - - /// Add a property. - pub fn property(mut self, key: impl Into, value: impl Into) -> Self { - self.builder = self.builder.property(key, value); - self - } - - /// Send the event. - pub async fn send(self) -> Result<(), Error> { - self.client.enqueue_and_maybe_flush(self.builder).await - } -} - -/// Sendable billing event builder. -pub struct SendableBilling<'a> { - builder: BillingBuilder, - client: &'a Outlit, -} - -impl<'a> SendableBilling<'a> { - /// Set customer ID. - pub fn customer_id(mut self, id: impl Into) -> Self { - self.builder = self.builder.customer_id(id); - self - } - - /// Set Stripe customer ID. - pub fn stripe_customer_id(mut self, id: impl Into) -> Self { - self.builder = self.builder.stripe_customer_id(id); - self - } - - /// Add a property. - pub fn property(mut self, key: impl Into, value: impl Into) -> Self { - self.builder = self.builder.property(key, value); - self - } - - /// Send the event. - pub async fn send(self) -> Result<(), Error> { - self.client.enqueue_and_maybe_flush(self.builder).await - } -} - -// ============================================ -// NAMESPACE METHODS -// ============================================ - -/// User journey stage methods. -pub struct UserMethods<'a> { - client: &'a Outlit, -} - -impl<'a> UserMethods<'a> { - /// Mark user as activated. - pub fn activate(&self, identity: impl Into) -> SendableStage<'a> { - SendableStage { - builder: StageBuilder::new(JourneyStage::Activated, identity.into()), - client: self.client, - } - } - - /// Mark user as activated by user_id. - pub fn activate_by_user_id(&self, identity: impl Into) -> SendableStage<'a> { - SendableStage { - builder: StageBuilder::new(JourneyStage::Activated, identity.into()), - client: self.client, - } - } - - /// Mark user as activated by fingerprint. - pub fn activate_by_fingerprint(&self, identity: impl Into) -> SendableStage<'a> { - SendableStage { - builder: StageBuilder::new(JourneyStage::Activated, identity.into()), - client: self.client, - } - } - - /// Deprecated: Outlit derives engaged from tracked activity. - #[deprecated( - note = "Outlit derives ENGAGED from tracked activity. Keep tracking product activity and only send activation manually with user().activate()." - )] - pub fn engaged(&self, identity: impl Into) -> SendableStage<'a> { - SendableStage { - builder: StageBuilder::new(JourneyStage::Engaged, identity.into()), - client: self.client, - } - } - - /// Deprecated: Outlit derives engaged from tracked activity. - #[deprecated( - note = "Outlit derives ENGAGED from tracked activity. Keep tracking product activity and only send activation manually with user().activate_by_user_id()." - )] - pub fn engaged_by_user_id(&self, identity: impl Into) -> SendableStage<'a> { - SendableStage { - builder: StageBuilder::new(JourneyStage::Engaged, identity.into()), - client: self.client, - } - } - - /// Deprecated: Outlit derives engaged from tracked activity. - #[deprecated( - note = "Outlit derives ENGAGED from tracked activity. Keep tracking product activity and only send activation manually with user().activate_by_fingerprint()." - )] - pub fn engaged_by_fingerprint(&self, identity: impl Into) -> SendableStage<'a> { - SendableStage { - builder: StageBuilder::new(JourneyStage::Engaged, identity.into()), - client: self.client, - } - } - - /// Deprecated: Outlit derives inactive from tracked activity. - #[deprecated( - note = "Outlit derives INACTIVE from tracked activity. Keep tracking product activity and only send activation manually with user().activate()." - )] - pub fn inactive(&self, identity: impl Into) -> SendableStage<'a> { - SendableStage { - builder: StageBuilder::new(JourneyStage::Inactive, identity.into()), - client: self.client, - } - } - - /// Deprecated: Outlit derives inactive from tracked activity. - #[deprecated( - note = "Outlit derives INACTIVE from tracked activity. Keep tracking product activity and only send activation manually with user().activate_by_user_id()." - )] - pub fn inactive_by_user_id(&self, identity: impl Into) -> SendableStage<'a> { - SendableStage { - builder: StageBuilder::new(JourneyStage::Inactive, identity.into()), - client: self.client, - } - } - - /// Deprecated: Outlit derives inactive from tracked activity. - #[deprecated( - note = "Outlit derives INACTIVE from tracked activity. Keep tracking product activity and only send activation manually with user().activate_by_fingerprint()." - )] - pub fn inactive_by_fingerprint(&self, identity: impl Into) -> SendableStage<'a> { - SendableStage { - builder: StageBuilder::new(JourneyStage::Inactive, identity.into()), - client: self.client, - } - } -} - -/// Customer billing methods. -pub struct CustomerMethods<'a> { - client: &'a Outlit, -} - -impl<'a> CustomerMethods<'a> { - /// Mark customer as trialing. - pub fn trialing(&self, domain: impl Into) -> SendableBilling<'a> { - SendableBilling { - builder: BillingBuilder::new(BillingStatus::Trialing, domain), - client: self.client, - } - } - - /// Mark customer as paid. - pub fn paid(&self, domain: impl Into) -> SendableBilling<'a> { - SendableBilling { - builder: BillingBuilder::new(BillingStatus::Paid, domain), - client: self.client, - } - } - - /// Mark customer as churned. - pub fn churned(&self, domain: impl Into) -> SendableBilling<'a> { - SendableBilling { - builder: BillingBuilder::new(BillingStatus::Churned, domain), - client: self.client, - } - } -} diff --git a/crates/outlit/src/lib.rs b/crates/outlit/src/lib.rs index f87379df..0a602ee2 100644 --- a/crates/outlit/src/lib.rs +++ b/crates/outlit/src/lib.rs @@ -30,15 +30,10 @@ mod queue; mod transport; pub mod types; -pub use client::{ - CustomerMethods, Outlit, SendableBilling, SendableIdentify, SendableStage, SendableTrack, - UserMethods, -}; +pub use client::{Outlit, SendableIdentify, SendableTrack}; pub use config::{Config, OutlitBuilder}; pub use error::Error; -pub use types::{ - BillingStatus, IngestPayload, IngestResponse, JourneyStage, SourceType, TrackerEvent, -}; +pub use types::{IngestPayload, IngestResponse, SourceType, TrackerEvent}; // Identity helpers diff --git a/crates/outlit/src/types.rs b/crates/outlit/src/types.rs index 58a15e0d..c8db352b 100644 --- a/crates/outlit/src/types.rs +++ b/crates/outlit/src/types.rs @@ -10,24 +10,6 @@ pub enum SourceType { Server, } -/// Journey stage values. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum JourneyStage { - Activated, - Engaged, - Inactive, -} - -/// Billing status values. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum BillingStatus { - Trialing, - Paid, - Churned, -} - /// Custom event data. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -57,36 +39,6 @@ pub struct IdentifyEventData { pub traits: Option>, } -/// Stage event data. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct StageEventData { - pub timestamp: i64, - pub url: String, - pub path: String, - pub stage: JourneyStage, - #[serde(skip_serializing_if = "Option::is_none")] - pub properties: Option>, -} - -/// Billing event data. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BillingEventData { - pub timestamp: i64, - pub url: String, - pub path: String, - pub status: BillingStatus, - #[serde(skip_serializing_if = "Option::is_none")] - pub customer_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub stripe_customer_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub domain: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub properties: Option>, -} - /// All event types. #[derive(Debug, Clone, Serialize)] #[serde(tag = "type", rename_all = "camelCase")] @@ -95,10 +47,6 @@ pub enum TrackerEvent { Custom(CustomEventData), #[serde(rename = "identify")] Identify(IdentifyEventData), - #[serde(rename = "stage")] - Stage(StageEventData), - #[serde(rename = "billing")] - Billing(BillingEventData), } /// Payload sent to the ingest API. @@ -204,44 +152,6 @@ mod tests { assert!(!json_str.contains("fingerprint")); } - #[test] - fn test_stage_event_serialization() { - let event = TrackerEvent::Stage(StageEventData { - timestamp: 1706400000000, - url: "server://user@example.com".into(), - path: "/".into(), - stage: JourneyStage::Activated, - properties: None, - }); - - let json = serde_json::to_value(&event).unwrap(); - - assert_eq!(json["type"], "stage"); - assert!(json.get("eventName").is_none()); - assert_eq!(json["stage"], "activated"); - } - - #[test] - fn test_billing_event_camel_case() { - let event = TrackerEvent::Billing(BillingEventData { - timestamp: 1706400000000, - url: "server://acme.com".into(), - path: "/".into(), - status: BillingStatus::Paid, - customer_id: Some("cust_123".into()), - stripe_customer_id: Some("cus_xxx".into()), - domain: Some("acme.com".into()), - properties: None, - }); - - let json = serde_json::to_value(&event).unwrap(); - - assert_eq!(json["type"], "billing"); - assert_eq!(json["status"], "paid"); - assert_eq!(json["customerId"], "cust_123"); // camelCase - assert_eq!(json["stripeCustomerId"], "cus_xxx"); // camelCase - } - #[test] fn test_optional_fields_omitted() { let event = TrackerEvent::Custom(CustomEventData { diff --git a/crates/outlit/tests/integration.rs b/crates/outlit/tests/integration.rs index 0eb00b51..d9863876 100644 --- a/crates/outlit/tests/integration.rs +++ b/crates/outlit/tests/integration.rs @@ -69,87 +69,6 @@ async fn test_identify_sends_correct_payload() { client.flush().await.unwrap(); } -#[tokio::test] -#[allow(deprecated)] -async fn test_stage_events() { - let mock_server = MockServer::start().await; - - Mock::given(method("POST")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "success": true, - "processed": 1 - }))) - .expect(3) - .mount(&mock_server) - .await; - - let client = Outlit::builder("pk_test") - .api_host(mock_server.uri()) - .max_batch_size(1) // Flush after each event - .build() - .unwrap(); - - client - .user() - .activate(email("user@test.com")) - .send() - .await - .unwrap(); - - client - .user() - .engaged(email("user@test.com")) - .send() - .await - .unwrap(); - - client - .user() - .inactive(email("user@test.com")) - .send() - .await - .unwrap(); -} - -#[tokio::test] -async fn test_billing_events() { - let mock_server = MockServer::start().await; - - Mock::given(method("POST")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "success": true, - "processed": 1 - }))) - .expect(3) - .mount(&mock_server) - .await; - - let client = Outlit::builder("pk_test") - .api_host(mock_server.uri()) - .max_batch_size(1) - .build() - .unwrap(); - - client.customer().trialing("acme.com").send().await.unwrap(); - - client - .customer() - .paid("acme.com") - .customer_id("cust_123") - .stripe_customer_id("cus_xxx") - .send() - .await - .unwrap(); - - client - .customer() - .churned("acme.com") - .property("reason", "pricing") - .send() - .await - .unwrap(); -} - /// Custom responder that counts calls struct CountingResponder { counter: Arc, @@ -582,33 +501,3 @@ async fn test_identify_with_fingerprint_links_device() { client.flush().await.unwrap(); } - -#[tokio::test] -async fn test_stage_with_fingerprint() { - let mock_server = MockServer::start().await; - - Mock::given(method("POST")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "success": true, - "processed": 1 - }))) - .expect(1) - .mount(&mock_server) - .await; - - let client = Outlit::builder("pk_test") - .api_host(mock_server.uri()) - .flush_interval(Duration::from_secs(100)) - .build() - .unwrap(); - - // Stage event with fingerprint identity - client - .user() - .activate_by_fingerprint(fingerprint("device_abc123")) - .send() - .await - .unwrap(); - - client.flush().await.unwrap(); -} diff --git a/crates/outlit/tests/payload_compatibility.rs b/crates/outlit/tests/payload_compatibility.rs index 48ff941f..09a3e4f9 100644 --- a/crates/outlit/tests/payload_compatibility.rs +++ b/crates/outlit/tests/payload_compatibility.rs @@ -3,10 +3,7 @@ //! These tests serialize events and verify the JSON structure matches //! what the server expects (based on TypeScript types). -use outlit::types::{ - BillingEventData, BillingStatus, CustomEventData, IdentifyEventData, JourneyStage, - StageEventData, -}; +use outlit::types::{CustomEventData, IdentifyEventData}; use outlit::{IngestPayload, SourceType, TrackerEvent}; use serde_json::json; @@ -75,45 +72,6 @@ fn test_identify_event_with_fingerprint_json_structure() { assert_eq!(json["userId"], "usr_123"); } -#[test] -fn test_stage_event_json_structure() { - let event = TrackerEvent::Stage(StageEventData { - timestamp: 1706400000000, - url: "server://user@test.com".into(), - path: "/".into(), - stage: JourneyStage::Activated, - properties: None, - }); - - let json = serde_json::to_value(&event).unwrap(); - - assert_eq!(json["type"], "stage"); - assert!(json.get("eventName").is_none()); - assert_eq!(json["stage"], "activated"); // lowercase enum value -} - -#[test] -fn test_billing_event_json_structure() { - let event = TrackerEvent::Billing(BillingEventData { - timestamp: 1706400000000, - url: "server://acme.com".into(), - path: "/".into(), - status: BillingStatus::Paid, - customer_id: Some("cust_123".into()), - stripe_customer_id: Some("cus_xxx".into()), - domain: Some("acme.com".into()), - properties: None, - }); - - let json = serde_json::to_value(&event).unwrap(); - - assert_eq!(json["type"], "billing"); - assert_eq!(json["status"], "paid"); // lowercase enum value - assert_eq!(json["customerId"], "cust_123"); // camelCase - assert_eq!(json["stripeCustomerId"], "cus_xxx"); // camelCase - assert!(json.get("customer_id").is_none()); // snake_case should NOT exist -} - #[test] fn test_ingest_payload_json_structure() { let payload = IngestPayload { diff --git a/docs/api-reference/ingest.mdx b/docs/api-reference/ingest.mdx index 3d854b0c..e1f61500 100644 --- a/docs/api-reference/ingest.mdx +++ b/docs/api-reference/ingest.mdx @@ -38,7 +38,7 @@ POST https://app.outlit.ai/api/i/v1/{publicKey}/events ### Body Parameters - Unique identifier for the visitor (UUID format). Required for browser (`client`) tracking. For server-side tracking (`server` source), this can be omitted; identity or attribution is derived from `email`, `userId`, `fingerprint`, `customerId`, or billing identifiers in the events. + Unique identifier for the visitor (UUID format). Required for browser (`client`) tracking. For server-side tracking (`server` source), this can be omitted; identity or attribution is derived from `email`, `userId`, `fingerprint`, or `customerId` in the events. @@ -341,87 +341,6 @@ Track calendar booking from embedded widgets (Cal.com, Calendly). --- -### Stage Event - -Track explicit activation milestones. Engaged and Inactive are derived automatically from tracked product activity, but the ingest API continues to accept those values for older SDKs and direct API callers. - -```json -{ - "type": "stage", - "url": "https://example.com/onboarding", - "path": "/onboarding", - "stage": "activated", - "properties": { - "flow": "onboarding", - "step": "completed" - }, - "timestamp": 1699999999999 -} -``` - - - Must be `"stage"`. - - - - The journey stage. Use `"activated"` for new integrations. `"engaged"` and `"inactive"` remain accepted for compatibility. - - - - Additional context for the stage transition. - - - - The `discovered` and `signed_up` stages are automatically inferred from identify events. Only use stage events for explicit activation milestone tracking. - - ---- - -### Billing Event - -Track account billing status for custom billing systems. If Stripe is connected, billing status is handled automatically. - -```json -{ - "type": "billing", - "url": "server://cust_123", - "path": "/", - "status": "paid", - "customerId": "cust_123", - "properties": { - "plan": "pro", - "amount": 99 - }, - "timestamp": 1699999999999 -} -``` - - - Must be `"billing"`. - - - - The billing status. One of: `"trialing"`, `"paid"`, `"churned"`. - - - - Your system-owned customer/account/workspace ID. Public billing calls should use this identifier. - - - - Stripe customer identifier. Prefer `customerId` unless you are sending compatibility events that already use Stripe IDs. - - - - Additional billing context such as plan, amount, or cancellation reason. - - - - Billing events require `customerId` or `stripeCustomerId`. They target the account, not an individual contact. - - ---- - ## Response ### Success Response @@ -601,7 +520,6 @@ For server-side tracking, the `visitorId` field can be omitted. Identity is reso - **Identify events**: Use `email` and/or `userId` fields, plus optional customer context - **Custom events**: Include top-level `email`, `userId`, `fingerprint`, and/or `customerId` fields -- **Billing events**: Include `customerId` or `stripeCustomerId` ```json { @@ -625,7 +543,7 @@ For server-side tracking, the `visitorId` field can be omitted. Identity is reso ``` - Server events require identity or account attribution. Custom events need at least one of `email`, `userId`, `fingerprint`, or `customerId`; identify events need `email` or `userId`; billing events need `customerId` or `stripeCustomerId`. + Server events require identity or account attribution. Custom events need at least one of `email`, `userId`, `fingerprint`, or `customerId`; identify events need `email` or `userId`. ## Batching Best Practices diff --git a/docs/api-reference/introduction.mdx b/docs/api-reference/introduction.mdx index 298c15c2..30a9d55e 100644 --- a/docs/api-reference/introduction.mdx +++ b/docs/api-reference/introduction.mdx @@ -147,5 +147,5 @@ Access-Control-Allow-Headers: Content-Type - Send tracking events (pageviews, custom events, identify, engagement, calendar, and stage events) + Send tracking events (pageviews, custom events, identify, engagement, and calendar events) diff --git a/docs/concepts/customer-context-graph.mdx b/docs/concepts/customer-context-graph.mdx index 59a6f42c..4a9695b2 100644 --- a/docs/concepts/customer-context-graph.mdx +++ b/docs/concepts/customer-context-graph.mdx @@ -136,7 +136,7 @@ Journey stages are properties on graph nodes — structured labels that Outlit m - **Contact journey stages** (Discovered, Signed Up, Activated, Engaged, Inactive) describe where each person is in their product engagement - **Account billing statuses** (None, Trialing, Paying, Churned) describe the commercial relationship -Some stages are inferred automatically from integrations. The Outlit browser SDK auto-detects Discovered and Signed Up based on the identifiers provided. Engaged and Inactive are derived from tracked product activity. Activated is the one stage you instrument manually — since the definition of "activated" is specific to your product, you call `user.activate()` when a contact completes your onboarding or key milestone. +Some stages are inferred automatically from integrations. The Outlit browser SDK auto-detects Discovered and Signed Up based on the identifiers provided. Engaged and Inactive are derived from tracked product activity. For Activated, choose the ordinary product event that represents your product's value milestone; Outlit Core derives activation when that event is received through `track()` or a verified product integration. Billing statuses update automatically when Stripe is connected. diff --git a/docs/concepts/customer-journey.mdx b/docs/concepts/customer-journey.mdx index ce8cbf24..650c9224 100644 --- a/docs/concepts/customer-journey.mdx +++ b/docs/concepts/customer-journey.mdx @@ -42,7 +42,7 @@ Each person (contact) progresses through stages based on their product engagemen |-------|---------|--------------| | **Discovered** | Email known, hasn't signed up yet | Auto-detected by browser SDK when email is provided without userId | | **Signed Up** | Created an account | Auto-detected by browser SDK when both email and userId are provided | -| **Activated** | Completed onboarding or key milestone | Manual — call `user.activate()` | +| **Activated** | Completed onboarding or key milestone | Derived by Core from your selected ordinary activation event | | **Engaged** | Actively using the product | Auto-inferred from product activity via PostHog or your SDK (browser/server) | | **Inactive** | No activity for extended period | Auto-inferred when no activity detected for 30+ days | @@ -100,26 +100,24 @@ Outlit derives Engaged and Inactive from tracked product activity. Activity sign --- -## How do I manually mark activation? +## How is activation derived? - These examples show browser SDK usage where the user is already identified. For server-side usage, see [Node.js SDK → Activation](/tracking/server/nodejs#activation). + Identify the user and send the same ordinary activation event consistently from the browser SDK, server SDK, or a verified product integration. -### user.activate() +### Track the selected activation event -Mark a contact as activated after they complete onboarding: +Choose an ordinary event that represents the product's meaningful value moment, then track it after the action succeeds: ```typescript -outlit.user.activate({ - flow: 'onboarding', - completedSteps: ['profile', 'first_action', 'invite_team'] +outlit.track('onboarding_completed', { + flow: 'self_serve', + completedSteps: 3 }) ``` -Call this when users complete your onboarding flow, perform a key "aha moment" action, or reach your definition of activation. - -Do not call `user.engaged()` or `user.inactive()` for new integrations. Send product activity with `track()` and let Outlit derive those stages. +Configure `onboarding_completed` (or your equivalent event) as the activation event in Outlit. Core derives Activated from that ordinary event. It also derives Engaged and Inactive from the broader stream of tracked product activity. --- @@ -130,9 +128,9 @@ Each account (company) has a billing status that's separate from individual cont | Status | Meaning | How It's Set | |--------|---------|--------------| | **None** | Never had a subscription | Default | -| **Trialing** | Active trial period | Automatic (Stripe) or manual | -| **Paying** | Active paid subscription | Automatic (Stripe) or manual | -| **Churned** | Had subscription, now cancelled | Automatic (Stripe) or manual | +| **Trialing** | Active trial period | Verified billing integration | +| **Paying** | Active paid subscription | Verified billing integration | +| **Churned** | Had subscription, now cancelled | Verified billing integration | --- @@ -155,43 +153,13 @@ When a subscription status changes in Stripe, Outlit automatically updates the m --- -## How do I set billing status manually? - -For non-Stripe payment processors or custom billing logic: - -```typescript -// When a trial starts -outlit.customer.trialing({ - customerId: 'cust_123', // Your app's account/workspace/customer ID - properties: { trialEndsAt: '2025-06-15' } -}) - -// When payment succeeds -outlit.customer.paid({ - customerId: 'cust_123', // Your app's account/workspace/customer ID - properties: { plan: 'pro', amount: 99 } -}) - -// When subscription is cancelled -outlit.customer.churned({ - customerId: 'cust_123', // Your app's account/workspace/customer ID - properties: { reason: 'too_expensive' } -}) -``` - - - Billing methods target the account by `customerId`, not individual contacts. Send the same `customerId` in identify calls to link people to that account/workspace. - - ---- - ## Best practices -**Let integrations handle what they can.** Discovered and Signed Up are auto-detected by the browser SDK based on identifiers. Engaged and Inactive are derived from tracked product activity captured by the browser SDK, server SDK, and connected product integrations. Stripe handles billing status. The main stage you need to instrument manually is Activated — call `user.activate()` when users complete your definition of activation. +**Send facts, not authoritative state.** Discovered and Signed Up are auto-detected from identity. Activated is derived by Core from the customer-selected ordinary activation event. Engaged and Inactive are derived from tracked product activity captured by the browser SDK, server SDK, and verified product integrations. Verified billing integrations provide billing status. **Understand the separation.** Contact journey and account billing are independent. Jane can be Engaged while her company is on None, or Inactive while her company is Paying. Jane's activity can change independently from her company's billing status. -**Call activation at the right time.** `user.activate()` should be called when the action is confirmed complete, not when the user clicks a button. Wait for the backend to confirm success before tracking. +**Track activation at the right time.** Send the selected ordinary activation event when the action is confirmed complete, not when the user clicks a button. Wait for the backend to confirm success before tracking. **Include useful properties.** Properties help you analyze stage transitions. Include context like which onboarding flow was completed, time-to-activate, and which steps were skipped. @@ -205,7 +173,7 @@ outlit.customer.churned({ - Activated is the main stage you instrument manually by calling `user.activate()`. Discovered and Signed Up are auto-detected by the browser SDK. Engaged and Inactive are derived from tracked product activity. If Stripe is connected, billing statuses sync automatically; otherwise, set billing status manually with the customer billing methods. + No journey stage is set through an authoritative SDK command. Discovered and Signed Up are derived from identity, Activated is derived from your selected ordinary activation event, and Engaged and Inactive are derived from tracked product activity. Billing statuses sync from verified billing integrations. @@ -222,12 +190,12 @@ outlit.customer.churned({ How Outlit connects anonymous visitors to known contacts - Track stage events from your backend + Track ordinary product events from your backend Set up browser tracking - Direct API for stage events + Direct API for identify and product events diff --git a/docs/openapi.json b/docs/openapi.json index 512ec1cd..cbeceb33 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -6367,12 +6367,6 @@ }, { "$ref": "#/components/schemas/CalendarEvent" - }, - { - "$ref": "#/components/schemas/StageEvent" - }, - { - "$ref": "#/components/schemas/BillingEvent" } ] }, @@ -6569,58 +6563,6 @@ } ] }, - "StageEvent": { - "allOf": [ - { - "$ref": "#/components/schemas/BaseEventFields" - }, - { - "type": "object", - "required": ["type", "stage"], - "properties": { - "type": { - "const": "stage" - }, - "stage": { - "type": "string", - "enum": ["activated", "engaged", "inactive"] - }, - "properties": { - "$ref": "#/components/schemas/JsonObject" - } - } - } - ] - }, - "BillingEvent": { - "allOf": [ - { - "$ref": "#/components/schemas/BaseEventFields" - }, - { - "type": "object", - "required": ["type", "status"], - "properties": { - "type": { - "const": "billing" - }, - "status": { - "type": "string", - "enum": ["trialing", "paid", "churned"] - }, - "customerId": { - "type": "string" - }, - "stripeCustomerId": { - "type": "string" - }, - "properties": { - "$ref": "#/components/schemas/JsonObject" - } - } - } - ] - }, "UtmParameters": { "type": "object", "properties": { diff --git a/docs/tracking/browser/angular.mdx b/docs/tracking/browser/angular.mdx index 2d64c1f6..b9e2a85c 100644 --- a/docs/tracking/browser/angular.mdx +++ b/docs/tracking/browser/angular.mdx @@ -96,10 +96,6 @@ export class OutlitService { outlit.clearUser() } - activate(properties?: Record): void { - outlit.user().activate(properties) - } - getVisitorId(): string | null { return outlit.getInstance().getVisitorId() } @@ -374,9 +370,9 @@ export const appConfig: ApplicationConfig = { } ``` -## Activation +## Activation event -Track contact lifecycle: +Track the ordinary event selected as your activation signal after the milestone succeeds: ```typescript // src/app/components/onboarding/onboarding.component.ts @@ -395,7 +391,7 @@ export class OnboardingComponent { ) {} handleComplete(): void { - this.outlit.activate({ + this.outlit.track('onboarding_completed', { flow: 'onboarding', step: 'completed' }) @@ -405,14 +401,8 @@ export class OnboardingComponent { } ``` - - `activate()` is associated to an identified user. If called before `setUser()` or `identify()`, it is queued and sent once identity is available. - - -Use `track()` for product activity. Outlit handles engagement and inactivity automatically; see [Customer Journey](/concepts/customer-journey#automatic-engagement-and-inactivity). - - Account billing (`paid`, `churned`, `trialing`) is tracked separately. If you've connected Stripe, billing is automatic. For manual billing, see [Stages & Billing](/concepts/customer-journey). + Outlit Core derives activation from your selected ordinary event and derives engagement and inactivity from product activity. Billing comes from verified integrations such as Stripe. See [Customer Journey](/concepts/customer-journey). ## Consent Management diff --git a/docs/tracking/browser/astro.mdx b/docs/tracking/browser/astro.mdx index 52acabd2..341f3a3e 100644 --- a/docs/tracking/browser/astro.mdx +++ b/docs/tracking/browser/astro.mdx @@ -427,7 +427,7 @@ const entry = await getEntry('blog', Astro.params.slug) ``` -## Activation +## Activation event Track activation after the user completes your product milestone: @@ -442,19 +442,13 @@ Track activation after the user completes your product milestone: import outlit from '@outlit/browser' document.getElementById('complete-onboarding')?.addEventListener('click', () => { - outlit.user().activate({ flow: 'onboarding', step: 'completed' }) + outlit.track('onboarding_completed', { flow: 'onboarding', step: 'completed' }) }) ``` - - `user.activate()` requires the user to be identified first using `setUser()` or `identify()`. - - -Use `track()` for product activity. Outlit handles engagement and inactivity automatically; see [Customer Journey](/concepts/customer-journey#automatic-engagement-and-inactivity). - - Account billing (`paid`, `churned`, `trialing`) is tracked separately. If you've connected Stripe, billing is automatic. For manual billing, see [Stages & Billing](/concepts/customer-journey). + Configure this ordinary event as your activation signal. Outlit Core derives activation, engagement, and inactivity; billing comes from verified integrations such as Stripe. See [Customer Journey](/concepts/customer-journey). ## Consent Management diff --git a/docs/tracking/browser/npm.mdx b/docs/tracking/browser/npm.mdx index a8366883..edc98edf 100644 --- a/docs/tracking/browser/npm.mdx +++ b/docs/tracking/browser/npm.mdx @@ -223,26 +223,16 @@ Clear the current user identity. Call this when the user logs out. outlit.clearUser() ``` -### Activation +### Activation event -Mark the current user as activated after they complete your product's activation milestone. For immediate delivery, identify first via `setUser()` or `identify()`. - -#### `outlit.user().activate(properties?)` - -Mark the current user as activated. Typically called after a user completes onboarding or a key activation milestone. +Track the ordinary event selected as your activation signal after the milestone succeeds: ```typescript -outlit.user().activate({ flow: 'onboarding', step: 'completed' }) +outlit.track('onboarding_completed', { flow: 'onboarding', step: 'completed' }) ``` -Use `track()` for product activity. Outlit handles engagement and inactivity automatically; see [Customer Journey](/concepts/customer-journey#automatic-engagement-and-inactivity). - - - `user.activate()` is associated to an identified user. If called before `setUser()` or `identify()`, it is queued and sent once identity is available. - - - Account billing status (`paid`, `churned`, `trialing`) is tracked separately at the account level. If you've connected Stripe, this is handled automatically. For manual billing tracking from your server, see [Stages & Billing](/concepts/customer-journey). + Configure this event as your activation signal. Outlit Core derives activation, engagement, and inactivity from ordinary events. Billing status comes from verified integrations such as Stripe. See [Customer Journey](/concepts/customer-journey). ### `outlit.getInstance()` diff --git a/docs/tracking/browser/nuxt.mdx b/docs/tracking/browser/nuxt.mdx index e46703cd..34125d55 100644 --- a/docs/tracking/browser/nuxt.mdx +++ b/docs/tracking/browser/nuxt.mdx @@ -322,28 +322,22 @@ export default defineEventHandler(async (event) => { For server-side tracking, see the [Node.js integration guide](/tracking/server/nodejs). -## Activation +## Activation event Mark activation after the user completes your product's activation milestone: ```vue ``` - - `user.activate()` requires the user to be identified first using `setUser()` or `identify()`. - - -Use `track()` for product activity. Outlit handles engagement and inactivity automatically; see [Customer Journey](/concepts/customer-journey#automatic-engagement-and-inactivity). - - Account billing (`paid`, `churned`, `trialing`) is tracked separately. If you've connected Stripe, billing is automatic. For manual billing, see [Stages & Billing](/concepts/customer-journey). + Configure this ordinary event as your activation signal. Outlit Core derives activation, engagement, and inactivity; billing comes from verified integrations such as Stripe. See [Customer Journey](/concepts/customer-journey). ## Consent Management diff --git a/docs/tracking/browser/react.mdx b/docs/tracking/browser/react.mdx index a6f04cc6..c0937eab 100644 --- a/docs/tracking/browser/react.mdx +++ b/docs/tracking/browser/react.mdx @@ -434,8 +434,7 @@ function MyComponent() { identify, setUser, clearUser, - user, // User identity and activation helpers - customer, // Customer billing methods: trialing, paid, churned + user, // User identity helper getVisitorId, isInitialized, isTrackingEnabled, @@ -449,14 +448,8 @@ function MyComponent() { // Identify the user identify({ email: 'user@example.com', traits: { plan: 'pro' } }) - // User activation - user.activate({ flow: 'onboarding' }) - - // Customer billing methods (requires account identifier) - customer.paid({ - customerId: 'cust_123', // Your app's account/workspace/customer ID - properties: { plan: 'pro' } - }) + // Track the ordinary event selected as your activation signal + track('onboarding_completed', { flow: 'onboarding' }) // Get visitor ID (null if tracking not enabled) const visitorId = getVisitorId() @@ -487,16 +480,8 @@ function MyComponent() { - User identity and activation namespace: + User identity namespace: - `user.identify(options)` - Identify the user - - `user.activate(properties?)` - Mark user as activated - - - - Customer billing methods namespace. Use `customerId` for public billing calls: - - `customer.trialing(options)` - Mark account as trialing - - `customer.paid(options)` - Mark account as paid - - `customer.churned(options)` - Mark account as churned @@ -562,9 +547,9 @@ function LoginForm() { } ``` -## Activation +## Activation event -Mark users as activated when they complete your product's activation milestone: +Track the ordinary event selected as your activation signal after the milestone succeeds: ```tsx 'use client' @@ -572,24 +557,18 @@ Mark users as activated when they complete your product's activation milestone: import { useOutlit } from '@outlit/browser/react' function OnboardingComplete() { - const { user } = useOutlit() + const { track } = useOutlit() const handleComplete = () => { - user.activate({ flow: 'onboarding', step: 'completed' }) + track('onboarding_completed', { flow: 'onboarding', step: 'completed' }) } return } ``` -Use `track()` for product activity. Outlit handles engagement and inactivity automatically; see [Customer Journey](/concepts/customer-journey#automatic-engagement-and-inactivity). - - - `user.activate()` requires the user to be identified first. Use `setUser()`, `identify()`, or the `user` prop before calling it. - - - Account billing status (`customer.paid`, `customer.churned`, `customer.trialing`) is tracked separately at the account level. If you've connected Stripe, this is handled automatically. For manual billing tracking from your server, see [Stages & Billing](/concepts/customer-journey). + Configure this event as your activation signal. Outlit Core derives lifecycle stages from ordinary events, and billing status comes from verified integrations such as Stripe. See [Customer Journey](/concepts/customer-journey). ## Framework Examples diff --git a/docs/tracking/browser/script.mdx b/docs/tracking/browser/script.mdx index cec64ed8..5adbb9df 100644 --- a/docs/tracking/browser/script.mdx +++ b/docs/tracking/browser/script.mdx @@ -25,9 +25,8 @@ Add this snippet before the closing `` tag. This creates a lightweight st } } stub(o,"",["init","track","identify","enableTracking","disableTracking","isTrackingEnabled","getVisitorId","setUser","clearUser"]); - o.user=o.user||{};o.customer=o.customer||{}; - stub(o.user,"user",["identify","activate","engaged","inactive"]); - stub(o.customer,"customer",["trialing","paid","churned"]); + o.user=o.user||{}; + stub(o.user,"user",["identify"]); var s=d.createElement("script");s.async=1;s.src=src; s.dataset.publicKey=key;if(auto!==undefined)s.dataset.autoTrack=auto; (d.body||d.head).appendChild(s); @@ -105,9 +104,8 @@ If you're using a consent management platform (CMP) or need to wait for user con } } stub(o,"",["init","track","identify","enableTracking","disableTracking","isTrackingEnabled","getVisitorId","setUser","clearUser"]); - o.user=o.user||{};o.customer=o.customer||{}; - stub(o.user,"user",["identify","activate","engaged","inactive"]); - stub(o.customer,"customer",["trialing","paid","churned"]); + o.user=o.user||{}; + stub(o.user,"user",["identify"]); var s=d.createElement("script");s.async=1;s.src=src; s.dataset.publicKey=key;if(auto!==undefined)s.dataset.autoTrack=auto; (d.body||d.head).appendChild(s); @@ -298,19 +296,17 @@ Call when the user logs out: window.outlit.clearUser() ``` -### Activation +### Activation event -Mark the current user as activated after they complete your product's activation milestone. The user must be identified first via `setUser()` or `identify()`. +Track the ordinary event selected as your activation signal after the milestone succeeds: ```javascript // After user completes onboarding -window.outlit.user.activate({ flow: 'onboarding' }) +window.outlit.track('onboarding_completed', { flow: 'onboarding' }) ``` -Use `track()` for product activity. Outlit handles engagement and inactivity automatically; see [Customer Journey](/concepts/customer-journey#automatic-engagement-and-inactivity). - - Account billing status (`paid`, `churned`, `trialing`) is tracked separately at the account level. If you've connected Stripe, this is handled automatically. For manual billing tracking, see [Stages & Billing](/concepts/customer-journey). + Configure this event as your activation signal. Outlit Core derives lifecycle stages from ordinary events, and billing status comes from verified integrations such as Stripe. See [Customer Journey](/concepts/customer-journey). ### Get Visitor ID @@ -340,9 +336,8 @@ The IIFE snippet creates stub methods that queue all tracking calls until the SD } } stub(o,"",["init","track","identify","enableTracking","disableTracking","isTrackingEnabled","getVisitorId","setUser","clearUser"]); - o.user=o.user||{};o.customer=o.customer||{}; - stub(o.user,"user",["identify","activate","engaged","inactive"]); - stub(o.customer,"customer",["trialing","paid","churned"]); + o.user=o.user||{}; + stub(o.user,"user",["identify"]); var s=d.createElement("script");s.async=1;s.src=src; s.dataset.publicKey=key;if(auto!==undefined)s.dataset.autoTrack=auto; (d.body||d.head).appendChild(s); @@ -414,9 +409,8 @@ For performance, events are batched and sent: } } stub(o,"",["init","track","identify","enableTracking","disableTracking","isTrackingEnabled","getVisitorId","setUser","clearUser"]); - o.user=o.user||{};o.customer=o.customer||{}; - stub(o.user,"user",["identify","activate","engaged","inactive"]); - stub(o.customer,"customer",["trialing","paid","churned"]); + o.user=o.user||{}; + stub(o.user,"user",["identify"]); var s=d.createElement("script");s.async=1;s.src=src; s.dataset.publicKey=key;if(auto!==undefined)s.dataset.autoTrack=auto; (d.body||d.head).appendChild(s); diff --git a/docs/tracking/browser/sveltekit.mdx b/docs/tracking/browser/sveltekit.mdx index 349f1331..c40cc3ed 100644 --- a/docs/tracking/browser/sveltekit.mdx +++ b/docs/tracking/browser/sveltekit.mdx @@ -273,22 +273,15 @@ export const POST: RequestHandler = async ({ request }) => { ``` -## Activation +## Activation event Track activation after the user completes your product milestone: ```svelte @@ -297,14 +290,8 @@ Track activation after the user completes your product milestone: ``` - - `user.activate()` requires the user to be identified first using `setUser()` or `identify()`. - - -Use `track()` for product activity. Outlit handles engagement and inactivity automatically; see [Customer Journey](/concepts/customer-journey#automatic-engagement-and-inactivity). - - Account billing (`paid`, `churned`, `trialing`) is tracked separately. If you've connected Stripe, billing is automatic. For manual billing, see [Stages & Billing](/concepts/customer-journey). + Configure `first_project_created` as your ordinary activation event if it represents the value milestone. Outlit Core derives lifecycle stages; billing comes from verified integrations such as Stripe. See [Customer Journey](/concepts/customer-journey). ## Consent Management diff --git a/docs/tracking/browser/vue.mdx b/docs/tracking/browser/vue.mdx index d1110ccf..8a6b1eca 100644 --- a/docs/tracking/browser/vue.mdx +++ b/docs/tracking/browser/vue.mdx @@ -271,8 +271,7 @@ const { identify, setUser, clearUser, - user, // User identity and activation helpers - customer, // Customer billing methods: trialing, paid, churned + user, // User identity helper getVisitorId, isInitialized, isTrackingEnabled, @@ -286,14 +285,8 @@ track('button_clicked', { buttonId: 'cta' }) // Identify the user identify({ email: 'user@example.com', traits: { plan: 'pro' } }) -// User activation -user.activate({ flow: 'onboarding' }) - -// Customer billing methods (requires account identifier) -customer.paid({ - customerId: 'cust_123', // Your app's account/workspace/customer ID - properties: { plan: 'pro' } -}) +// Track the ordinary event selected as your activation signal +track('onboarding_completed', { flow: 'onboarding' }) // Get visitor ID (null if tracking not enabled) const visitorId = getVisitorId() @@ -319,16 +312,8 @@ const visitorId = getVisitorId() - User identity and activation namespace: + User identity namespace: - `user.identify(options)` - Identify the user - - `user.activate(properties?)` - Mark user as activated - - - - Customer billing methods namespace. Use `customerId` for public billing calls: - - `customer.trialing(options)` - Mark account as trialing - - `customer.paid(options)` - Mark account as paid - - `customer.churned(options)` - Mark account as churned @@ -466,7 +451,7 @@ function acceptTracking() { ``` -## Activation +## Activation event Track user progression through your product lifecycle: @@ -474,10 +459,10 @@ Track user progression through your product lifecycle: @@ -488,14 +473,8 @@ function handleOnboardingComplete() { ``` - - `user.activate()` requires the user to be identified first. Use `useOutlitUser()`, `setUser()`, or `identify()` before calling it. - - -Use `track()` for product activity. Outlit handles engagement and inactivity automatically; see [Customer Journey](/concepts/customer-journey#automatic-engagement-and-inactivity). - - Account billing status (`customer.paid`, `customer.churned`, `customer.trialing`) is tracked separately at the account level. If you've connected Stripe, this is handled automatically. + Configure this event as your activation signal. Outlit Core derives lifecycle stages from ordinary events, and billing status comes from verified integrations such as Stripe. See [Customer Journey](/concepts/customer-journey). ## Vue Router Integration diff --git a/docs/tracking/how-it-works.mdx b/docs/tracking/how-it-works.mdx index 4292c0ce..42dc6c04 100644 --- a/docs/tracking/how-it-works.mdx +++ b/docs/tracking/how-it-works.mdx @@ -126,7 +126,7 @@ Journey stages are properties maintained on graph entities: - Contacts carry a journey stage: Discovered → Signed Up → Activated → Engaged → Inactive - Accounts carry a billing status: None → Trialing → Paying → Churned -The Outlit browser SDK auto-detects **Discovered** (email known, no userId) and **Signed Up** (both email and userId provided). **Engaged** and **Inactive** are derived from tracked product activity. **Activated** is the one stage you call manually — `user.activate()` — because the definition of activation is specific to your product. Billing statuses update automatically when Stripe is connected. +The Outlit browser SDK auto-detects **Discovered** (email known, no userId) and **Signed Up** (both email and userId provided). **Engaged** and **Inactive** are derived from tracked product activity. For **Activated**, select the ordinary product event that represents your value milestone; Outlit Core derives activation when that event arrives through `track()` or a verified product integration. Billing statuses update from verified integrations such as Stripe. For the full breakdown of journey stages and billing statuses, see [Customer Journey](/concepts/customer-journey). @@ -193,7 +193,7 @@ For custom integrations, the [Ingest API](/api-reference/ingest) accepts events - The browser SDK automatically captures pageviews, form submissions, engagement time, UTM attribution, and company enrichment. Identity is auto-detected from forms with email fields. You only need to manually call `identify()` for JavaScript-based auth (OAuth, magic links), `user.activate()` for activation milestones, and `track()` for custom business events. + The browser SDK automatically captures pageviews, form submissions, engagement time, UTM attribution, and company enrichment. Identity is auto-detected from forms with email fields. You only need to manually call `identify()` for JavaScript-based auth (OAuth, magic links) and `track()` for meaningful product events, including the ordinary event selected as your activation signal. diff --git a/docs/tracking/server/nodejs.mdx b/docs/tracking/server/nodejs.mdx index 46c43ae2..6744738d 100644 --- a/docs/tracking/server/nodejs.mdx +++ b/docs/tracking/server/nodejs.mdx @@ -195,64 +195,20 @@ outlit.identify({ At least one of `email` or `userId` is required for `identify()`. Customer fields are optional additions. -### Activation +### Lifecycle and billing -Mark activation after a contact completes your product's activation milestone. This method requires at least one of `fingerprint`, `email`, or `userId`. - -#### `outlit.user.activate(options)` - -Mark a contact as activated. Typically called after a user completes onboarding or a key activation milestone. +Send ordinary product facts with `track()`. For activation, choose the ordinary event that represents your product's value milestone and track it after the action succeeds: ```typescript -outlit.user.activate({ +outlit.track({ email: 'user@example.com', + eventName: 'onboarding_completed', properties: { flow: 'onboarding' } }) ``` - The `discovered` and `signed_up` stages are automatically inferred from identify calls. Use `track()` for product activity. Outlit handles engagement and inactivity automatically; see [Customer Journey](/concepts/customer-journey#automatic-engagement-and-inactivity). - - -### Account Billing Methods - -Track account billing status. These methods target the account by `customerId`, not individual contacts. If you've connected Stripe, billing is handled automatically. - -#### `outlit.customer.trialing(options)` - -Mark an account as trialing. - -```typescript -outlit.customer.trialing({ - customerId: 'cust_123', // Your app's account/workspace/customer ID - properties: { trialEndsAt: '2024-02-15' } -}) -``` - -#### `outlit.customer.paid(options)` - -Mark an account as paying. - -```typescript -outlit.customer.paid({ - customerId: 'cust_123', // Your app's account/workspace/customer ID - properties: { plan: 'pro', amount: 99 } -}) -``` - -#### `outlit.customer.churned(options)` - -Mark an account as churned. - -```typescript -outlit.customer.churned({ - customerId: 'cust_123', // Your app's account/workspace/customer ID - properties: { reason: 'cancelled_by_user' } -}) -``` - - - Billing methods target accounts by `customerId`. Contacts linked to that customer share the same billing status. See [Customer Journey](/concepts/customer-journey) for details on how contact stages and account billing work together. + Outlit Core derives activation from the customer-selected ordinary event and derives engagement and inactivity from activity. Billing status comes from verified integrations such as Stripe. See [Customer Journey](/concepts/customer-journey). ### `outlit.flush()` @@ -328,7 +284,7 @@ import { Outlit } from '@outlit/node' const outlit = new Outlit({ publicKey: process.env.OUTLIT_KEY! }) export async function POST(request: Request) { - const { userId, email, customerId, newPlan, amount } = await request.json() + const { userId, email, customerId, newPlan } = await request.json() // Your upgrade logic await upgradeSubscription(userId, newPlan) @@ -342,12 +298,6 @@ export async function POST(request: Request) { properties: { plan: newPlan } }) - // Update account billing status - outlit.customer.paid({ - customerId, - properties: { plan: newPlan, amount } - }) - // Flush immediately (serverless) await outlit.flush() @@ -385,59 +335,9 @@ export async function handler(event) { ### Stripe Webhooks - If you've connected Stripe to Outlit, billing status is handled automatically—you don't need to implement webhook handling yourself. The example below is only needed for custom billing logic. + Connect Stripe as a verified Outlit integration. Subscription state then updates account billing status automatically; do not translate Stripe webhooks into authoritative SDK billing commands. -Track payment and subscription events from Stripe: - -```typescript -import { Outlit } from '@outlit/node' -import Stripe from 'stripe' - -const outlit = new Outlit({ publicKey: process.env.OUTLIT_KEY! }) - -export async function handleStripeWebhook(event: Stripe.Event) { - switch (event.type) { - case 'checkout.session.completed': { - const session = event.data.object as Stripe.Checkout.Session - const customerId = - typeof session.customer === 'string' ? session.customer : session.customer?.id - if (!customerId) break - outlit.customer.paid({ - customerId, - properties: { - ...(session.amount_total != null - ? { amount: session.amount_total / 100 } - : {}), - ...(session.currency != null ? { currency: session.currency } : {}), - } - }) - break - } - - case 'customer.subscription.deleted': { - const subscription = event.data.object as Stripe.Subscription - const customerId = - typeof subscription.customer === 'string' - ? subscription.customer - : subscription.customer?.id - if (!customerId) break - outlit.customer.churned({ - customerId, - properties: { - ...(subscription.cancellation_details?.reason != null - ? { reason: subscription.cancellation_details.reason } - : {}), - } - }) - break - } - } - - await outlit.flush() -} -``` - ### Background Jobs (Bull, Agenda) ```typescript @@ -507,8 +407,6 @@ import { type OutlitOptions, type ServerTrackOptions, type ServerIdentifyOptions, - type StageOptions, - type BillingOptions, } from '@outlit/node' const options: OutlitOptions = { @@ -526,22 +424,6 @@ const trackOptions: ServerTrackOptions = { } outlit.track(trackOptions) - -// Contact stage options -const stageOptions: StageOptions = { - email: 'user@test.com', - properties: { flow: 'onboarding' } -} - -outlit.user.activate(stageOptions) - -// Account billing options -const billingOptions: BillingOptions = { - customerId: 'cust_123', // Your app's account/workspace/customer ID - properties: { plan: 'pro' } -} - -outlit.customer.paid(billingOptions) ``` ## Next Steps diff --git a/docs/tracking/server/rust.mdx b/docs/tracking/server/rust.mdx index 22ff02cd..3eb4c842 100644 --- a/docs/tracking/server/rust.mdx +++ b/docs/tracking/server/rust.mdx @@ -158,54 +158,18 @@ client.identify(email("user@example.com")) Add a trait to the user profile. -### Activation +### Lifecycle and billing -Mark activation after a contact completes your product's activation milestone. +Track the ordinary event selected as your activation signal after the milestone succeeds: ```rust -// Mark user as activated -client.user() - .activate(email("user@example.com")) +client.track("onboarding_completed", email("user@example.com")) .property("flow", "onboarding") .send() .await?; - -// With fingerprint -client.user() - .activate_by_fingerprint(fingerprint("device_abc123")) - .send() - .await?; ``` -Use custom track events for product activity. Outlit handles engagement and inactivity automatically; see [Customer Journey](/concepts/customer-journey#automatic-engagement-and-inactivity). - -### Account Billing Methods - -Track account billing status by domain. - -```rust -// Mark account as paid -client.customer() - .paid("acme.com") - .property("plan", "pro") - .property("amount", 99) - .send() - .await?; - -// Mark account as trialing -client.customer() - .trialing("acme.com") - .property("trial_ends", "2024-02-15") - .send() - .await?; - -// Mark account as churned -client.customer() - .churned("acme.com") - .property("reason", "cancelled") - .send() - .await?; -``` +Configure this event as your activation signal. Outlit Core derives activation, engagement, and inactivity from ordinary events. Billing status comes from verified integrations such as Stripe. See [Customer Journey](/concepts/customer-journey). ### `client.flush()` diff --git a/examples/pi-agents/README.md b/examples/pi-agents/README.md index db5c7096..69416660 100644 --- a/examples/pi-agents/README.md +++ b/examples/pi-agents/README.md @@ -17,7 +17,7 @@ The prompts are intentionally conservative. A good run may return fewer than 5 a The usage-decay command runs deterministic churn pretriage before the model starts. It reads `churn.json`, surfaces customers that match the configured usage, billing, and user-inactivity thresholds, and then asks Pi to review only those customers with the rest of the Outlit tools. -The activation-failure command also runs deterministic pretriage before the model starts. It checks user journey stages and namespaced Outlit activation stage events, then asks Pi to review the surfaced accounts with customer, timeline, fact, source, and search tools. +The activation-failure command also runs deterministic pretriage before the model starts. It checks Core-derived user journey stages and ordinary product activity, then asks Pi to review the surfaced accounts with customer, timeline, fact, source, and search tools. After evidence review, each command asks Pi to call the Slack notification tool once with a JSON-compatible `payload` object, then return the same findings in chat. Usage decay and activation failure use deterministic pretriage for candidate discovery, then follow the same notification-first workflow as the other growth agents. @@ -155,9 +155,9 @@ Edit this file when your product has a clearer definition of meaningful activity ## Activation Pretriage -`/outlit-activation-failure` uses `outlit_activation_pretriage` before the model starts. The helper scans trialing, unpaid, and early paying accounts by default, then surfaces accounts that have observed users but no activated or engaged users and no namespaced Outlit activation stage event. +`/outlit-activation-failure` uses `outlit_activation_pretriage` before the model starts. The helper scans trialing, unpaid, and early paying accounts by default, then surfaces accounts that have observed users but no users in Core-derived `ACTIVATED` or `ENGAGED` journey stages. -The SDK activation helpers emit lifecycle stage events that ingestion stores under the namespaced `stage:activated` event name. This keeps Outlit lifecycle state distinct from customer-defined product events such as `track("activated")`. +Core derives activation after the customer selects an ordinary product event—such as `onboarding_completed`—as the activation milestone. Applications send that event with `track()`; there is no authoritative SDK lifecycle command. ## Tool Scope diff --git a/examples/pi-agents/lib/activation-pretriage-data.ts b/examples/pi-agents/lib/activation-pretriage-data.ts index 8e117dda..89261731 100644 --- a/examples/pi-agents/lib/activation-pretriage-data.ts +++ b/examples/pi-agents/lib/activation-pretriage-data.ts @@ -5,7 +5,6 @@ import type { import { buildBillingScopeFilter, buildCustomerIdFilter, - normalizeEventNames, type QueryClient, queryRows, toSqlDateTime, @@ -34,7 +33,6 @@ export type EventActivationRow = { lastProductEventAt: string | null recentEventCount: number recentActiveDays: number - activationEventCount: number } type SqlParts = { @@ -151,8 +149,7 @@ function buildEventActivationSql( countDistinctIf( toDate(occurred_at), occurred_at >= ${sqlNow} - INTERVAL ${config.recentActivityWindowDays} DAY - ) AS recentActiveDays, - countIf(lower(trim(event_name)) IN (${toSqlStringList(normalizeEventNames(config.activationEventNames))})) AS activationEventCount + ) AS recentActiveDays FROM activity WHERE occurred_at <= ${sqlNow} AND customer_id != '' @@ -183,6 +180,5 @@ function normalizeEventActivationRow(row: EventActivationRow): EventActivationRo ...row, recentEventCount: Number(row.recentEventCount), recentActiveDays: Number(row.recentActiveDays), - activationEventCount: Number(row.activationEventCount), } } diff --git a/examples/pi-agents/lib/activation-pretriage.ts b/examples/pi-agents/lib/activation-pretriage.ts index c036d2ba..273fc1f3 100644 --- a/examples/pi-agents/lib/activation-pretriage.ts +++ b/examples/pi-agents/lib/activation-pretriage.ts @@ -60,14 +60,13 @@ export type OutlitActivationPretriageConfig = { staleAfterDays: number recentActivityWindowDays: number activatedStages: Array<"ACTIVATED" | "ENGAGED"> - activationEventNames: string[] } } export type ResolvedActivationPretriageConfig = OutlitActivationPretriageConfig["defaults"] export type OutlitActivationPretriageSignal = { - key: "noActivatedUsers" | "noActivationEvent" | "noRecentProductActivity" | "stalledAfterSignup" + key: "noActivatedUsers" | "noRecentProductActivity" | "stalledAfterSignup" summary: string value?: number previousValue?: number @@ -83,7 +82,6 @@ export type OutlitActivationPretriageCustomer = { activationBaseline: { usersObserved: number activatedUsers: number - activationEventCount: number firstUserSeenAt: string | null lastUserActivityAt: string | null firstProductEventAt: string | null @@ -157,7 +155,6 @@ export const defaultActivationPretriageConfig: OutlitActivationPretriageConfig = staleAfterDays: 7, recentActivityWindowDays: 14, activatedStages: ["ACTIVATED", "ENGAGED"], - activationEventNames: ["stage:activated"], }, } @@ -219,7 +216,7 @@ export function createOutlitActivationPretriageTool( name: "outlit_activation_pretriage", label: "Outlit Activation Pretriage", description: - "Run deterministic activation-risk pretriage with user journey stage and activation-event checks before deeper account review.", + "Run deterministic activation-risk pretriage with Core-derived journey stages and ordinary product activity before deeper account review.", promptSnippet: "Outlit Activation Pretriage: deterministically surfaces accounts that have not reached first value.", parameters: activationPretriageToolParameters as unknown as TSchema, @@ -287,8 +284,6 @@ function validateActivationPretriageConfig( throw new Error("defaults.activatedStages contains an unsupported journey stage") } } - assertStringArray(config.defaults.activationEventNames, "activationEventNames") - return config } @@ -342,9 +337,7 @@ function finalizeActivationCustomers(params: { const eventRow = params.eventActivationRows.get(customer.customerId) const usersObserved = userRow?.usersObserved ?? 0 const activatedUsers = userRow?.activatedUsers ?? 0 - const activationEventCount = eventRow?.activationEventCount ?? 0 - - if (usersObserved <= 0 || activatedUsers > 0 || activationEventCount > 0) { + if (usersObserved <= 0 || activatedUsers > 0) { continue } @@ -359,7 +352,6 @@ function finalizeActivationCustomers(params: { config: params.config, usersObserved, activatedUsers, - activationEventCount, recentEventCount, recentActiveDays: eventRow?.recentActiveDays ?? 0, lastUserActivityDays, @@ -371,7 +363,6 @@ function finalizeActivationCustomers(params: { activationBaseline: { usersObserved, activatedUsers, - activationEventCount, firstUserSeenAt: userRow?.firstUserSeenAt ?? null, lastUserActivityAt: userRow?.lastUserActivityAt ?? null, firstProductEventAt: eventRow?.firstProductEventAt ?? null, @@ -390,7 +381,6 @@ function buildSignals(params: { config: ResolvedActivationPretriageConfig usersObserved: number activatedUsers: number - activationEventCount: number recentEventCount: number recentActiveDays: number lastUserActivityDays: number | null @@ -402,11 +392,6 @@ function buildSignals(params: { value: params.activatedUsers, previousValue: params.usersObserved, }, - { - key: "noActivationEvent", - summary: "No namespaced Outlit activation stage event was found for this customer.", - value: params.activationEventCount, - }, ] if (params.recentEventCount === 0) { @@ -482,7 +467,7 @@ Candidate accounting: } return `DETERMINISTIC ACTIVATION PRETRIAGE RESULTS: -- These customers were surfaced by deterministic user-stage and activation-event checks before the model review. +- These customers were surfaced by Core-derived user journey stages and ordinary product-activity checks before the model review. - The payload's activation metrics are hard behavior evidence from product event and user journey data. - Treat these customers as the investigation set for this activation run. Do not add unrelated customers unless the user explicitly asks for a broader scan. - You may drop a listed customer only if richer Outlit evidence clearly contradicts the activation risk. diff --git a/examples/pi-agents/skills/outlit-growth-agents/SKILL.md b/examples/pi-agents/skills/outlit-growth-agents/SKILL.md index 80aa9daf..3f5d1287 100644 --- a/examples/pi-agents/skills/outlit-growth-agents/SKILL.md +++ b/examples/pi-agents/skills/outlit-growth-agents/SKILL.md @@ -125,7 +125,7 @@ Investigation discipline: Look for: - trial or new account with no activation event -- deterministic pretriage matches from `outlit_activation_pretriage`, including no activated users, no namespaced Outlit activation stage event, stale user activity, or no recent product activity +- deterministic pretriage matches from `outlit_activation_pretriage`, including no users in Core-derived activated or engaged stages, stale user activity, or no recent product activity - setup started but stalled - onboarding or integration blockers - conversation interest without product follow-through diff --git a/examples/pi-agents/tests/activation-pretriage.test.ts b/examples/pi-agents/tests/activation-pretriage.test.ts index 40282c02..e16242a7 100644 --- a/examples/pi-agents/tests/activation-pretriage.test.ts +++ b/examples/pi-agents/tests/activation-pretriage.test.ts @@ -17,7 +17,7 @@ describe("runOutlitActivationPretriage", () => { ).toEqual(["NONE", "TRIALING", "PAYING"]) }) - test("surfaces accounts with users but no activated users or activation events", async () => { + test("surfaces accounts whose Core-derived journey stage has not reached activation", async () => { const queryMock = vi .fn() .mockResolvedValueOnce({ @@ -64,7 +64,6 @@ describe("runOutlitActivationPretriage", () => { lastProductEventAt: "2026-04-04T00:00:00Z", recentEventCount: 0, recentActiveDays: 0, - activationEventCount: 0, }, { customerId: "cust_activated", @@ -72,7 +71,6 @@ describe("runOutlitActivationPretriage", () => { lastProductEventAt: "2026-04-14T00:00:00Z", recentEventCount: 20, recentActiveDays: 5, - activationEventCount: 1, }, ], }) @@ -92,8 +90,8 @@ describe("runOutlitActivationPretriage", () => { sql: expect.not.stringContaining("first_seen_at"), }) const eventActivationSql = String(queryMock.mock.calls[2]?.[1]?.sql ?? "") - expect(eventActivationSql).toContain("'stage:activated'") - expect(eventActivationSql).not.toContain("'activated',") + expect(eventActivationSql).not.toContain("stage:activated") + expect(eventActivationSql).not.toContain("activationEventCount") expect(eventActivationSql).toContain("parseDateTimeBestEffort('2026-04-15T12:00:00.000Z')") expect(result.kind).toBe("activation") expect(result.summary).toMatchObject({ @@ -107,7 +105,6 @@ describe("runOutlitActivationPretriage", () => { billingStatus: "TRIALING", signals: expect.arrayContaining([ expect.objectContaining({ key: "noActivatedUsers" }), - expect.objectContaining({ key: "noActivationEvent" }), expect.objectContaining({ key: "noRecentProductActivity" }), ]), }), diff --git a/examples/pi-agents/tests/pretriage-utils.test.ts b/examples/pi-agents/tests/pretriage-utils.test.ts index 4245b018..79200dbf 100644 --- a/examples/pi-agents/tests/pretriage-utils.test.ts +++ b/examples/pi-agents/tests/pretriage-utils.test.ts @@ -11,15 +11,15 @@ import { describe("pretriage utilities", () => { test("normalizes event names for deterministic SQL filters", () => { - expect(normalizeEventNames([" Stage:Activated ", "", "CUSTOM_EVENT"])).toEqual([ - "stage:activated", + expect(normalizeEventNames([" Onboarding_Completed ", "", "CUSTOM_EVENT"])).toEqual([ + "onboarding_completed", "custom_event", ]) }) test("escapes SQL string lists", () => { - expect(toSqlStringList(["stage:activated", "customer's_event"])).toBe( - "'stage:activated', 'customer''s_event'", + expect(toSqlStringList(["onboarding_completed", "customer's_event"])).toBe( + "'onboarding_completed', 'customer''s_event'", ) }) diff --git a/packages/browser/README.md b/packages/browser/README.md index a3e2bcae..8cbac481 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -2,7 +2,7 @@ Browser tracking SDK for Outlit customer context, with React and Vue bindings. -Use this package to send page views, product events, identity, activation, form activity, and customer billing signals from client-side applications into Outlit. +Use this package to send page views, ordinary product events, identity, form activity, and customer attribution from client-side applications into Outlit. Core derives lifecycle from identity and activity, including the customer-selected ordinary activation event; billing comes from verified integrations. Outlit is the real-time understanding of every customer, the infrastructure agents use to automate customer operations. diff --git a/packages/browser/__tests__/e2e/api-surface.spec.ts b/packages/browser/__tests__/e2e/api-surface.spec.ts new file mode 100644 index 00000000..04650d9c --- /dev/null +++ b/packages/browser/__tests__/e2e/api-surface.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from "@playwright/test" + +test.describe("Public API surface", () => { + test("exposes ordinary identity and tracking without authoritative lifecycle or billing methods", async ({ + page, + }) => { + await page.goto("/test-page.html") + await page.waitForFunction(() => window.outlit?._initialized) + + const api = await page.evaluate(() => ({ + hasTrack: typeof window.outlit.track === "function", + hasIdentify: typeof window.outlit.identify === "function", + userMethods: Object.keys(window.outlit.user), + hasCustomerNamespace: "customer" in window.outlit, + })) + + expect(api).toEqual({ + hasTrack: true, + hasIdentify: true, + userMethods: ["identify"], + hasCustomerNamespace: false, + }) + }) +}) diff --git a/packages/browser/__tests__/e2e/browser-sdk.spec.ts b/packages/browser/__tests__/e2e/browser-sdk.spec.ts index 39d1cb0a..28319502 100644 --- a/packages/browser/__tests__/e2e/browser-sdk.spec.ts +++ b/packages/browser/__tests__/e2e/browser-sdk.spec.ts @@ -16,12 +16,6 @@ interface ApiCall { } } -interface StageEvent { - type: "stage" - stage: string - properties?: Record -} - // Helper to intercept and collect API calls async function interceptApiCalls(page: import("@playwright/test").Page): Promise { const apiCalls: ApiCall[] = [] @@ -586,13 +580,9 @@ test.describe("Stub Snippet (Recommended Approach)", () => { const earlyEvents = allEvents.filter( (e) => e.type === "custom" && e.properties?.queued === true, ) - const queuedStageEvents = allEvents.filter( - (e): e is StageEvent => e.type === "stage" && e.properties?.queued === true, - ) - - // Both queued custom events should have been processed + // Both queued custom events should have been processed. expect(earlyEvents.length).toBe(2) - expect(queuedStageEvents.map((e) => e.stage).sort()).toEqual(["engaged", "inactive"]) + expect(allEvents.some((event) => event.type === "identify")).toBe(true) }) test("double-load protection prevents re-initialization", async ({ page }) => { diff --git a/packages/browser/__tests__/e2e/fixtures/form-all-sensitive.html b/packages/browser/__tests__/e2e/fixtures/form-all-sensitive.html index bfe52a71..75d60f78 100644 --- a/packages/browser/__tests__/e2e/fixtures/form-all-sensitive.html +++ b/packages/browser/__tests__/e2e/fixtures/form-all-sensitive.html @@ -26,9 +26,8 @@

Form With Only Sensitive Fields

} } stub(o,"",["init","track","identify","enableTracking","isTrackingEnabled","getVisitorId","setUser","clearUser"]); - o.user=o.user||{};o.customer=o.customer||{}; - stub(o.user,"user",["identify","activate","engaged","inactive"]); - stub(o.customer,"customer",["trialing","paid","churned"]); + o.user=o.user||{}; + stub(o.user,"user",["identify"]); var s=d.createElement("script");s.async=1;s.src=src; s.dataset.publicKey=key; (d.body||d.head).appendChild(s); diff --git a/packages/browser/__tests__/e2e/fixtures/form-auto-identify-disabled.html b/packages/browser/__tests__/e2e/fixtures/form-auto-identify-disabled.html index f25b59e0..72f2f294 100644 --- a/packages/browser/__tests__/e2e/fixtures/form-auto-identify-disabled.html +++ b/packages/browser/__tests__/e2e/fixtures/form-auto-identify-disabled.html @@ -41,9 +41,8 @@

Sign up

} } stub(o,"",["init","track","identify","enableTracking","isTrackingEnabled","getVisitorId","setUser","clearUser"]); - o.user=o.user||{};o.customer=o.customer||{}; - stub(o.user,"user",["identify","activate","engaged","inactive"]); - stub(o.customer,"customer",["trialing","paid","churned"]); + o.user=o.user||{}; + stub(o.user,"user",["identify"]); var s=d.createElement("script");s.async=1;s.src=src; s.dataset.publicKey=key; s.dataset.autoIdentify="false"; diff --git a/packages/browser/__tests__/e2e/fixtures/form-credit-card.html b/packages/browser/__tests__/e2e/fixtures/form-credit-card.html index d598a4d1..47b4d02a 100644 --- a/packages/browser/__tests__/e2e/fixtures/form-credit-card.html +++ b/packages/browser/__tests__/e2e/fixtures/form-credit-card.html @@ -27,9 +27,8 @@

Form With Credit Card Number in Value

} } stub(o,"",["init","track","identify","enableTracking","isTrackingEnabled","getVisitorId","setUser","clearUser"]); - o.user=o.user||{};o.customer=o.customer||{}; - stub(o.user,"user",["identify","activate","engaged","inactive"]); - stub(o.customer,"customer",["trialing","paid","churned"]); + o.user=o.user||{}; + stub(o.user,"user",["identify"]); var s=d.createElement("script");s.async=1;s.src=src; s.dataset.publicKey=key; (d.body||d.head).appendChild(s); diff --git a/packages/browser/__tests__/e2e/fixtures/form-email-first-last.html b/packages/browser/__tests__/e2e/fixtures/form-email-first-last.html index 40ba1e8a..23d9a9d0 100644 --- a/packages/browser/__tests__/e2e/fixtures/form-email-first-last.html +++ b/packages/browser/__tests__/e2e/fixtures/form-email-first-last.html @@ -44,9 +44,8 @@

Send us a message

} } stub(o,"",["init","track","identify","enableTracking","isTrackingEnabled","getVisitorId","setUser","clearUser"]); - o.user=o.user||{};o.customer=o.customer||{}; - stub(o.user,"user",["identify","activate","engaged","inactive"]); - stub(o.customer,"customer",["trialing","paid","churned"]); + o.user=o.user||{}; + stub(o.user,"user",["identify"]); var s=d.createElement("script");s.async=1;s.src=src; s.dataset.publicKey=key;if(auto!==undefined)s.dataset.autoTrack=auto; (d.body||d.head).appendChild(s); diff --git a/packages/browser/__tests__/e2e/fixtures/form-email-name.html b/packages/browser/__tests__/e2e/fixtures/form-email-name.html index 49652648..10646aea 100644 --- a/packages/browser/__tests__/e2e/fixtures/form-email-name.html +++ b/packages/browser/__tests__/e2e/fixtures/form-email-name.html @@ -42,9 +42,8 @@

Get a personalized demo

} } stub(o,"",["init","track","identify","enableTracking","isTrackingEnabled","getVisitorId","setUser","clearUser"]); - o.user=o.user||{};o.customer=o.customer||{}; - stub(o.user,"user",["identify","activate","engaged","inactive"]); - stub(o.customer,"customer",["trialing","paid","churned"]); + o.user=o.user||{}; + stub(o.user,"user",["identify"]); var s=d.createElement("script");s.async=1;s.src=src; s.dataset.publicKey=key;if(auto!==undefined)s.dataset.autoTrack=auto; (d.body||d.head).appendChild(s); diff --git a/packages/browser/__tests__/e2e/fixtures/form-email-no-name.html b/packages/browser/__tests__/e2e/fixtures/form-email-no-name.html index 4e1fa829..f0667ce8 100644 --- a/packages/browser/__tests__/e2e/fixtures/form-email-no-name.html +++ b/packages/browser/__tests__/e2e/fixtures/form-email-no-name.html @@ -41,9 +41,8 @@

Join our waitlist

} } stub(o,"",["init","track","identify","enableTracking","isTrackingEnabled","getVisitorId","setUser","clearUser"]); - o.user=o.user||{};o.customer=o.customer||{}; - stub(o.user,"user",["identify","activate","engaged","inactive"]); - stub(o.customer,"customer",["trialing","paid","churned"]); + o.user=o.user||{}; + stub(o.user,"user",["identify"]); var s=d.createElement("script");s.async=1;s.src=src; s.dataset.publicKey=key;if(auto!==undefined)s.dataset.autoTrack=auto; (d.body||d.head).appendChild(s); diff --git a/packages/browser/__tests__/e2e/fixtures/form-email-only.html b/packages/browser/__tests__/e2e/fixtures/form-email-only.html index bbed7c03..ab79c92e 100644 --- a/packages/browser/__tests__/e2e/fixtures/form-email-only.html +++ b/packages/browser/__tests__/e2e/fixtures/form-email-only.html @@ -40,9 +40,8 @@

Subscribe to our newsletter

} } stub(o,"",["init","track","identify","enableTracking","isTrackingEnabled","getVisitorId","setUser","clearUser"]); - o.user=o.user||{};o.customer=o.customer||{}; - stub(o.user,"user",["identify","activate","engaged","inactive"]); - stub(o.customer,"customer",["trialing","paid","churned"]); + o.user=o.user||{}; + stub(o.user,"user",["identify"]); var s=d.createElement("script");s.async=1;s.src=src; s.dataset.publicKey=key;if(auto!==undefined)s.dataset.autoTrack=auto; (d.body||d.head).appendChild(s); diff --git a/packages/browser/__tests__/e2e/fixtures/form-file-inputs.html b/packages/browser/__tests__/e2e/fixtures/form-file-inputs.html index 6ddfe385..e3a0fe3b 100644 --- a/packages/browser/__tests__/e2e/fixtures/form-file-inputs.html +++ b/packages/browser/__tests__/e2e/fixtures/form-file-inputs.html @@ -27,9 +27,8 @@

Form With File Inputs

} } stub(o,"",["init","track","identify","enableTracking","isTrackingEnabled","getVisitorId","setUser","clearUser"]); - o.user=o.user||{};o.customer=o.customer||{}; - stub(o.user,"user",["identify","activate","engaged","inactive"]); - stub(o.customer,"customer",["trialing","paid","churned"]); + o.user=o.user||{}; + stub(o.user,"user",["identify"]); var s=d.createElement("script");s.async=1;s.src=src; s.dataset.publicKey=key; (d.body||d.head).appendChild(s); diff --git a/packages/browser/__tests__/e2e/fixtures/form-multiple.html b/packages/browser/__tests__/e2e/fixtures/form-multiple.html index bc86777a..bf62277b 100644 --- a/packages/browser/__tests__/e2e/fixtures/form-multiple.html +++ b/packages/browser/__tests__/e2e/fixtures/form-multiple.html @@ -30,9 +30,8 @@

Page With Multiple Forms

} } stub(o,"",["init","track","identify","enableTracking","isTrackingEnabled","getVisitorId","setUser","clearUser"]); - o.user=o.user||{};o.customer=o.customer||{}; - stub(o.user,"user",["identify","activate","engaged","inactive"]); - stub(o.customer,"customer",["trialing","paid","churned"]); + o.user=o.user||{}; + stub(o.user,"user",["identify"]); var s=d.createElement("script");s.async=1;s.src=src; s.dataset.publicKey=key; (d.body||d.head).appendChild(s); diff --git a/packages/browser/__tests__/e2e/fixtures/form-no-email.html b/packages/browser/__tests__/e2e/fixtures/form-no-email.html index f0835461..88b535d4 100644 --- a/packages/browser/__tests__/e2e/fixtures/form-no-email.html +++ b/packages/browser/__tests__/e2e/fixtures/form-no-email.html @@ -48,9 +48,8 @@

Share your feedback

} } stub(o,"",["init","track","identify","enableTracking","isTrackingEnabled","getVisitorId","setUser","clearUser"]); - o.user=o.user||{};o.customer=o.customer||{}; - stub(o.user,"user",["identify","activate","engaged","inactive"]); - stub(o.customer,"customer",["trialing","paid","churned"]); + o.user=o.user||{}; + stub(o.user,"user",["identify"]); var s=d.createElement("script");s.async=1;s.src=src; s.dataset.publicKey=key;if(auto!==undefined)s.dataset.autoTrack=auto; (d.body||d.head).appendChild(s); diff --git a/packages/browser/__tests__/e2e/fixtures/form-no-id.html b/packages/browser/__tests__/e2e/fixtures/form-no-id.html index 4b6fcb06..9fa2032c 100644 --- a/packages/browser/__tests__/e2e/fixtures/form-no-id.html +++ b/packages/browser/__tests__/e2e/fixtures/form-no-id.html @@ -25,9 +25,8 @@

Form Without ID or Name

} } stub(o,"",["init","track","identify","enableTracking","isTrackingEnabled","getVisitorId","setUser","clearUser"]); - o.user=o.user||{};o.customer=o.customer||{}; - stub(o.user,"user",["identify","activate","engaged","inactive"]); - stub(o.customer,"customer",["trialing","paid","churned"]); + o.user=o.user||{}; + stub(o.user,"user",["identify"]); var s=d.createElement("script");s.async=1;s.src=src; s.dataset.publicKey=key; (d.body||d.head).appendChild(s); diff --git a/packages/browser/__tests__/e2e/fixtures/form-password-variations.html b/packages/browser/__tests__/e2e/fixtures/form-password-variations.html index 999d356e..88ae4a1a 100644 --- a/packages/browser/__tests__/e2e/fixtures/form-password-variations.html +++ b/packages/browser/__tests__/e2e/fixtures/form-password-variations.html @@ -31,9 +31,8 @@

Form With Various Password Field Names

} } stub(o,"",["init","track","identify","enableTracking","isTrackingEnabled","getVisitorId","setUser","clearUser"]); - o.user=o.user||{};o.customer=o.customer||{}; - stub(o.user,"user",["identify","activate","engaged","inactive"]); - stub(o.customer,"customer",["trialing","paid","churned"]); + o.user=o.user||{}; + stub(o.user,"user",["identify"]); var s=d.createElement("script");s.async=1;s.src=src; s.dataset.publicKey=key; (d.body||d.head).appendChild(s); diff --git a/packages/browser/__tests__/e2e/fixtures/form-ssn.html b/packages/browser/__tests__/e2e/fixtures/form-ssn.html index ac6c32cc..ca27db1e 100644 --- a/packages/browser/__tests__/e2e/fixtures/form-ssn.html +++ b/packages/browser/__tests__/e2e/fixtures/form-ssn.html @@ -26,9 +26,8 @@

Form With SSN Pattern in Value

} } stub(o,"",["init","track","identify","enableTracking","isTrackingEnabled","getVisitorId","setUser","clearUser"]); - o.user=o.user||{};o.customer=o.customer||{}; - stub(o.user,"user",["identify","activate","engaged","inactive"]); - stub(o.customer,"customer",["trialing","paid","churned"]); + o.user=o.user||{}; + stub(o.user,"user",["identify"]); var s=d.createElement("script");s.async=1;s.src=src; s.dataset.publicKey=key; (d.body||d.head).appendChild(s); diff --git a/packages/browser/__tests__/e2e/fixtures/test-page-consent-persistence.html b/packages/browser/__tests__/e2e/fixtures/test-page-consent-persistence.html index 539f4e8b..c3b14b7a 100644 --- a/packages/browser/__tests__/e2e/fixtures/test-page-consent-persistence.html +++ b/packages/browser/__tests__/e2e/fixtures/test-page-consent-persistence.html @@ -33,9 +33,8 @@

Consent Persistence Test

} } stub(o,"",["init","track","identify","enableTracking","disableTracking","isTrackingEnabled","getVisitorId","setUser","clearUser"]); - o.user=o.user||{};o.customer=o.customer||{}; - stub(o.user,"user",["identify","activate","engaged","inactive"]); - stub(o.customer,"customer",["trialing","paid","churned"]); + o.user=o.user||{}; + stub(o.user,"user",["identify"]); var s=d.createElement("script");s.async=1;s.src=src; s.dataset.publicKey=key;if(auto!==undefined)s.dataset.autoTrack=auto; (d.body||d.head).appendChild(s); diff --git a/packages/browser/__tests__/e2e/fixtures/test-page-consent.html b/packages/browser/__tests__/e2e/fixtures/test-page-consent.html index 603ffce0..7a25702f 100644 --- a/packages/browser/__tests__/e2e/fixtures/test-page-consent.html +++ b/packages/browser/__tests__/e2e/fixtures/test-page-consent.html @@ -50,9 +50,8 @@

SDK Test Page - Consent Mode

} } stub(o,"",["init","track","identify","enableTracking","disableTracking","isTrackingEnabled","getVisitorId","setUser","clearUser"]); - o.user=o.user||{};o.customer=o.customer||{}; - stub(o.user,"user",["identify","activate","engaged","inactive"]); - stub(o.customer,"customer",["trialing","paid","churned"]); + o.user=o.user||{}; + stub(o.user,"user",["identify"]); var s=d.createElement("script");s.async=1;s.src=src; s.dataset.publicKey=key;if(auto!==undefined)s.dataset.autoTrack=auto; (d.body||d.head).appendChild(s); diff --git a/packages/browser/__tests__/e2e/fixtures/test-page-custom-denylist.html b/packages/browser/__tests__/e2e/fixtures/test-page-custom-denylist.html index 3d446fd0..7e89b02e 100644 --- a/packages/browser/__tests__/e2e/fixtures/test-page-custom-denylist.html +++ b/packages/browser/__tests__/e2e/fixtures/test-page-custom-denylist.html @@ -48,9 +48,8 @@

Test Form with Custom Denied Fields

} } stub(o,"",["init","track","identify","enableTracking","isTrackingEnabled","getVisitorId","setUser","clearUser"]); - o.user=o.user||{};o.customer=o.customer||{}; - stub(o.user,"user",["identify","activate","engaged","inactive"]); - stub(o.customer,"customer",["trialing","paid","churned"]); + o.user=o.user||{}; + stub(o.user,"user",["identify"]); var s=d.createElement("script");s.async=1;s.src=src; // Don't set publicKey on script - we'll init manually s.dataset.publicKey=key; diff --git a/packages/browser/__tests__/e2e/fixtures/test-page-links.html b/packages/browser/__tests__/e2e/fixtures/test-page-links.html index dabb659e..85ec4363 100644 --- a/packages/browser/__tests__/e2e/fixtures/test-page-links.html +++ b/packages/browser/__tests__/e2e/fixtures/test-page-links.html @@ -32,9 +32,8 @@

Navigation Test Page

} } stub(o,"",["init","track","identify","enableTracking","isTrackingEnabled","getVisitorId","setUser","clearUser"]); - o.user=o.user||{};o.customer=o.customer||{}; - stub(o.user,"user",["identify","activate","engaged","inactive"]); - stub(o.customer,"customer",["trialing","paid","churned"]); + o.user=o.user||{}; + stub(o.user,"user",["identify"]); var s=d.createElement("script");s.async=1;s.src=src; s.dataset.publicKey=key;if(auto!==undefined)s.dataset.autoTrack=auto; (d.body||d.head).appendChild(s); diff --git a/packages/browser/__tests__/e2e/fixtures/test-page-no-forms.html b/packages/browser/__tests__/e2e/fixtures/test-page-no-forms.html index 9638731e..4754639e 100644 --- a/packages/browser/__tests__/e2e/fixtures/test-page-no-forms.html +++ b/packages/browser/__tests__/e2e/fixtures/test-page-no-forms.html @@ -45,9 +45,8 @@

Test Form (should not be tracked)

} } stub(o,"",["init","track","identify","enableTracking","isTrackingEnabled","getVisitorId","setUser","clearUser"]); - o.user=o.user||{};o.customer=o.customer||{}; - stub(o.user,"user",["identify","activate","engaged","inactive"]); - stub(o.customer,"customer",["trialing","paid","churned"]); + o.user=o.user||{}; + stub(o.user,"user",["identify"]); var s=d.createElement("script");s.async=1;s.src=src; s.dataset.publicKey=key; s.dataset.trackForms="false"; diff --git a/packages/browser/__tests__/e2e/fixtures/test-page-no-pageviews.html b/packages/browser/__tests__/e2e/fixtures/test-page-no-pageviews.html index 777cc25c..b7b4a6f5 100644 --- a/packages/browser/__tests__/e2e/fixtures/test-page-no-pageviews.html +++ b/packages/browser/__tests__/e2e/fixtures/test-page-no-pageviews.html @@ -36,9 +36,8 @@

SDK Test Page - No Pageviews

} } stub(o,"",["init","track","identify","enableTracking","isTrackingEnabled","getVisitorId","setUser","clearUser"]); - o.user=o.user||{};o.customer=o.customer||{}; - stub(o.user,"user",["identify","activate","engaged","inactive"]); - stub(o.customer,"customer",["trialing","paid","churned"]); + o.user=o.user||{}; + stub(o.user,"user",["identify"]); var s=d.createElement("script");s.async=1;s.src=src; s.dataset.publicKey=key; s.dataset.trackPageviews="false"; diff --git a/packages/browser/__tests__/e2e/fixtures/test-page-queued.html b/packages/browser/__tests__/e2e/fixtures/test-page-queued.html index 70cc879b..2c80048e 100644 --- a/packages/browser/__tests__/e2e/fixtures/test-page-queued.html +++ b/packages/browser/__tests__/e2e/fixtures/test-page-queued.html @@ -36,9 +36,8 @@

SDK Test Page - Queued Calls

} } stub(o,"",["init","track","identify","enableTracking","isTrackingEnabled","getVisitorId","setUser","clearUser"]); - o.user=o.user||{};o.customer=o.customer||{}; - stub(o.user,"user",["identify","activate","engaged","inactive"]); - stub(o.customer,"customer",["trialing","paid","churned"]); + o.user=o.user||{}; + stub(o.user,"user",["identify"]); var s=d.createElement("script");s.async=1;s.src=src; s.dataset.publicKey=key;if(auto!==undefined)s.dataset.autoTrack=auto; (d.body||d.head).appendChild(s); @@ -48,8 +47,7 @@

SDK Test Page - Queued Calls

window.outlit.track('early_event_1', { queued: true, order: 1 }); window.outlit.track('early_event_2', { queued: true, order: 2 }); window.outlit.setUser({ userId: 'queued-user' }); - window.outlit.user.engaged({ queued: true, order: 3 }); - window.outlit.user.inactive({ queued: true, order: 4 }); + window.outlit.user.identify({ userId: 'queued-user' }); console.log('[Test] Queued 2 early events, queue length:', window.outlit._q.length); diff --git a/packages/browser/__tests__/e2e/fixtures/test-page-session.html b/packages/browser/__tests__/e2e/fixtures/test-page-session.html index 8baebd12..43872ac3 100644 --- a/packages/browser/__tests__/e2e/fixtures/test-page-session.html +++ b/packages/browser/__tests__/e2e/fixtures/test-page-session.html @@ -31,9 +31,8 @@

Session Tracking Test Page

} } stub(o,"",["init","track","identify","enableTracking","isTrackingEnabled","getVisitorId","setUser","clearUser"]); - o.user=o.user||{};o.customer=o.customer||{}; - stub(o.user,"user",["identify","activate","engaged","inactive"]); - stub(o.customer,"customer",["trialing","paid","churned"]); + o.user=o.user||{}; + stub(o.user,"user",["identify"]); var s=d.createElement("script");s.async=1;s.src=src; // Don't set data-public-key to prevent auto-init - we'll init manually with custom options (d.body||d.head).appendChild(s); diff --git a/packages/browser/__tests__/e2e/fixtures/test-page-timer.html b/packages/browser/__tests__/e2e/fixtures/test-page-timer.html index 45ebd220..afdd6cb5 100644 --- a/packages/browser/__tests__/e2e/fixtures/test-page-timer.html +++ b/packages/browser/__tests__/e2e/fixtures/test-page-timer.html @@ -21,9 +21,8 @@

Timer Test Page

} } stub(o,"",["init","track","identify","enableTracking","isTrackingEnabled","getVisitorId","setUser","clearUser"]); - o.user=o.user||{};o.customer=o.customer||{}; - stub(o.user,"user",["identify","activate","engaged","inactive"]); - stub(o.customer,"customer",["trialing","paid","churned"]); + o.user=o.user||{}; + stub(o.user,"user",["identify"]); var s=d.createElement("script");s.async=1;s.src=src; s.dataset.publicKey=key; (d.body||d.head).appendChild(s); diff --git a/packages/browser/__tests__/e2e/fixtures/test-page.html b/packages/browser/__tests__/e2e/fixtures/test-page.html index ced00037..b51209b8 100644 --- a/packages/browser/__tests__/e2e/fixtures/test-page.html +++ b/packages/browser/__tests__/e2e/fixtures/test-page.html @@ -49,9 +49,8 @@

Test Form

} } stub(o,"",["init","track","identify","enableTracking","isTrackingEnabled","getVisitorId","setUser","clearUser"]); - o.user=o.user||{};o.customer=o.customer||{}; - stub(o.user,"user",["identify","activate","engaged","inactive"]); - stub(o.customer,"customer",["trialing","paid","churned"]); + o.user=o.user||{}; + stub(o.user,"user",["identify"]); var s=d.createElement("script");s.async=1;s.src=src; s.dataset.publicKey=key;if(auto!==undefined)s.dataset.autoTrack=auto; (d.body||d.head).appendChild(s); diff --git a/packages/browser/__tests__/e2e/global.d.ts b/packages/browser/__tests__/e2e/global.d.ts index 3e5e1192..409ae373 100644 --- a/packages/browser/__tests__/e2e/global.d.ts +++ b/packages/browser/__tests__/e2e/global.d.ts @@ -36,26 +36,6 @@ declare global { userId?: string traits?: Record }) => void - activate: (properties?: Record) => void - engaged: (properties?: Record) => void - inactive: (properties?: Record) => void - } - customer: { - trialing: (options: { - customerId?: string - stripeCustomerId?: string - properties?: Record - }) => void - paid: (options: { - customerId?: string - stripeCustomerId?: string - properties?: Record - }) => void - churned: (options: { - customerId?: string - stripeCustomerId?: string - properties?: Record - }) => void } // Internal properties (for testing) diff --git a/packages/browser/__tests__/e2e/stage-methods.spec.ts b/packages/browser/__tests__/e2e/stage-methods.spec.ts deleted file mode 100644 index c63d2d7c..00000000 --- a/packages/browser/__tests__/e2e/stage-methods.spec.ts +++ /dev/null @@ -1,462 +0,0 @@ -/** - * Stage Methods E2E Tests - * - * Tests activation stage tracking and deprecated engaged/inactive compatibility. - */ - -import { expect, type Request, type Route, test } from "@playwright/test" - -interface StageEvent { - type: "stage" - stage: string - path?: string - properties?: Record -} - -interface BillingEvent { - type: "billing" - status: string - customerId?: string - stripeCustomerId?: string - path?: string - properties?: Record -} - -interface ApiCall { - url: string - payload: { - visitorId?: string - source?: string - events?: Array> - } -} - -// Helper to intercept and collect API calls -async function interceptApiCalls(page: import("@playwright/test").Page): Promise { - const apiCalls: ApiCall[] = [] - - await page.route("**/api/i/v1/**/events", async (route: Route) => { - const request: Request = route.request() - const postData = request.postData() - - apiCalls.push({ - url: request.url(), - payload: postData ? JSON.parse(postData) : {}, - }) - - // Return success response - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ success: true, processed: 1 }), - }) - }) - - return apiCalls -} - -// ============================================ -// STAGE METHOD TESTS -// ============================================ - -test.describe("Stage Methods", () => { - test("deprecated inactive() sends stage event with properties and warns", async ({ page }) => { - const apiCalls = await interceptApiCalls(page) - const warnings: string[] = [] - page.on("console", (msg) => { - if (msg.type() === "warning") warnings.push(msg.text()) - }) - - await page.goto("/test-page.html") - await page.waitForFunction(() => window.outlit?._initialized) - - await page.evaluate(() => { - window.outlit.setUser({ userId: "user-123" }) - window.outlit.user.inactive({ reason: "cancelled", plan: "pro" }) - }) - - // Force flush - await page.evaluate(() => window.dispatchEvent(new Event("beforeunload"))) - await page.waitForTimeout(500) - - const allEvents = apiCalls.flatMap((c) => c.payload.events || []) - const stageEvent = allEvents.find( - (e): e is StageEvent => e.type === "stage" && (e as StageEvent).stage === "inactive", - ) - - expect(stageEvent).toBeDefined() - expect(stageEvent?.type).toBe("stage") - expect(stageEvent?.stage).toBe("inactive") - expect(stageEvent?.properties?.reason).toBe("cancelled") - expect(stageEvent?.properties?.plan).toBe("pro") - expect(warnings.some((w) => w.includes("user.inactive() is deprecated"))).toBe(true) - }) - - test("deprecated inactive() queues event when user identity not set", async ({ page }) => { - const warnings: string[] = [] - - // Set up console listener before navigation - page.on("console", (msg) => { - if (msg.type() === "warning") warnings.push(msg.text()) - }) - - await page.goto("/test-page.html") - await page.waitForFunction(() => window.outlit?._initialized) - - // Call inactive without setting user identity - should queue silently - await page.evaluate(() => { - window.outlit.user.inactive({ reason: "test" }) - }) - - // Wait a bit to ensure no warning is logged - await page.waitForTimeout(100) - - expect(warnings.some((w) => w.includes("user.inactive() is deprecated"))).toBe(true) - // Should NOT log the missing identity warning when queuing (only when queue is full) - expect(warnings.some((w) => w.includes("setUser") || w.includes("identify"))).toBe(false) - }) - - test("activate() sends stage event", async ({ page }) => { - const apiCalls = await interceptApiCalls(page) - await page.goto("/test-page.html") - await page.waitForFunction(() => window.outlit?._initialized) - - await page.evaluate(() => { - window.outlit.setUser({ userId: "user-activate" }) - window.outlit.user.activate({ milestone: "onboarding_complete" }) - }) - - await page.evaluate(() => window.dispatchEvent(new Event("beforeunload"))) - await page.waitForTimeout(500) - - const allEvents = apiCalls.flatMap((c) => c.payload.events || []) - const stageEvent = allEvents.find( - (e): e is StageEvent => e.type === "stage" && (e as StageEvent).stage === "activated", - ) - - expect(stageEvent).toBeDefined() - expect(stageEvent?.type).toBe("stage") - expect(stageEvent?.stage).toBe("activated") - expect(stageEvent?.properties?.milestone).toBe("onboarding_complete") - }) - - test("deprecated engaged() sends stage event and warns", async ({ page }) => { - const apiCalls = await interceptApiCalls(page) - const warnings: string[] = [] - page.on("console", (msg) => { - if (msg.type() === "warning") warnings.push(msg.text()) - }) - - await page.goto("/test-page.html") - await page.waitForFunction(() => window.outlit?._initialized) - - await page.evaluate(() => { - window.outlit.setUser({ userId: "user-engaged" }) - window.outlit.user.engaged({ sessions: 10 }) - }) - - await page.evaluate(() => window.dispatchEvent(new Event("beforeunload"))) - await page.waitForTimeout(500) - - const allEvents = apiCalls.flatMap((c) => c.payload.events || []) - const stageEvent = allEvents.find( - (e): e is StageEvent => e.type === "stage" && (e as StageEvent).stage === "engaged", - ) - - expect(stageEvent).toBeDefined() - expect(stageEvent?.type).toBe("stage") - expect(stageEvent?.stage).toBe("engaged") - expect(stageEvent?.properties?.sessions).toBe(10) - expect(warnings.some((w) => w.includes("user.engaged() is deprecated"))).toBe(true) - }) - - test("paid() sends billing event", async ({ page }) => { - const apiCalls = await interceptApiCalls(page) - await page.goto("/test-page.html") - await page.waitForFunction(() => window.outlit?._initialized) - - await page.evaluate(() => { - window.outlit.customer.paid({ - customerId: "cust_paid", - properties: { plan: "enterprise" }, - }) - }) - - await page.evaluate(() => window.dispatchEvent(new Event("beforeunload"))) - await page.waitForTimeout(500) - - const allEvents = apiCalls.flatMap((c) => c.payload.events || []) - const billingEvent = allEvents.find( - (e): e is BillingEvent => e.type === "billing" && (e as BillingEvent).status === "paid", - ) - - expect(billingEvent).toBeDefined() - expect(billingEvent?.type).toBe("billing") - expect(billingEvent?.status).toBe("paid") - expect(billingEvent?.customerId).toBe("cust_paid") - expect(billingEvent?.properties?.plan).toBe("enterprise") - }) - - test("trialing() sends billing event with customerId", async ({ page }) => { - const apiCalls = await interceptApiCalls(page) - await page.goto("/test-page.html") - await page.waitForFunction(() => window.outlit?._initialized) - - await page.evaluate(() => { - window.outlit.customer.trialing({ - customerId: "cust_123", - properties: { plan: "pro" }, - }) - }) - - await page.evaluate(() => window.dispatchEvent(new Event("beforeunload"))) - await page.waitForTimeout(500) - - const allEvents = apiCalls.flatMap((c) => c.payload.events || []) - const billingEvent = allEvents.find( - (e): e is BillingEvent => e.type === "billing" && (e as BillingEvent).status === "trialing", - ) - - expect(billingEvent).toBeDefined() - expect(billingEvent?.type).toBe("billing") - expect(billingEvent?.status).toBe("trialing") - expect(billingEvent?.customerId).toBe("cust_123") - expect(billingEvent?.properties?.plan).toBe("pro") - }) - - test("churned() sends billing event with stripeCustomerId", async ({ page }) => { - const apiCalls = await interceptApiCalls(page) - await page.goto("/test-page.html") - await page.waitForFunction(() => window.outlit?._initialized) - - await page.evaluate(() => { - window.outlit.customer.churned({ - stripeCustomerId: "cus_stripe_abc", - properties: { reason: "cancelled" }, - }) - }) - - await page.evaluate(() => window.dispatchEvent(new Event("beforeunload"))) - await page.waitForTimeout(500) - - const allEvents = apiCalls.flatMap((c) => c.payload.events || []) - const billingEvent = allEvents.find( - (e): e is BillingEvent => e.type === "billing" && (e as BillingEvent).status === "churned", - ) - - expect(billingEvent).toBeDefined() - expect(billingEvent?.type).toBe("billing") - expect(billingEvent?.status).toBe("churned") - expect(billingEvent?.stripeCustomerId).toBe("cus_stripe_abc") - expect(billingEvent?.properties?.reason).toBe("cancelled") - }) - - test("deprecated stage methods still work in sequence with activate", async ({ page }) => { - const apiCalls = await interceptApiCalls(page) - await page.goto("/test-page.html") - await page.waitForFunction(() => window.outlit?._initialized) - - await page.evaluate(() => { - window.outlit.setUser({ userId: "user-lifecycle" }) - window.outlit.user.activate() - window.outlit.user.engaged() - window.outlit.user.inactive() - }) - - await page.evaluate(() => window.dispatchEvent(new Event("beforeunload"))) - await page.waitForTimeout(500) - - const allEvents = apiCalls.flatMap((c) => c.payload.events || []) - const stageEvents = allEvents.filter((e): e is StageEvent => e.type === "stage") - const stages = stageEvents.map((e) => e.stage) - - expect(stages).toContain("activated") - expect(stages).toContain("engaged") - expect(stages).toContain("inactive") - expect(stageEvents.length).toBe(3) - }) - - test("stage methods without properties send events correctly", async ({ page }) => { - const apiCalls = await interceptApiCalls(page) - await page.goto("/test-page.html") - await page.waitForFunction(() => window.outlit?._initialized) - - await page.evaluate(() => { - window.outlit.setUser({ userId: "user-no-props" }) - window.outlit.user.inactive() - }) - - await page.evaluate(() => window.dispatchEvent(new Event("beforeunload"))) - await page.waitForTimeout(500) - - const allEvents = apiCalls.flatMap((c) => c.payload.events || []) - const stageEvent = allEvents.find( - (e): e is StageEvent => e.type === "stage" && (e as StageEvent).stage === "inactive", - ) - - expect(stageEvent).toBeDefined() - expect(stageEvent?.type).toBe("stage") - expect(stageEvent?.stage).toBe("inactive") - }) -}) - -// ============================================ -// PENDING STAGE EVENTS TESTS -// ============================================ - -test.describe("Pending Stage Events", () => { - test("stage events queued when user not set, flushed on setUser()", async ({ page }) => { - const apiCalls = await interceptApiCalls(page) - await page.goto("/test-page.html") - await page.waitForFunction(() => window.outlit?._initialized) - - // Call stage method BEFORE setting user identity - await page.evaluate(() => { - window.outlit.user.activate({ milestone: "onboarding_complete" }) - }) - - // Verify no events sent yet (no flush triggered) - await page.waitForTimeout(100) - let allEvents = apiCalls.flatMap((c) => c.payload.events || []) - const stageEvents = allEvents.filter((e): e is StageEvent => e.type === "stage") - expect(stageEvents.length).toBe(0) - - // Now set user identity - should flush pending events - await page.evaluate(() => { - window.outlit.setUser({ userId: "user-delayed" }) - }) - - // Force flush - await page.evaluate(() => window.dispatchEvent(new Event("beforeunload"))) - await page.waitForTimeout(500) - - allEvents = apiCalls.flatMap((c) => c.payload.events || []) - const activateEvent = allEvents.find( - (e): e is StageEvent => e.type === "stage" && (e as StageEvent).stage === "activated", - ) - - expect(activateEvent).toBeDefined() - expect(activateEvent?.properties?.milestone).toBe("onboarding_complete") - }) - - test("stage events queued when user not set, flushed on identify()", async ({ page }) => { - const apiCalls = await interceptApiCalls(page) - await page.goto("/test-page.html") - await page.waitForFunction(() => window.outlit?._initialized) - - // Call stage method BEFORE identifying - await page.evaluate(() => { - window.outlit.user.engaged({ sessions: 5 }) - }) - - // Now identify - should flush pending events - await page.evaluate(() => { - window.outlit.identify({ email: "test@example.com" }) - }) - - // Force flush - await page.evaluate(() => window.dispatchEvent(new Event("beforeunload"))) - await page.waitForTimeout(500) - - const allEvents = apiCalls.flatMap((c) => c.payload.events || []) - const engagedEvent = allEvents.find( - (e): e is StageEvent => e.type === "stage" && (e as StageEvent).stage === "engaged", - ) - - expect(engagedEvent).toBeDefined() - expect(engagedEvent?.properties?.sessions).toBe(5) - }) - - test("queue limit warning when exceeding MAX_PENDING_STAGE_EVENTS", async ({ page }) => { - const warnings: string[] = [] - - page.on("console", (msg) => { - if (msg.type() === "warning") warnings.push(msg.text()) - }) - - await page.goto("/test-page.html") - await page.waitForFunction(() => window.outlit?._initialized) - - // Queue 11 events (limit is 10) - await page.evaluate(() => { - for (let i = 0; i < 11; i++) { - window.outlit.user.activate({ attempt: i }) - } - }) - - await page.waitForTimeout(100) - - expect(warnings.some((w) => w.includes("queue full") || w.includes("10"))).toBe(true) - }) - - test("pending events cleared on clearUser()", async ({ page }) => { - const apiCalls = await interceptApiCalls(page) - await page.goto("/test-page.html") - await page.waitForFunction(() => window.outlit?._initialized) - - // Queue a stage event - await page.evaluate(() => { - window.outlit.user.activate({ milestone: "should_be_cleared" }) - }) - - // Clear user (simulating logout) - should clear pending events - await page.evaluate(() => { - window.outlit.clearUser() - }) - - // Set a new user - await page.evaluate(() => { - window.outlit.setUser({ userId: "new-user" }) - }) - - // Force flush - await page.evaluate(() => window.dispatchEvent(new Event("beforeunload"))) - await page.waitForTimeout(500) - - const allEvents = apiCalls.flatMap((c) => c.payload.events || []) - const activateEvent = allEvents.find( - (e): e is StageEvent => - e.type === "stage" && - (e as StageEvent).stage === "activated" && - (e as StageEvent).properties?.milestone === "should_be_cleared", - ) - - // The event should NOT be present (it was cleared) - expect(activateEvent).toBeUndefined() - }) - - test("multiple pending events preserve order and properties when flushed", async ({ page }) => { - const apiCalls = await interceptApiCalls(page) - await page.goto("/test-page.html") - await page.waitForFunction(() => window.outlit?._initialized) - - // Queue multiple events before setting user - await page.evaluate(() => { - window.outlit.user.activate({ step: 1 }) - window.outlit.user.engaged({ step: 2 }) - window.outlit.user.inactive({ step: 3 }) - }) - - // Set user to flush - await page.evaluate(() => { - window.outlit.setUser({ userId: "user-multi" }) - }) - - // Force flush - await page.evaluate(() => window.dispatchEvent(new Event("beforeunload"))) - await page.waitForTimeout(500) - - const allEvents = apiCalls.flatMap((c) => c.payload.events || []) - const stageEvents = allEvents.filter((e): e is StageEvent => e.type === "stage") - - expect(stageEvents.length).toBe(3) - - const activateEvent = stageEvents.find((e) => e.stage === "activated") - const engagedEvent = stageEvents.find((e) => e.stage === "engaged") - const inactiveEvent = stageEvents.find((e) => e.stage === "inactive") - - expect(activateEvent?.properties?.step).toBe(1) - expect(engagedEvent?.properties?.step).toBe(2) - expect(inactiveEvent?.properties?.step).toBe(3) - }) -}) diff --git a/packages/browser/__tests__/unit/react-hooks.test.tsx b/packages/browser/__tests__/unit/react-hooks.test.tsx index 09f7fc05..263a6644 100644 --- a/packages/browser/__tests__/unit/react-hooks.test.tsx +++ b/packages/browser/__tests__/unit/react-hooks.test.tsx @@ -3,8 +3,7 @@ * * Tests the React hooks (useOutlit) to ensure they: * - Warn when used outside OutlitProvider - * - Expose user namespace and deprecated derived-stage aliases for compatibility - * - Expose customer namespace (trialing, paid, churned) + * - Expose only ordinary identity and tracking APIs * - Handle consent flow correctly * * Run with: bun run test:unit @@ -70,7 +69,7 @@ describe("useOutlit hook", () => { consoleSpy.mockRestore() }) - it("exposes user namespace with activation and deprecated derived-stage aliases", () => { + it("exposes only identify in the user namespace", () => { const wrapper = ({ children }: { children: ReactNode }) => ( {children} @@ -79,14 +78,10 @@ describe("useOutlit hook", () => { const { result } = renderHook(() => useOutlit(), { wrapper }) - // Verify user namespace methods exist and are functions - expect(typeof result.current.user.activate).toBe("function") - expect(typeof result.current.user.engaged).toBe("function") - expect(typeof result.current.user.inactive).toBe("function") - expect(typeof result.current.user.identify).toBe("function") + expect(Object.keys(result.current.user)).toEqual(["identify"]) }) - it("exposes customer namespace with billing methods", () => { + it("does not expose a customer billing namespace", () => { const wrapper = ({ children }: { children: ReactNode }) => ( {children} @@ -95,10 +90,7 @@ describe("useOutlit hook", () => { const { result } = renderHook(() => useOutlit(), { wrapper }) - // Verify customer namespace methods exist and are functions - expect(typeof result.current.customer.trialing).toBe("function") - expect(typeof result.current.customer.paid).toBe("function") - expect(typeof result.current.customer.churned).toBe("function") + expect("customer" in result.current).toBe(false) }) it("exposes track, identify, and user management methods", () => { @@ -233,63 +225,6 @@ describe("OutlitProvider", () => { }) }) -describe("Stage methods behavior", () => { - it("deprecated inactive method can be called without properties", () => { - const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) - const wrapper = ({ children }: { children: ReactNode }) => ( - - {children} - - ) - - const { result } = renderHook(() => useOutlit(), { wrapper }) - - // Should not throw when called without properties - act(() => { - result.current.enableTracking() - result.current.setUser({ userId: "test-user" }) - }) - - // This should not throw - expect(() => { - act(() => { - result.current.user.inactive() - }) - }).not.toThrow() - - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining("user.inactive() is deprecated"), - ) - }) - - it("deprecated inactive method can be called with properties", () => { - const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) - const wrapper = ({ children }: { children: ReactNode }) => ( - - {children} - - ) - - const { result } = renderHook(() => useOutlit(), { wrapper }) - - act(() => { - result.current.enableTracking() - result.current.setUser({ userId: "test-user" }) - }) - - // This should not throw - expect(() => { - act(() => { - result.current.user.inactive({ reason: "cancelled", plan: "pro" }) - }) - }).not.toThrow() - - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining("user.inactive() is deprecated"), - ) - }) -}) - // ============================================ // OutlitProvider client prop tests // ============================================ diff --git a/packages/browser/__tests__/unit/tracker.test.ts b/packages/browser/__tests__/unit/tracker.test.ts index 779e3480..b55b0f7d 100644 --- a/packages/browser/__tests__/unit/tracker.test.ts +++ b/packages/browser/__tests__/unit/tracker.test.ts @@ -200,31 +200,4 @@ describe("payload identity", () => { expect(payload.userIdentity).not.toHaveProperty("customerId") expect(payload.customerIdentity).not.toHaveProperty("customerTraits") }) - - it("keeps user activation stage events separate from customer event names", async () => { - const outlit = new Outlit({ - publicKey: "pk_test", - autoTrack: false, - trackPageviews: false, - trackForms: false, - trackEngagement: false, - }) - - outlit.enableTracking() - outlit.setUser({ userId: "user_123" }) - outlit.user.activate({ milestone: "first_project_created" }) - - await outlit.flush() - - const fetchOptions = vi.mocked(global.fetch).mock.calls[0]?.[1] - const payload = JSON.parse(String(fetchOptions?.body)) as { - events: Array<{ type: string; stage?: string; eventName?: string }> - } - const activationEvent = payload.events.find( - (event) => event.type === "stage" && event.stage === "activated", - ) - - expect(activationEvent).toBeDefined() - expect(activationEvent).not.toHaveProperty("eventName") - }) }) diff --git a/packages/browser/__tests__/unit/vue-composables.test.ts b/packages/browser/__tests__/unit/vue-composables.test.ts index 3e690f7e..cf0bc882 100644 --- a/packages/browser/__tests__/unit/vue-composables.test.ts +++ b/packages/browser/__tests__/unit/vue-composables.test.ts @@ -3,8 +3,7 @@ * * Tests the Vue composables (useOutlit) to ensure they: * - Throw when used outside OutlitPlugin - * - Expose user namespace and deprecated derived-stage aliases for compatibility - * - Expose customer namespace (trialing, paid, churned) + * - Expose only ordinary identity and tracking APIs * - Handle consent flow correctly * * Run with: bun run test:unit @@ -128,7 +127,7 @@ describe("useOutlit composable", () => { document.body.removeChild(root) }) - it("exposes user namespace with activation and deprecated derived-stage aliases", () => { + it("exposes only identify in the user namespace", () => { let result: ReturnType | null = null const TestComponent = defineComponent({ @@ -141,15 +140,12 @@ describe("useOutlit composable", () => { const { unmount } = mountWithPlugin(TestComponent) expect(result).not.toBeNull() - expect(typeof result!.user.activate).toBe("function") - expect(typeof result!.user.engaged).toBe("function") - expect(typeof result!.user.inactive).toBe("function") - expect(typeof result!.user.identify).toBe("function") + expect(Object.keys(result!.user)).toEqual(["identify"]) unmount() }) - it("exposes customer namespace with billing methods", () => { + it("does not expose a customer billing namespace", () => { let result: ReturnType | null = null const TestComponent = defineComponent({ @@ -162,9 +158,7 @@ describe("useOutlit composable", () => { const { unmount } = mountWithPlugin(TestComponent) expect(result).not.toBeNull() - expect(typeof result!.customer.trialing).toBe("function") - expect(typeof result!.customer.paid).toBe("function") - expect(typeof result!.customer.churned).toBe("function") + expect("customer" in result!).toBe(false) unmount() }) @@ -418,67 +412,3 @@ describe("useOutlitUser composable", () => { unmount() }) }) - -describe("Stage methods behavior", () => { - it("deprecated inactive method can be called without properties", async () => { - const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) - let result: ReturnType | null = null - - const TestComponent = defineComponent({ - setup() { - result = useOutlit() - return () => h("div") - }, - }) - - const { unmount } = mountWithPlugin(TestComponent, { publicKey: "pk_test", autoTrack: false }) - - result!.enableTracking() - await nextTick() - - result!.setUser({ userId: "test-user" }) - await nextTick() - - // This should not throw - expect(() => { - result!.user.inactive() - }).not.toThrow() - - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining("user.inactive() is deprecated"), - ) - - unmount() - }) - - it("deprecated inactive method can be called with properties", async () => { - const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) - let result: ReturnType | null = null - - const TestComponent = defineComponent({ - setup() { - result = useOutlit() - return () => h("div") - }, - }) - - const { unmount } = mountWithPlugin(TestComponent, { publicKey: "pk_test", autoTrack: false }) - - result!.enableTracking() - await nextTick() - - result!.setUser({ userId: "test-user" }) - await nextTick() - - // This should not throw - expect(() => { - result!.user.inactive({ reason: "cancelled", plan: "pro" }) - }).not.toThrow() - - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining("user.inactive() is deprecated"), - ) - - unmount() - }) -}) diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index b8d0a8ea..ac080c85 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -4,15 +4,12 @@ export type { BrowserIdentifyOptions, BrowserTrackOptions, - CustomerIdentifier, - ExplicitJourneyStage, TrackerConfig, UtmParams, } from "@outlit/core" -export type { BillingOptions, OutlitOptions, UserIdentity, UserMethods } from "./tracker" +export type { OutlitOptions, UserIdentity, UserMethods } from "./tracker" export { clearUser, - customer, disableTracking, enableTracking, getInstance, @@ -28,7 +25,6 @@ export { // Default export for simple import import { clearUser, - customer, disableTracking, enableTracking, getInstance, @@ -53,5 +49,4 @@ export default { setUser, clearUser, user, - customer, } diff --git a/packages/browser/src/react/hooks.ts b/packages/browser/src/react/hooks.ts index 23772198..12aea711 100644 --- a/packages/browser/src/react/hooks.ts +++ b/packages/browser/src/react/hooks.ts @@ -1,6 +1,6 @@ import type { BrowserIdentifyOptions, BrowserTrackOptions } from "@outlit/core" import { useCallback, useContext } from "react" -import type { BillingOptions, UserIdentity } from "../tracker" +import type { UserIdentity } from "../tracker" import { OutlitContext } from "./provider" // ============================================ @@ -37,30 +37,10 @@ export interface UseOutlitReturn { clearUser: () => void /** - * User namespace methods for identity and activation. + * User namespace method for identity. */ user: { identify: (options: BrowserIdentifyOptions) => void - activate: (properties?: Record) => void - /** - * @deprecated Outlit derives ENGAGED from tracked activity. Keep tracking product activity - * with track() and only send activation manually with user.activate(). - */ - engaged: (properties?: Record) => void - /** - * @deprecated Outlit derives INACTIVE from tracked activity. Keep tracking product activity - * with track() and only send activation manually with user.activate(). - */ - inactive: (properties?: Record) => void - } - - /** - * Customer namespace methods for billing status. - */ - customer: { - trialing: (options: BillingOptions) => void - paid: (options: BillingOptions) => void - churned: (options: BillingOptions) => void } /** @@ -95,10 +75,10 @@ export interface UseOutlitReturn { * import { useOutlit } from '@outlit/browser/react' * * function MyComponent() { - * const { track, user } = useOutlit() + * const { track } = useOutlit() * * return ( - * * ) @@ -183,72 +163,6 @@ export function useOutlit(): UseOutlitReturn { outlit.clearUser() }, [outlit]) - const activate = useCallback( - (properties?: Record) => { - if (!outlit) { - console.warn("[Outlit] Not initialized. Make sure OutlitProvider is mounted.") - return - } - outlit.user.activate(properties) - }, - [outlit], - ) - - const engaged = useCallback( - (properties?: Record) => { - if (!outlit) { - console.warn("[Outlit] Not initialized. Make sure OutlitProvider is mounted.") - return - } - outlit.user.engaged(properties) - }, - [outlit], - ) - - const inactive = useCallback( - (properties?: Record) => { - if (!outlit) { - console.warn("[Outlit] Not initialized. Make sure OutlitProvider is mounted.") - return - } - outlit.user.inactive(properties) - }, - [outlit], - ) - - const trialing = useCallback( - (options: BillingOptions) => { - if (!outlit) { - console.warn("[Outlit] Not initialized. Make sure OutlitProvider is mounted.") - return - } - outlit.customer.trialing(options) - }, - [outlit], - ) - - const paid = useCallback( - (options: BillingOptions) => { - if (!outlit) { - console.warn("[Outlit] Not initialized. Make sure OutlitProvider is mounted.") - return - } - outlit.customer.paid(options) - }, - [outlit], - ) - - const churned = useCallback( - (options: BillingOptions) => { - if (!outlit) { - console.warn("[Outlit] Not initialized. Make sure OutlitProvider is mounted.") - return - } - outlit.customer.churned(options) - }, - [outlit], - ) - return { track, identify, @@ -257,14 +171,6 @@ export function useOutlit(): UseOutlitReturn { clearUser, user: { identify: userIdentify, - activate, - engaged, - inactive, - }, - customer: { - trialing, - paid, - churned, }, isInitialized, isTrackingEnabled, diff --git a/packages/browser/src/react/index.ts b/packages/browser/src/react/index.ts index f22c6f8a..0837dcb8 100644 --- a/packages/browser/src/react/index.ts +++ b/packages/browser/src/react/index.ts @@ -4,12 +4,10 @@ export type { BrowserIdentifyOptions, BrowserTrackOptions, - CustomerIdentifier, - ExplicitJourneyStage, TrackerConfig, } from "@outlit/core" // Re-export useful types from tracker -export type { BillingOptions, UserIdentity } from "../tracker" +export type { UserIdentity } from "../tracker" export type { UseOutlitReturn } from "./hooks" // Hooks export { useIdentify, useOutlit, useTrack } from "./hooks" diff --git a/packages/browser/src/script.ts b/packages/browser/src/script.ts index 57c2cea2..dbc84b1f 100644 --- a/packages/browser/src/script.ts +++ b/packages/browser/src/script.ts @@ -15,9 +15,8 @@ * } * } * stub(o,"",["init","track","identify","enableTracking","disableTracking","isTrackingEnabled","getVisitorId","setUser","clearUser"]); - * o.user=o.user||{};o.customer=o.customer||{}; - * stub(o.user,"user",["identify","activate","engaged","inactive"]); - * stub(o.customer,"customer",["trialing","paid","churned"]); + * o.user=o.user||{}; + * stub(o.user,"user",["identify"]); * var s=d.createElement("script");s.async=1;s.src=src; * s.dataset.publicKey=key;if(auto!==undefined)s.dataset.autoTrack=auto; * (d.body||d.head).appendChild(s); @@ -32,7 +31,7 @@ */ import type { BrowserIdentifyOptions, BrowserTrackOptions } from "@outlit/core" -import { type BillingOptions, Outlit, type OutlitOptions, type UserIdentity } from "./tracker" +import { Outlit, type OutlitOptions, type UserIdentity } from "./tracker" // ============================================ // TYPES @@ -61,22 +60,6 @@ interface OutlitGlobal { clearUser: () => void user: { identify: (options: BrowserIdentifyOptions) => void - activate: (properties?: Record) => void - /** - * @deprecated Outlit derives ENGAGED from tracked activity. Keep tracking product activity - * with track() and only send activation manually with user.activate(). - */ - engaged: (properties?: Record) => void - /** - * @deprecated Outlit derives INACTIVE from tracked activity. Keep tracking product activity - * with track() and only send activation manually with user.activate(). - */ - inactive: (properties?: Record) => void - } - customer: { - trialing: (options: BillingOptions) => void - paid: (options: BillingOptions) => void - churned: (options: BillingOptions) => void } } @@ -110,7 +93,7 @@ const outlit: OutlitGlobal & { _loaded?: boolean } = { // eslint-disable-next-line @typescript-eslint/no-explicit-any const self = this as any - // Handle namespace methods like "user.activate" or "customer.paid" + // Handle namespace methods like "user.identify" if (method.includes(".")) { const [namespace, methodName] = method.split(".") if ( @@ -221,62 +204,6 @@ const outlit: OutlitGlobal & { _loaded?: boolean } = { } outlit._instance.user.identify(options) }, - activate(properties?: Record) { - if (!outlit._initialized || !outlit._instance) { - outlit._queue.push(() => outlit.user.activate(properties)) - return - } - outlit._instance.user.activate(properties) - }, - /** - * @deprecated Outlit derives ENGAGED from tracked activity. Keep tracking product activity - * with track() and only send activation manually with user.activate(). - */ - engaged(properties?: Record) { - if (!outlit._initialized || !outlit._instance) { - outlit._queue.push(() => outlit.user.engaged(properties)) - return - } - outlit._instance.user.engaged(properties) - }, - /** - * @deprecated Outlit derives INACTIVE from tracked activity. Keep tracking product activity - * with track() and only send activation manually with user.activate(). - */ - inactive(properties?: Record) { - if (!outlit._initialized || !outlit._instance) { - outlit._queue.push(() => outlit.user.inactive(properties)) - return - } - outlit._instance.user.inactive(properties) - }, - }, - - /** - * Customer namespace helpers. - */ - customer: { - trialing(options: BillingOptions) { - if (!outlit._initialized || !outlit._instance) { - outlit._queue.push(() => outlit.customer.trialing(options)) - return - } - outlit._instance.customer.trialing(options) - }, - paid(options: BillingOptions) { - if (!outlit._initialized || !outlit._instance) { - outlit._queue.push(() => outlit.customer.paid(options)) - return - } - outlit._instance.customer.paid(options) - }, - churned(options: BillingOptions) { - if (!outlit._initialized || !outlit._instance) { - outlit._queue.push(() => outlit.customer.churned(options)) - return - } - outlit._instance.customer.churned(options) - }, }, } diff --git a/packages/browser/src/tracker.ts b/packages/browser/src/tracker.ts index 4b361878..1be16314 100644 --- a/packages/browser/src/tracker.ts +++ b/packages/browser/src/tracker.ts @@ -1,27 +1,19 @@ import { - type BillingStatus, type BrowserIdentifyOptions, type BrowserTrackOptions, - buildBillingEvent, buildCalendarEvent, buildCustomEvent, buildFormEvent, buildIdentifyEvent, buildIngestPayload, buildPageviewEvent, - buildStageEvent, - type CustomerIdentifier, DEFAULT_API_HOST, - type ExplicitJourneyStage, type PayloadCustomerIdentity, type PayloadUserIdentity, type TrackerConfig, type TrackerEvent, - validateCustomerIdentity, } from "@outlit/core" -const MAX_PENDING_STAGE_EVENTS = 10 - import { initFormTracking, initPageviewTracking, stopAutocapture } from "./autocapture" import { type CalendarBookingEvent, @@ -31,19 +23,6 @@ import { import { initSessionTracking, type SessionTracker, stopSessionTracking } from "./session-tracker" import { getConsentState, getOrCreateVisitorId, setConsentState } from "./storage" -function warnIfDerivedJourneyStage( - warnedStages: Set, - stage: ExplicitJourneyStage, -): void { - if (stage === "activated") return - if (warnedStages.has(stage)) return - warnedStages.add(stage) - - console.warn( - `[Outlit] user.${stage}() is deprecated. Outlit now derives ENGAGED and INACTIVE from tracked activity. Keep using user.activate() for product-specific activation milestones.`, - ) -} - // ============================================ // OUTLIT CLIENT // ============================================ @@ -96,23 +75,8 @@ export interface OutlitOptions extends TrackerConfig { export type UserIdentity = BrowserIdentifyOptions -export interface BillingOptions extends CustomerIdentifier { - properties?: Record -} - export interface UserMethods { identify: (options: BrowserIdentifyOptions) => void - activate: (properties?: Record) => void - /** - * @deprecated Outlit derives ENGAGED from tracked activity. Keep tracking product activity - * with track() and only send activation manually with user.activate(). - */ - engaged: (properties?: Record) => void - /** - * @deprecated Outlit derives INACTIVE from tracked activity. Keep tracking product activity - * with track() and only send activation manually with user.activate(). - */ - inactive: (properties?: Record) => void } export class Outlit { @@ -126,14 +90,8 @@ export class Outlit { private options: OutlitOptions private hasHandledExit = false private sessionTracker: SessionTracker | null = null - private warnedDerivedJourneyStages = new Set() - // User identity state for stage events private currentUser: UserIdentity | null = null private pendingUser: UserIdentity | null = null - private pendingStageEvents: Array<{ - stage: ExplicitJourneyStage - properties?: Record - }> = [] private exitCleanups: Array<() => void> = [] constructor(options: OutlitOptions) { @@ -316,9 +274,6 @@ export class Outlit { /** * Identify the current visitor. * Links the anonymous visitor to a known user. - * - * When email or userId is provided, also sets the current user identity - * for activation events. */ identify(options: BrowserIdentifyOptions): void { if (!this.isTrackingEnabled) { @@ -331,10 +286,7 @@ export class Outlit { return } - // Update currentUser if email or userId is provided - // This enables stage events after identify() is called if (options.email || options.userId) { - const hadNoUser = !this.currentUser this.currentUser = { email: options.email, userId: options.userId, @@ -342,9 +294,6 @@ export class Outlit { customerTraits: options.customerTraits, traits: options.traits, } - if (hadNoUser) { - this.flushPendingStageEvents() - } } const event = buildIdentifyEvent({ @@ -367,9 +316,7 @@ export class Outlit { * If called before tracking is enabled, the identity is stored as pending * and applied automatically when enableTracking() is called. * - * Note: Both setUser() and identify() enable stage events. The difference is - * setUser() can be called before tracking is enabled (identity is queued), - * while identify() requires tracking to be enabled first. + * Unlike identify(), setUser() can be called before tracking is enabled. */ setUser(identity: UserIdentity): void { if (!identity.email && !identity.userId) { @@ -392,7 +339,6 @@ export class Outlit { clearUser(): void { this.currentUser = null this.pendingUser = null - this.pendingStageEvents = [] } /** @@ -407,107 +353,11 @@ export class Outlit { customerId: identity.customerId, customerTraits: identity.customerTraits, }) - this.flushPendingStageEvents() } - /** - * Flush any pending stage events that were queued before user identity was set. - */ - private flushPendingStageEvents(): void { - if (this.pendingStageEvents.length === 0) return - - const events = [...this.pendingStageEvents] - this.pendingStageEvents = [] - - for (const { stage, properties } of events) { - const event = buildStageEvent({ - url: window.location.href, - referrer: document.referrer, - stage, - properties, - }) - this.enqueue(event) - } - } - - /** - * User namespace methods for contact journey stages. - */ + /** User namespace method for identity. */ readonly user: UserMethods = { identify: (options: BrowserIdentifyOptions) => this.identify(options), - activate: (properties?: Record) => - this.sendStageEvent("activated", properties), - engaged: (properties?: Record) => - this.sendStageEvent("engaged", properties), - inactive: (properties?: Record) => - this.sendStageEvent("inactive", properties), - } - - /** - * Customer namespace methods for billing status. - */ - readonly customer = { - trialing: (options: BillingOptions) => this.sendBillingEvent("trialing", options), - paid: (options: BillingOptions) => this.sendBillingEvent("paid", options), - churned: (options: BillingOptions) => this.sendBillingEvent("churned", options), - } - - /** - * Internal method to send a stage event. - */ - private sendStageEvent( - stage: ExplicitJourneyStage, - properties?: Record, - ): void { - warnIfDerivedJourneyStage(this.warnedDerivedJourneyStages, stage) - - if (!this.isTrackingEnabled) { - console.warn("[Outlit] Tracking not enabled. Call enableTracking() first.") - return - } - - if (!this.currentUser) { - if (this.pendingStageEvents.length >= MAX_PENDING_STAGE_EVENTS) { - console.warn( - `[Outlit] Pending stage event queue full (${MAX_PENDING_STAGE_EVENTS}). Call setUser() or identify() to flush queued events.`, - ) - return - } - this.pendingStageEvents.push({ stage, properties }) - return - } - - const event = buildStageEvent({ - url: window.location.href, - referrer: document.referrer, - stage, - properties, - }) - this.enqueue(event) - } - - private sendBillingEvent(status: BillingStatus, options: BillingOptions): void { - if (!this.isTrackingEnabled) { - console.warn("[Outlit] Tracking not enabled. Call enableTracking() first.") - return - } - - try { - validateCustomerIdentity(options.customerId, options.stripeCustomerId) - } catch (error) { - console.warn("[Outlit]", error instanceof Error ? error.message : error) - return - } - - const event = buildBillingEvent({ - url: window.location.href, - referrer: document.referrer, - status, - customerId: options.customerId, - stripeCustomerId: options.stripeCustomerId, - properties: options.properties, - }) - this.enqueue(event) } /** @@ -831,13 +681,9 @@ export function clearUser(): void { } /** - * Access user and customer namespaces. + * Access the user namespace. * Convenience method that uses the singleton instance. */ export function user(): Outlit["user"] { return getInstance().user } - -export function customer(): Outlit["customer"] { - return getInstance().customer -} diff --git a/packages/browser/src/vue/composables.ts b/packages/browser/src/vue/composables.ts index a2bec332..a5d2f595 100644 --- a/packages/browser/src/vue/composables.ts +++ b/packages/browser/src/vue/composables.ts @@ -1,6 +1,6 @@ import type { BrowserIdentifyOptions, BrowserTrackOptions } from "@outlit/core" import { inject, type Ref, watch } from "vue" -import type { BillingOptions, UserIdentity } from "../tracker" +import type { UserIdentity } from "../tracker" import { OutlitKey } from "./plugin" // ============================================ @@ -38,30 +38,10 @@ export interface UseOutlitReturn { clearUser: () => void /** - * User namespace methods for identity and activation. + * User namespace method for identity. */ user: { identify: (options: BrowserIdentifyOptions) => void - activate: (properties?: Record) => void - /** - * @deprecated Outlit derives ENGAGED from tracked activity. Keep tracking product activity - * with track() and only send activation manually with user.activate(). - */ - engaged: (properties?: Record) => void - /** - * @deprecated Outlit derives INACTIVE from tracked activity. Keep tracking product activity - * with track() and only send activation manually with user.activate(). - */ - inactive: (properties?: Record) => void - } - - /** - * Customer namespace methods for billing status. - */ - customer: { - trialing: (options: BillingOptions) => void - paid: (options: BillingOptions) => void - churned: (options: BillingOptions) => void } /** @@ -94,10 +74,10 @@ export interface UseOutlitReturn { * * ``` @@ -156,14 +136,6 @@ export function useOutlit(): UseOutlitReturn { clearUser, user: { identify: (options: BrowserIdentifyOptions) => outlit.value?.user.identify(options), - activate: (properties) => outlit.value?.user.activate(properties), - engaged: (properties) => outlit.value?.user.engaged(properties), - inactive: (properties) => outlit.value?.user.inactive(properties), - }, - customer: { - trialing: (options: BillingOptions) => outlit.value?.customer.trialing(options), - paid: (options: BillingOptions) => outlit.value?.customer.paid(options), - churned: (options: BillingOptions) => outlit.value?.customer.churned(options), }, isInitialized, isTrackingEnabled, diff --git a/packages/core/src/__tests__/customer-identity.test.ts b/packages/core/src/__tests__/customer-identity.test.ts index 1bf6b68a..f1d0077b 100644 --- a/packages/core/src/__tests__/customer-identity.test.ts +++ b/packages/core/src/__tests__/customer-identity.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest" import { buildCustomEvent, buildIdentifyEvent, buildIngestPayload } from "../payload" -import { validateCustomerIdentity, validateServerIdentity } from "../utils" +import { validateServerIdentity } from "../utils" describe("customer identity contract", () => { it("allows customer-only server tracking", () => { @@ -92,10 +92,4 @@ describe("customer identity contract", () => { customerTraits: { plan: "legacy-pro" }, }) }) - - it("requires a customer identifier for billing", () => { - expect(() => validateCustomerIdentity()).toThrow() - expect(() => validateCustomerIdentity("cust_123")).not.toThrow() - expect(() => validateCustomerIdentity(undefined, "cus_123")).not.toThrow() - }) }) diff --git a/packages/core/src/__tests__/types.test.ts b/packages/core/src/__tests__/types.test.ts index 2a92baea..e4ab51ec 100644 --- a/packages/core/src/__tests__/types.test.ts +++ b/packages/core/src/__tests__/types.test.ts @@ -1,8 +1,8 @@ import { describe, expect, expectTypeOf, it } from "vitest" -import { buildStageEvent } from "../payload" import type { BrowserIdentifyOptions, CustomerTraits, + EventType, IdentifyTraits, ServerIdentifyOptions, ServerTrackOptions, @@ -104,15 +104,14 @@ describe("BrowserIdentifyOptions", () => { }) }) -describe("StageEvent", () => { - it("keeps lifecycle stages distinct from customer product event names", () => { - const event = buildStageEvent({ - url: "https://example.com/onboarding", - stage: "activated", - }) +describe("EventType", () => { + it("does not accept authoritative lifecycle or billing events", () => { + // @ts-expect-error lifecycle is derived from ordinary events + const stage: EventType = "stage" + // @ts-expect-error billing is sourced from verified integrations + const billing: EventType = "billing" - expect(event.type).toBe("stage") - expect(event.stage).toBe("activated") - expect(event).not.toHaveProperty("eventName") + expect(stage).toBe("stage") + expect(billing).toBe("billing") }) }) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2d2ed23d..03b2d3bb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,7 +3,6 @@ // Payload builders export { batchEvents, - buildBillingEvent, buildCalendarEvent, buildCustomEvent, buildEngagementEvent, @@ -11,23 +10,18 @@ export { buildIdentifyEvent, buildIngestPayload, buildPageviewEvent, - buildStageEvent, MAX_BATCH_SIZE, } from "./payload" export type { - BillingEvent, - BillingStatus, BrowserIdentifyOptions, BrowserTrackOptions, CalendarEvent, CalendarProvider, CustomEvent, CustomerAttribution, - CustomerIdentifier, CustomerTraits, EngagementEvent, EventType, - ExplicitJourneyStage, FormEvent, IdentifyEvent, IdentifyTraits, @@ -40,7 +34,6 @@ export type { ServerIdentity, ServerTrackOptions, SourceType, - StageEvent, TrackerConfig, TrackerEvent, UtmParams, @@ -61,6 +54,5 @@ export { // Auto-identify utilities isValidEmail, sanitizeFormFields, - validateCustomerIdentity, validateServerIdentity, } from "./utils" diff --git a/packages/core/src/payload.ts b/packages/core/src/payload.ts index 8e5da6cf..62526dec 100644 --- a/packages/core/src/payload.ts +++ b/packages/core/src/payload.ts @@ -1,13 +1,10 @@ import { v7 as uuidv7 } from "uuid" import type { - BillingEvent, - BillingStatus, CalendarEvent, CalendarProvider, CustomEvent, CustomerTraits, EngagementEvent, - ExplicitJourneyStage, FormEvent, IdentifyEvent, IdentifyTraits, @@ -16,7 +13,6 @@ import type { PayloadCustomerIdentity, PayloadUserIdentity, SourceType, - StageEvent, TrackerEvent, } from "./types" import { extractPathFromUrl, extractUtmParams } from "./utils" @@ -226,61 +222,6 @@ export function buildEngagementEvent( } } -/** - * Build a stage event. - * Used to explicitly send customer journey stage events. - * New SDK callers should only send activated; engaged and inactive are derived - * by Outlit from tracked activity but remain accepted for wire compatibility. - * discovered/signed_up stages are inferred from identify calls. - */ -export function buildStageEvent( - params: BaseEventParams & { - stage: ExplicitJourneyStage - properties?: Record - }, -): StageEvent { - const { url, referrer, timestamp, stage, properties } = params - return { - uuid: uuidv7(), - type: "stage", - timestamp: timestamp ?? Date.now(), - url, - path: extractPathFromUrl(url), - referrer, - utm: extractUtmParams(url), - stage, - properties, - } -} - -/** - * Build a billing event. - * Used to set customer billing status (trialing, paid, churned). - */ -export function buildBillingEvent( - params: BaseEventParams & { - status: BillingStatus - customerId?: string - stripeCustomerId?: string - properties?: Record - }, -): BillingEvent { - const { url, referrer, timestamp, status, customerId, stripeCustomerId, properties } = params - return { - uuid: uuidv7(), - type: "billing", - timestamp: timestamp ?? Date.now(), - url, - path: extractPathFromUrl(url), - referrer, - utm: extractUtmParams(url), - status, - customerId, - stripeCustomerId, - properties, - } -} - // ============================================ // PAYLOAD BUILDER // ============================================ diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 5053fd79..45127629 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2,22 +2,7 @@ // EVENT TYPES // ============================================ -export type EventType = - | "pageview" - | "form" - | "identify" - | "custom" - | "calendar" - | "engagement" - | "stage" - | "billing" - -// Explicit stage event values accepted by ingest for wire compatibility. -// New SDK callers should only send "activated"; "engaged" and "inactive" -// are derived by Outlit from tracked activity. -export type ExplicitJourneyStage = "activated" | "engaged" | "inactive" - -export type BillingStatus = "trialing" | "paid" | "churned" +export type EventType = "pageview" | "form" | "identify" | "custom" | "calendar" | "engagement" export type CalendarProvider = "cal.com" | "calendly" | "unknown" @@ -125,18 +110,6 @@ export interface ServerIdentifyOptions extends ServerIdentity, CustomerAttributi customerTraits?: CustomerTraits } -/** - * Customer identity for SDK billing methods. - * Public billing calls should use `customerId`. - */ -export interface CustomerIdentifier extends CustomerAttribution { - /** - * @deprecated Stripe customer identifier. - * Billing attribution should use `customerId` publicly. - */ - stripeCustomerId?: string -} - // ============================================ // INTERNAL EVENT TYPES // These are the full event objects sent to the API @@ -206,25 +179,6 @@ export interface EngagementEvent extends BaseEvent { sessionId: string } -export interface StageEvent extends BaseEvent { - type: "stage" - /** Stage event value. New SDK callers should only send "activated". */ - stage: ExplicitJourneyStage - /** Optional properties for context */ - properties?: Record -} - -export interface BillingEvent extends BaseEvent { - type: "billing" - /** The billing status to set for a customer */ - status: BillingStatus - /** Optional customer identifiers */ - customerId?: string - stripeCustomerId?: string - /** Optional properties for context */ - properties?: Record -} - export type TrackerEvent = | PageviewEvent | FormEvent @@ -232,8 +186,6 @@ export type TrackerEvent = | CustomEvent | CalendarEvent | EngagementEvent - | StageEvent - | BillingEvent // ============================================ // INGEST PAYLOAD diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 72a4d2b0..457fa456 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -195,21 +195,6 @@ export function validateServerIdentity( } } -/** - * Validate that at least one customer identifier is provided for billing calls. - */ -export function validateCustomerIdentity(customerId?: string, stripeCustomerId?: string): void { - const hasCustomerId = customerId && customerId.trim().length > 0 - const hasStripeCustomerId = stripeCustomerId && stripeCustomerId.trim().length > 0 - - if (!hasCustomerId && !hasStripeCustomerId) { - throw new Error( - "Billing methods require customerId or stripeCustomerId. " + - "Use customerId for your system-owned account ID.", - ) - } -} - // ============================================ // AUTO-IDENTIFY: EMAIL & NAME EXTRACTION // ============================================ diff --git a/packages/node/README.md b/packages/node/README.md index e086a706..91706f03 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -2,7 +2,7 @@ Node.js tracking SDK for Outlit customer context. -Use this package to track backend events, identify users, attach customer account traits, mark lifecycle and billing status, and flush server-side event queues before shutdown. +Use this package to track ordinary backend events, identify users, attach customer account traits, and flush server-side event queues before shutdown. Core derives lifecycle from identity and activity, including the customer-selected ordinary activation event; billing comes from verified integrations. Outlit is the real-time understanding of every customer, the infrastructure agents use to automate customer operations. diff --git a/packages/node/src/__tests__/client.test.ts b/packages/node/src/__tests__/client.test.ts index fdde9502..f8890f15 100644 --- a/packages/node/src/__tests__/client.test.ts +++ b/packages/node/src/__tests__/client.test.ts @@ -114,59 +114,11 @@ describe("Outlit", () => { await outlit.shutdown() }) - it("preserves stage identity markers without customer event-name collisions", async () => { + it("exposes only ordinary identity and tracking APIs", async () => { const outlit = new Outlit({ publicKey: "pk_test", flushInterval: 60_000 }) - outlit.user.activate({ - email: "user@example.com", - userId: "usr_123", - properties: { source: "signup" }, - }) - - await outlit.flush() - - const payload = getLastPayload() - const event = payload.events[0]! - expect(event.type).toBe("stage") - expect(event).not.toHaveProperty("eventName") - expect(event.properties?.__email).toBe("user@example.com") - expect(event.properties?.__userId).toBe("usr_123") - expect(event.properties?.__fingerprint).toBeNull() - expect(event.properties?.source).toBe("signup") - - await outlit.shutdown() - }) - - it("warns when derived journey stages are sent manually", async () => { - const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) - const outlit = new Outlit({ publicKey: "pk_test", flushInterval: 60_000 }) - - outlit.user.engaged({ email: "user@example.com" }) - outlit.user.inactive({ email: "user@example.com" }) - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("user.engaged() is deprecated")) - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining("user.inactive() is deprecated"), - ) - - await outlit.shutdown() - }) - - it("publishes billing events using customerId", async () => { - const outlit = new Outlit({ publicKey: "pk_test", flushInterval: 60_000 }) - - outlit.customer.paid({ - customerId: "cust_123", - properties: { plan: "enterprise" }, - }) - - await outlit.flush() - - const payload = getLastPayload() - const event = payload.events[0]! - expect(event.type).toBe("billing") - expect(event.customerId).toBe("cust_123") - expect(event).not.toHaveProperty("customerDomain") + expect(Object.keys(outlit.user)).toEqual(["identify"]) + expect("customer" in outlit).toBe(false) await outlit.shutdown() }) diff --git a/packages/node/src/client.ts b/packages/node/src/client.ts index d8e8fd8d..591bde85 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -1,72 +1,18 @@ import { - type BillingStatus, - buildBillingEvent, buildCustomEvent, buildIdentifyEvent, - buildStageEvent, - type CustomerIdentifier, DEFAULT_API_HOST, - type ExplicitJourneyStage, type IngestPayload, type ServerIdentifyOptions, - type ServerIdentity, type ServerTrackOptions, type TrackerEvent, - validateCustomerIdentity, validateServerIdentity, } from "@outlit/core" import { EventQueue } from "./queue" import { HttpTransport, TransportError } from "./transport" -function warnIfDerivedJourneyStage( - warnedStages: Set, - stage: ExplicitJourneyStage, -): void { - if (stage === "activated") return - if (warnedStages.has(stage)) return - warnedStages.add(stage) - - console.warn( - `[Outlit] user.${stage}() is deprecated. Outlit now derives ENGAGED and INACTIVE from tracked activity. Keep using user.activate() for product-specific activation milestones.`, - ) -} - -// ============================================ -// STAGE OPTIONS -// ============================================ - -/** - * Options for user activation events. - * Server-side stage events require at least one identifier (fingerprint, email, or userId). - */ -export interface StageOptions extends ServerIdentity { - /** - * Optional properties for context. - */ - properties?: Record -} - export interface UserMethods { identify: (options: ServerIdentifyOptions) => void - activate: (options: StageOptions) => void - /** - * @deprecated Outlit derives ENGAGED from tracked activity. Keep tracking product activity - * with track() and only send activation manually with user.activate(). - */ - engaged: (options: StageOptions) => void - /** - * @deprecated Outlit derives INACTIVE from tracked activity. Keep tracking product activity - * with track() and only send activation manually with user.activate(). - */ - inactive: (options: StageOptions) => void -} - -/** - * Options for billing status events. - * Public billing calls should use customerId. - */ -export interface BillingOptions extends CustomerIdentifier { - properties?: Record } // ============================================ @@ -157,7 +103,6 @@ export class Outlit { private flushInterval: number private isShutdown = false private fatalTransportError: TransportError | null = null - private warnedDerivedJourneyStages = new Set() constructor(options: OutlitOptions) { const apiHost = options.apiHost ?? DEFAULT_API_HOST @@ -259,60 +204,9 @@ export class Outlit { this.queue.enqueue(event) } - /** - * User namespace methods for contact journey stages. - */ + /** User namespace method for identity. */ readonly user: UserMethods = { identify: (options: ServerIdentifyOptions) => this.identify(options), - activate: (options: StageOptions) => this.sendStageEvent("activated", options), - engaged: (options: StageOptions) => this.sendStageEvent("engaged", options), - inactive: (options: StageOptions) => this.sendStageEvent("inactive", options), - } - - /** - * Customer namespace methods for billing status. - */ - readonly customer = { - trialing: (options: BillingOptions) => this.sendBillingEvent("trialing", options), - paid: (options: BillingOptions) => this.sendBillingEvent("paid", options), - churned: (options: BillingOptions) => this.sendBillingEvent("churned", options), - } - - /** - * Internal method to send a stage event. - */ - private sendStageEvent(stage: ExplicitJourneyStage, options: StageOptions): void { - this.ensureNotShutdown() - validateServerIdentity(options.fingerprint, options.email, options.userId) - warnIfDerivedJourneyStage(this.warnedDerivedJourneyStages, stage) - - const event = buildStageEvent({ - url: `server://${options.email ?? options.userId ?? options.fingerprint}`, - stage, - properties: { - ...options.properties, - __fingerprint: options.fingerprint ?? null, - __email: options.email ?? null, - __userId: options.userId ?? null, - }, - }) - - this.queue.enqueue(event) - } - - private sendBillingEvent(status: BillingStatus, options: BillingOptions): void { - this.ensureNotShutdown() - validateCustomerIdentity(options.customerId, options.stripeCustomerId) - - const event = buildBillingEvent({ - url: `server://${options.customerId ?? options.stripeCustomerId}`, - status, - customerId: options.customerId, - stripeCustomerId: options.stripeCustomerId, - properties: options.properties, - }) - - this.queue.enqueue(event) } /** diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 2a70e51f..e8a3b89d 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -2,11 +2,10 @@ // Re-export useful types from core export type { - ExplicitJourneyStage, IngestResponse, ServerIdentifyOptions, ServerTrackOptions, TrackerConfig, } from "@outlit/core" -export type { BillingOptions, OutlitOptions, StageOptions, UserMethods } from "./client" +export type { OutlitOptions, UserMethods } from "./client" export { Outlit } from "./client" From e2881810adea551a42d6cd8faa71b88788ba3be7 Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 14 Jul 2026 15:17:51 -0700 Subject: [PATCH 2/3] docs: remove stale manual billing guidance --- docs/concepts/customer-context-graph.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/concepts/customer-context-graph.mdx b/docs/concepts/customer-context-graph.mdx index 4a9695b2..541563f8 100644 --- a/docs/concepts/customer-context-graph.mdx +++ b/docs/concepts/customer-context-graph.mdx @@ -88,7 +88,7 @@ Data flows in from multiple sources: Entities form relationships: - **Visitors → Contacts**: Via identity resolution (email, userId, device fingerprint) - **Contacts → Accounts**: Via organizational email domains, explicit `customerId` attribution, and connected source data -- **Accounts → Billing**: Via Stripe integration or manual status updates +- **Accounts → Billing**: Via verified billing integrations such as Stripe Each contact carries a [journey stage](/concepts/customer-journey) (Discovered → Signed Up → Activated → Engaged → Inactive) and each account carries a billing status (None → Trialing → Paying → Churned). From 7e3380a9c8f91a665d266748dacfe28249d9ec52 Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 14 Jul 2026 17:55:52 -0700 Subject: [PATCH 3/3] fix: address sdk review feedback --- docs/tracking/browser/react.mdx | 3 --- examples/pi-agents/tests/activation-pretriage.test.ts | 2 ++ packages/browser/__tests__/e2e/browser-sdk.spec.ts | 6 +++++- .../browser/__tests__/e2e/fixtures/test-page-queued.html | 4 ++-- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/tracking/browser/react.mdx b/docs/tracking/browser/react.mdx index c0937eab..331e81b6 100644 --- a/docs/tracking/browser/react.mdx +++ b/docs/tracking/browser/react.mdx @@ -448,9 +448,6 @@ function MyComponent() { // Identify the user identify({ email: 'user@example.com', traits: { plan: 'pro' } }) - // Track the ordinary event selected as your activation signal - track('onboarding_completed', { flow: 'onboarding' }) - // Get visitor ID (null if tracking not enabled) const visitorId = getVisitorId() diff --git a/examples/pi-agents/tests/activation-pretriage.test.ts b/examples/pi-agents/tests/activation-pretriage.test.ts index e16242a7..d5042568 100644 --- a/examples/pi-agents/tests/activation-pretriage.test.ts +++ b/examples/pi-agents/tests/activation-pretriage.test.ts @@ -92,6 +92,8 @@ describe("runOutlitActivationPretriage", () => { const eventActivationSql = String(queryMock.mock.calls[2]?.[1]?.sql ?? "") expect(eventActivationSql).not.toContain("stage:activated") expect(eventActivationSql).not.toContain("activationEventCount") + expect(eventActivationSql).toContain("AS recentEventCount") + expect(eventActivationSql).toContain("AS recentActiveDays") expect(eventActivationSql).toContain("parseDateTimeBestEffort('2026-04-15T12:00:00.000Z')") expect(result.kind).toBe("activation") expect(result.summary).toMatchObject({ diff --git a/packages/browser/__tests__/e2e/browser-sdk.spec.ts b/packages/browser/__tests__/e2e/browser-sdk.spec.ts index 28319502..9c7702e1 100644 --- a/packages/browser/__tests__/e2e/browser-sdk.spec.ts +++ b/packages/browser/__tests__/e2e/browser-sdk.spec.ts @@ -582,7 +582,11 @@ test.describe("Stub Snippet (Recommended Approach)", () => { ) // Both queued custom events should have been processed. expect(earlyEvents.length).toBe(2) - expect(allEvents.some((event) => event.type === "identify")).toBe(true) + expect( + allEvents.some( + (event) => event.type === "identify" && event.userId === "queued-identify-user", + ), + ).toBe(true) }) test("double-load protection prevents re-initialization", async ({ page }) => { diff --git a/packages/browser/__tests__/e2e/fixtures/test-page-queued.html b/packages/browser/__tests__/e2e/fixtures/test-page-queued.html index 2c80048e..3a5c9549 100644 --- a/packages/browser/__tests__/e2e/fixtures/test-page-queued.html +++ b/packages/browser/__tests__/e2e/fixtures/test-page-queued.html @@ -46,8 +46,8 @@

SDK Test Page - Queued Calls

// Queue calls BEFORE SDK loads - these should be processed after init window.outlit.track('early_event_1', { queued: true, order: 1 }); window.outlit.track('early_event_2', { queued: true, order: 2 }); - window.outlit.setUser({ userId: 'queued-user' }); - window.outlit.user.identify({ userId: 'queued-user' }); + window.outlit.setUser({ userId: 'queued-set-user' }); + window.outlit.user.identify({ userId: 'queued-identify-user' }); console.log('[Test] Queued 2 early events, queue length:', window.outlit._q.length);