products/product_tours/ARCHITECTURE.md
Product tours allow PostHog users to implement automated onboarding and product walkthroughs, similar to tools like Userpilot and Product Fruits.
┌─────────────────────────────────────────────────────────────────────────────┐
│ PostHog Main Repo │
│ ┌──────────────────────────────────┐ ┌─────────────────────────────────┐ │
│ │ Toolbar (Authoring) │ │ Backend (Storage) │ │
│ │ frontend/src/toolbar/ │ │ products/product_tours/backend │ │
│ │ product-tours/ │ │ │ │
│ │ │ │ - Django models │ │
│ │ - Element inspector │ │ - REST API │ │
│ │ - Step editor with rich text │ │ - Feature flag management │ │
│ │ - Tour builder UI │ │ - Public SDK endpoint │ │
│ └────────────┬─────────────────────┘ └──────────────┬──────────────────┘ │
│ │ creates/edits via API │ │
│ └────────────────────────────────────────┤ │
│ │ serves tours │
└────────────────────────────────────────────────────────┼─────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ posthog-js SDK (Display) │
│ packages/browser/src/extensions/product-tours/ │
│ │
│ - Fetches active tours from /api/product_tours │
│ - Evaluates eligibility (URL, selector, device, flags) │
│ - Renders tooltip UI with spotlight │
│ - Captures analytics events │
└─────────────────────────────────────────────────────────────────────────────┘
┌──────────────┐ ┌───────────────┐ ┌──────────────────┐ ┌───────────────┐
│ Toolbar │────▶│ Backend API │────▶│ Database + Flag │────▶│ SDK Polling │
│ (create) │ │ (CRUD) │ │ (stored) │ │ (display) │
└──────────────┘ └───────────────┘ └──────────────────┘ └───────────────┘
│
▼
┌───────────────┐
│ End User Sees │
│ Tour Tooltip │
└───────────────┘
Location: products/product_tours/backend/
| File | Purpose |
|---|---|
models.py | ProductTour Django model |
api/product_tour.py | CRUD ViewSet + public SDK endpoint |
| Field | Type | Description |
|---|---|---|
id | UUID | Primary key |
team | FK(Team) | Owner team |
name | string | Tour name (unique per team when not archived) |
description | string | Optional description |
internal_targeting_flag | FK(FeatureFlag) | Auto-created flag for targeting |
content | JSON | Steps, appearance, conditions (see schema below) |
start_date | datetime | When tour becomes active |
end_date | datetime | When tour stops |
archived | bool | Soft delete flag |
{
"steps": [
{
"selector": "#my-button",
"content": { "type": "doc", "content": [...] } // TipTap JSON format
}
],
"appearance": {
"backgroundColor": "#ffffff",
"textColor": "#1d1d1f",
"buttonColor": "#1d4aff",
"buttonTextColor": "#ffffff",
"borderRadius": 8,
"borderColor": "#e5e5e5",
"whiteLabel": false
},
"conditions": {
"url": "/dashboard",
"urlMatchType": "contains", // exact | contains | regex
"selector": "#target-element",
"deviceTypes": ["desktop", "mobile"]
}
}
| Method | Path | Description |
|---|---|---|
| GET | /api/projects/{id}/product_tours/ | List tours (authenticated) |
| POST | /api/projects/{id}/product_tours/ | Create tour |
| GET | /api/projects/{id}/product_tours/{id}/ | Get tour |
| PATCH | /api/projects/{id}/product_tours/{id}/ | Update tour |
| DELETE | /api/projects/{id}/product_tours/{id}/ | Archive tour (soft delete) |
| GET | /api/product_tours | Public endpoint for SDK (token auth) |
Each tour gets an auto-created feature flag:
product-tour-targeting-{slugified_name}-{random_id}$product_tour_completed/{tour_id} or $product_tour_dismissed/{tour_id}start_date is set and tour is not archivedLocation: frontend/src/toolbar/product-tours/
The toolbar is injected into the customer's website to allow visual tour creation.
| File | Purpose |
|---|---|
productToursLogic.ts | Kea logic for tour state, element inspection, form handling |
ProductToursToolbarMenu.tsx | Sidebar menu listing tours + "Create new" button |
ProductToursEditingBar.tsx | Bottom bar for editing tour name, steps, save/cancel |
StepEditor.tsx | Popup editor for step content + CSS selector |
ElementHighlight.tsx | Visual highlight overlay for hovered/selected elements |
ToolbarRichTextEditor.tsx | TipTap-based rich text editor for step content |
NewTourModal.tsx | Initial modal when creating a new tour |
POST /api/projects/@current/product_tours/Uses elementToActionStep() from toolbar utils to generate CSS selectors from clicked elements, considering:
Location (external repo): posthog-js/packages/browser/src/extensions/product-tours/
The SDK fetches and displays tours to end-users on the customer's website.
| File | Purpose |
|---|---|
product-tours.tsx | ProductTourManager class - main orchestrator |
product-tours-utils.ts | Eligibility checks, positioning, TipTap rendering |
product-tour.css | Styles for tooltip, spotlight, animations |
components/ProductTourTooltip.tsx | Preact component for tour UI |
start() → setInterval(1s) → evaluateAndDisplayTours()
│
▼
getActiveProductTours()
│
▼
for each tour: isTourEligible()?
│
┌─────────┴─────────┐
│ │
Yes No
│ │
▼ └──▶ skip
showTour()
│
▼
renderCurrentStep()
│
▼
ProductTourTooltip (Preact)
isTourEligible)start_date ≤ now ≤ end_dateconditions.url (exact/contains/regex)conditions.selector exists in DOMconditions.deviceTypesinternal_targeting_flag_key evaluates trueph_product_tour_completed_{id} / ph_product_tour_dismissed_{id}| Event | Description | Key Properties |
|---|---|---|
product tour shown | Tour displayed | $product_tour_id, $product_tour_name, $product_tour_render_reason |
product tour step shown | Step rendered | $product_tour_step_id, $product_tour_step_order, $product_tour_step_selector |
product tour step completed | User clicked Next | $product_tour_step_id, $product_tour_step_order |
product tour dismissed | User dismissed | $product_tour_dismiss_reason (user_clicked_skip, escape_key, user_clicked_outside) |
product tour completed | All steps done | $product_tour_steps_count |
product tour step selector failed | Selector issue | $product_tour_error (not_found, not_visible, multiple_matches) |
On tour completion:
posthog.capture('$set', {
$set: { [`$product_tour_completed/${tour_id}`]: true },
})
Exposed via posthog.productTours:
// Manually show a tour (bypasses eligibility checks, clears localStorage state)
posthog.productTours.showProductTour('tour-uuid')
// Control active tour
posthog.productTours.dismissProductTour()
posthog.productTours.nextStep()
posthog.productTours.previousStep()
// Fetch tours
posthog.productTours.getProductTours((tours) => console.log(tours))
posthog.productTours.getActiveProductTours((tours) => console.log(tours))
// Reset state (useful for testing)
posthog.productTours.resetTour('tour-uuid') // Clear completed/dismissed for one tour
posthog.productTours.resetAllTours() // Clear all completed/dismissed state
posthog.productTours.clearCache() // Clear cached tour data
Status: Not yet implemented
Will provide a dedicated UI for viewing, managing, and analyzing product tours within the PostHog app (outside the toolbar).
Planned location: frontend/src/scenes/product-tours/
Planned features:
| Context | Auth Method |
|---|---|
| Toolbar → Backend API | OAuth Bearer token (OAuthAccessTokenAuthentication) |
| SDK → Public endpoint | Project token in request |
| App UI → Backend API | Session auth (standard PostHog auth) |
linked_flag) - use existing feature flag instead of auto-createdProductTour model in models.pypython manage.py makemigrations product_toursapi/product_tour.pyproductToursLogic.tsposthog-product-tours-types.tsThe toolbar requires a specific setup because it runs inside an iframe on the customer's site:
hogli startcd frontend && pnpm build (repeat after every change)product-tours feature flag
pnpm dev in playground repo)http://localhost:8010/project/1/toolbar)https://localhost:3000)start_date set/api/product_tours responseIf you change remote_config.py and don't see updates at /array/{token}/config.js:
# In Django shell
from posthog.models.remote_config import RemoteConfig
from posthog.models.team import Team
token = 'your-token-here'
team = Team.objects.get(api_token=token)
rc, _ = RemoteConfig.objects.get_or_create(team=team)
rc.sync(force=True)