协作头脑风暴
brainstorming
在实现前澄清需求、探索设计选择,并形成清晰的开发方向。
skill-077【开发与工程】Comprehensive guide for implementing Fullstory's User Properties API (setProperties with type 'user') for web applications. Teaches proper property naming, type handling, incremental updates, and special fields (displayName, email). Includes detailed good/bad examples for CRM integration, progressive profiling, and subscription tracking to help devel…
独立 GitHub 快照 · 自动分类 · 以原作者文档为准
Fullstory's User Properties API allows developers to capture custom user data that enriches user profiles for search, filtering, segmentation, and analytics. Unlike setIdentity which links a session to a known user ID, setProperties with type: 'user' lets you add or update attributes about any user - including anonymous users.
Important: Every new browser/device starts as an anonymous user, tracked via the
fs_uidfirst-party cookie (1-year expiry). You can set user properties on anonymous users before they ever identify. These properties persist across sessions and transfer when/if the user later identifies viasetIdentity.
Key use cases:
| API | Purpose | When to Use | Works for Anonymous? |
|---|---|---|---|
setIdentity | Link session to a known user ID + optional initial properties | Login, authentication | No (converts anonymous → identified) |
setProperties (user) | Add/update properties for the current user | Anytime - works for anonymous AND identified users | Yes ✅ |
Key Distinction: Use
setIdentitywhen you need to link a session to a known user (requires auid). UsesetPropertieswhen you just want to add or update attributes about the current user - this works for both identified AND anonymous users.
Every user starts as anonymous, tracked via the fs_uid first-party cookie:
fs_uid cookie (1-year expiry) that tracks the same anonymous user across sessions and page viewssetProperties to add attributes to anonymous userssetIdentity is called, ALL previous sessions (linked by the cookie) merge into the identified userReference: Why Fullstory uses First-Party Cookies
// User lands on your site (anonymous - "User 4521" in Fullstory)
FS('setProperties', {
type: 'user',
properties: {
landing_page: '/pricing',
referral_source: 'google_ads',
campaign: 'spring_sale_2024',
},
})
// ... user browses for a while ...
// Later, user creates an account and logs in
FS('setIdentity', {
uid: 'user_abc123',
properties: {
displayName: 'Jane Smith',
email: 'jane@example.com',
},
})
// The anonymous properties (landing_page, referral_source, campaign)
// are now attached to the identified user "Jane Smith"
User logs in → setIdentity({ uid: "user_123", properties: { displayName: "Jane" } })
↓
User updates profile → setProperties({ type: 'user', properties: { plan: "pro" } })
↓
User upgrades plan → setProperties({ type: 'user', properties: { plan: "enterprise" } })
For anonymous users (not yet logged in):
// User hasn't logged in yet, but we know something about them
FS('setProperties', {
type: 'user',
properties: {
visitor_type: 'returning',
referral_source: 'google_ads',
landing_page: '/pricing',
},
})
// These properties will be associated with the anonymous user
// and will persist if/when they later identify
| Field | Behavior |
|---|---|
displayName | Shown in session list and user card in Fullstory UI |
email | Enables email-based search and HTTP API lookups |
FS('setProperties', {
type: 'user',
properties: object, // Required: Key/value pairs
schema?: object // Optional: Type hints
});
| Parameter | Type | Required | Description |
|---|---|---|---|
type | string | Yes | Must be 'user' for user properties |
properties | object | Yes | Key/value pairs of user data |
schema | object | No | Explicit type inference for properties |
| Type | Description | Examples |
|---|---|---|
str | String value | "premium", "enterprise" |
strs | Array of strings | ["admin", "beta-tester"] |
int | Integer | 42, -5, 0 |
ints | Array of integers | [1, 2, 3] |
real | Float/decimal | 99.99, -3.14 |
reals | Array of reals | [10.5, 20.0] |
bool | Boolean | true, false |
bools | Array of booleans | [true, false, true] |
date | ISO8601 date | "2024-01-15T00:00:00Z" |
dates | Array of dates | ["2024-01-01", "2024-02-01"] |
// GOOD: Add properties after initial identification
// Step 1: Identify user on login (minimal properties)
FS('setIdentity', {
uid: user.id,
properties: {
displayName: user.name,
email: user.email,
},
})
// Step 2: Enrich with additional data once loaded
async function loadUserProfile() {
const profile = await fetchUserProfile(user.id)
FS('setProperties', {
type: 'user',
properties: {
companyName: profile.company.name,
companySize: profile.company.employeeCount,
industry: profile.company.industry,
role: profile.role,
department: profile.department,
signupSource: profile.attribution.source,
referralCode: profile.attribution.referralCode,
},
})
}
Why this is good:
// GOOD: Track subscription changes without re-identifying
async function handlePlanUpgrade(newPlan) {
// Process the upgrade
await processUpgrade(newPlan)
// Update user properties to reflect new plan
FS('setProperties', {
type: 'user',
properties: {
plan: newPlan.name,
planTier: newPlan.tier,
monthlyPrice: newPlan.price,
billingCycle: newPlan.billingCycle,
planChangedAt: new Date().toISOString(),
previousPlan: getCurrentPlan().name,
},
schema: {
monthlyPrice: 'real',
planChangedAt: 'date',
},
})
// Also track as an event for funnel analysis
FS('trackEvent', {
name: 'Plan Upgraded',
properties: {
fromPlan: getCurrentPlan().name,
toPlan: newPlan.name,
priceDifference: newPlan.price - getCurrentPlan().price,
},
})
}
Why this is good:
// GOOD: Build up user profile through onboarding steps
class OnboardingFlow {
// Step 1: Basic info collected
completeBasicInfo(data) {
FS('setProperties', {
type: 'user',
properties: {
companyName: data.companyName,
companySize: data.companySize,
onboardingStep: 1,
onboardingStartedAt: new Date().toISOString(),
},
schema: {
onboardingStep: 'int',
onboardingStartedAt: 'date',
},
})
}
// Step 2: Use case selection
completeUseCaseSelection(useCases) {
FS('setProperties', {
type: 'user',
properties: {
primaryUseCase: useCases.primary,
secondaryUseCases: useCases.secondary, // Array of strings
onboardingStep: 2,
},
schema: {
secondaryUseCases: 'strs',
onboardingStep: 'int',
},
})
}
// Step 3: Integration setup
completeIntegrationSetup(integrations) {
FS('setProperties', {
type: 'user',
properties: {
connectedIntegrations: integrations.connected,
integrationCount: integrations.connected.length,
onboardingStep: 3,
},
schema: {
connectedIntegrations: 'strs',
integrationCount: 'int',
onboardingStep: 'int',
},
})
}
// Final step: Mark complete
completeOnboarding() {
FS('setProperties', {
type: 'user',
properties: {
onboardingComplete: true,
onboardingCompletedAt: new Date().toISOString(),
onboardingStep: 4,
},
schema: {
onboardingComplete: 'bool',
onboardingCompletedAt: 'date',
onboardingStep: 'int',
},
})
}
}
Why this is good:
// GOOD: Track feature adoption at user level
class FeatureUsageTracker {
trackFeatureFirstUse(featureName) {
const propertyName = `firstUsed_${featureName}`
FS('setProperties', {
type: 'user',
properties: {
[propertyName]: new Date().toISOString(),
},
schema: {
[propertyName]: 'date',
},
})
}
updateFeatureEngagement(features) {
FS('setProperties', {
type: 'user',
properties: {
featuresUsed: features.used,
mostUsedFeature: features.mostUsed,
featureUsageScore: features.engagementScore,
lastActiveFeature: features.lastUsed,
lastFeatureUseAt: new Date().toISOString(),
},
schema: {
featuresUsed: 'strs',
featureUsageScore: 'int',
lastFeatureUseAt: 'date',
},
})
}
}
// Usage
const tracker = new FeatureUsageTracker()
tracker.trackFeatureFirstUse('advanced_export')
tracker.updateFeatureEngagement({
used: ['dashboard', 'reports', 'advanced_export'],
mostUsed: 'reports',
engagementScore: 85,
lastUsed: 'advanced_export',
})
Why this is good:
// GOOD: Sync key CRM fields to Fullstory
async function syncCRMData(userId) {
const crmData = await fetchFromCRM(userId)
FS('setProperties', {
type: 'user',
properties: {
// Sales/Account info
accountOwner: crmData.owner.name,
accountStage: crmData.stage,
dealValue: crmData.opportunity.value,
closeDate: crmData.opportunity.expectedClose,
// Health metrics
healthScore: crmData.health.score,
churnRisk: crmData.health.churnRisk,
npsScore: crmData.health.nps,
// Engagement
lastContactDate: crmData.lastContact,
meetingsScheduled: crmData.meetings.scheduled,
supportTicketsOpen: crmData.support.openTickets,
// Sync metadata
crmSyncedAt: new Date().toISOString(),
},
schema: {
dealValue: 'real',
closeDate: 'date',
healthScore: 'int',
churnRisk: 'real',
npsScore: 'int',
lastContactDate: 'date',
meetingsScheduled: 'int',
supportTicketsOpen: 'int',
crmSyncedAt: 'date',
},
})
}
Why this is good:
// BAD: Trying to use setProperties for initial identification
FS('setProperties', {
type: 'user',
properties: {
uid: user.id, // This won't work!
displayName: user.name,
email: user.email,
},
})
Why this is bad:
CORRECTED VERSION:
// GOOD: Use setIdentity for identification
FS('setIdentity', {
uid: user.id,
properties: {
displayName: user.name,
email: user.email,
},
})
// BAD: Setting user properties before user is identified
function updateUserPreferences(preferences) {
// This won't persist properly if user is anonymous!
FS('setProperties', {
type: 'user',
properties: {
theme: preferences.theme,
language: preferences.language,
},
})
}
Why this is bad:
CORRECTED VERSION:
// GOOD: Check identification status first
function updateUserPreferences(preferences) {
// Only set user properties if identified
if (isUserIdentified()) {
FS('setProperties', {
type: 'user',
properties: {
theme: preferences.theme,
language: preferences.language,
},
})
}
// For anonymous users, consider page properties or just skip
}
// BAD: Calling setProperties too frequently
function handleFormFieldChange(fieldName, value) {
// BAD: This fires on every keystroke!
FS('setProperties', {
type: 'user',
properties: {
[`form_${fieldName}`]: value,
},
})
}
Why this is bad:
CORRECTED VERSION:
// GOOD: Batch updates on form submission
function handleFormSubmit(formData) {
// Set meaningful final values
FS('setProperties', {
type: 'user',
properties: {
preferredContact: formData.contactMethod,
marketingOptIn: formData.optIn,
timezone: formData.timezone,
},
})
// Track the form submission as event
FS('trackEvent', {
name: 'Preferences Updated',
properties: formData,
})
}
// BAD: Missing type parameter
FS('setProperties', {
properties: {
plan: 'premium',
},
// Missing type: 'user'!
})
Why this is bad:
type parameterCORRECTED VERSION:
// GOOD: Include type parameter
FS('setProperties', {
type: 'user', // Required!
properties: {
plan: 'premium',
},
})
// BAD: Incorrect value formats
FS('setProperties', {
type: 'user',
properties: {
accountBalance: '$1,234.56', // BAD: Formatted currency
loginCount: 'forty-two', // BAD: Written number
isPremium: 'yes', // BAD: String instead of boolean
signupDate: 'January 15, 2024', // BAD: Not ISO8601
},
schema: {
accountBalance: 'real',
loginCount: 'int',
isPremium: 'bool',
signupDate: 'date',
},
})
Why this is bad:
CORRECTED VERSION:
// GOOD: Properly formatted values
FS('setProperties', {
type: 'user',
properties: {
accountBalance: 1234.56,
currency: 'USD', // Separate field for formatting
loginCount: 42,
isPremium: true,
signupDate: '2024-01-15T00:00:00Z',
},
schema: {
accountBalance: 'real',
loginCount: 'int',
isPremium: 'bool',
signupDate: 'date',
},
})
// BAD: Carelessly overwriting displayName
function updateLastActivity() {
FS('setProperties', {
type: 'user',
properties: {
displayName: 'Active User', // BAD: Overwrites the real name!
lastActivityAt: new Date().toISOString(),
},
})
}
Why this is bad:
CORRECTED VERSION:
// GOOD: Only update intended properties
function updateLastActivity() {
FS('setProperties', {
type: 'user',
properties: {
lastActivityAt: new Date().toISOString(),
isRecentlyActive: true,
},
schema: {
lastActivityAt: 'date',
isRecentlyActive: 'bool',
},
})
}
// Centralized user property management
class UserPropertyManager {
constructor() {
this.pendingProperties = {}
this.flushTimeout = null
}
// Queue properties for batched update
queue(properties, schema = {}) {
Object.assign(this.pendingProperties, properties)
// Debounce to batch rapid updates
if (this.flushTimeout) clearTimeout(this.flushTimeout)
this.flushTimeout = setTimeout(() => this.flush(), 1000)
}
// Immediately send properties
flush() {
if (Object.keys(this.pendingProperties).length === 0) return
FS('setProperties', {
type: 'user',
properties: this.pendingProperties,
})
this.pendingProperties = {}
this.flushTimeout = null
}
// Update specific category of properties
updateEngagement(data) {
this.queue({
lastActiveAt: new Date().toISOString(),
sessionCount: data.sessionCount,
pageViewsTotal: data.pageViews,
})
}
updateSubscription(plan) {
// Subscription updates are important - flush immediately
FS('setProperties', {
type: 'user',
properties: {
plan: plan.name,
planTier: plan.tier,
planUpdatedAt: new Date().toISOString(),
},
})
}
}
// Sync important properties on each page load
async function syncUserProperties() {
const user = await getCurrentUser()
if (!user) return
// Fetch latest data
const [profile, subscription, usage] = await Promise.all([
fetchProfile(user.id),
fetchSubscription(user.id),
fetchUsageStats(user.id),
])
// Sync to Fullstory
FS('setProperties', {
type: 'user',
properties: {
// Profile
displayName: profile.fullName,
email: profile.email,
role: profile.role,
// Subscription
plan: subscription.plan,
planStatus: subscription.status,
mrr: subscription.mrr,
// Usage
lastLoginAt: usage.lastLogin,
totalLogins: usage.loginCount,
daysActive: usage.activeDays,
},
schema: {
mrr: 'real',
lastLoginAt: 'date',
totalLogins: 'int',
daysActive: 'int',
},
})
}
// Run on app initialization
initApp().then(syncUserProperties)
// Update properties based on key events
const eventPropertyMap = {
trial_started: (event) => ({
trialStartedAt: new Date().toISOString(),
trialPlan: event.plan,
isTrialing: true,
}),
trial_converted: (event) => ({
trialConvertedAt: new Date().toISOString(),
isTrialing: false,
isPaying: true,
plan: event.plan,
}),
trial_expired: (event) => ({
trialExpiredAt: new Date().toISOString(),
isTrialing: false,
isPaying: false,
}),
feature_enabled: (event) => ({
[`feature_${event.feature}_enabled`]: true,
[`feature_${event.feature}_enabledAt`]: new Date().toISOString(),
}),
}
function handleBusinessEvent(eventName, eventData) {
// Track the event
FS('trackEvent', {
name: eventName,
properties: eventData,
})
// Update user properties if mapping exists
const propertyUpdater = eventPropertyMap[eventName]
if (propertyUpdater) {
FS('setProperties', {
type: 'user',
properties: propertyUpdater(eventData),
})
}
}
// Initial identification with core properties
FS('setIdentity', {
uid: user.id,
properties: {
displayName: user.name,
email: user.email,
},
})
// Later: add more properties
FS('setProperties', {
type: 'user',
properties: {
company: companyData.name,
role: roleData.title,
},
})
// User properties: persist across sessions, about the person
FS('setProperties', {
type: 'user',
properties: {
plan: 'enterprise',
accountAge: 365,
},
})
// Page properties: session-scoped, about the current context
FS('setProperties', {
type: 'page',
properties: {
pageName: 'Dashboard',
filters: ['active', 'recent'],
},
})
// Properties: current state (what IS)
FS('setProperties', {
type: 'user',
properties: {
plan: 'professional',
seats: 10,
},
})
// Events: actions/changes (what HAPPENED)
FS('trackEvent', {
name: 'Plan Upgraded',
properties: {
from: 'starter',
to: 'professional',
seatChange: 5,
},
})
Symptom: User properties don't show in Fullstory
Common Causes:
type: 'user' parameterSolutions:
type: 'user'Symptom: Property values are incorrect or unexpected type
Common Causes:
Solutions:
Symptom: User's display name changes unexpectedly
Common Causes:
Solutions:
When helping developers implement User Properties:
Always emphasize:
type: 'user' parameterCommon mistakes to watch for:
Questions to ask developers:
Best practices to recommend:
This skill document was created to help Agent understand and guide developers in implementing Fullstory's User Properties API correctly for web applications.