Files
donetick/src/analytics/eventSchemas.js

166 lines
5.0 KiB
JavaScript

// Every event has an explicit property allowlist. Unknown events are
// dropped entirely; unknown or mistyped properties are dropped individually.
// This is the mechanism (not just a convention) that keeps user-generated
// content and PII out of PostHog — see spec.md sections 2, 14, 22.
// Attached to every event so cohort analysis (e.g. "do Plus accounts behave
// differently") works without every call site having to pass them.
const COMMON_PROPS = {
is_plus_account: 'boolean',
circle_member_count: 'number',
}
const withCommon = props => ({ ...props, ...COMMON_PROPS })
export const EVENT_SCHEMAS = {
onboarding_started: withCommon({}),
onboarding_completed: withCommon({}),
onboarding_skipped: withCommon({}),
onboarding_option_selected: withCommon({
option: 'string',
step: 'string',
}),
chore_created: withCommon({
has_due_date: 'boolean',
has_assignee: 'boolean',
has_labels: 'boolean',
has_description: 'boolean',
has_recurrence: 'boolean',
recurrence_type: 'string',
priority: 'number',
// quick_add/voice/scan = the AddTaskModal popup; full_page/clone = the
// dedicated create page (ChoreEdit.jsx with no existing chore id).
source: 'enum:quick_add,voice,scan,full_page,clone',
}),
chore_updated: withCommon({
has_due_date: 'boolean',
has_assignee: 'boolean',
has_labels: 'boolean',
has_description: 'boolean',
has_recurrence: 'boolean',
recurrence_type: 'string',
priority: 'number',
}),
thing_created: withCommon({}),
project_created: withCommon({}),
filter_created: withCommon({}),
localization_setting_changed: withCommon({
setting: 'enum:language,date_format,time_format,first_day_of_week',
value: 'string',
}),
analytics_enabled: withCommon({
source: 'enum:onboarding,settings',
}),
feedback_prompt_shown: withCommon({
source: 'enum:auto,settings',
shown_count: 'number',
}),
feedback_prompt_dismissed: withCommon({
source: 'enum:auto,settings',
shown_count: 'number',
}),
feedback_sentiment_selected: withCommon({
sentiment: 'enum:love,okay,issues',
}),
feedback_review_action: withCommon({
action: 'enum:github,appStore,playStore',
}),
feedback_submitted: withCommon({
category:
'enum:bugs,missingFeature,tooComplicated,slow,notifications,ai,other',
has_message: 'boolean',
result: 'enum:sent,failed,unconfigured,misconfigured,self-hosted',
}),
}
export const ERROR_SCHEMAS = {
api_error: {
http_status: 'string',
method: 'string',
error_code: 'string',
operation: 'string',
},
// No message/stack field here by design — those come from the real Error
// object passed to posthog.captureException() itself, not from this
// sanitized properties bag. This schema only classifies how it was caught.
frontend_error: {
source: 'enum:window_error,unhandled_rejection',
},
}
const MAX_STRING_LENGTH = 200
// Defense in depth: operation/endpoint-like strings must never carry a query
// string even though callers are expected to have already stripped one.
const containsQueryOrDisallowedChars = value => /[?&=]/.test(value)
const isValidValue = (value, type) => {
if (value === null || value === undefined) return false
if (type === 'boolean') return typeof value === 'boolean'
if (type === 'number') return typeof value === 'number' && !isNaN(value)
if (type.startsWith('enum:')) {
const allowed = type.slice('enum:'.length).split(',')
return typeof value === 'string' && allowed.includes(value)
}
if (type === 'string') {
return (
typeof value === 'string' &&
value.length <= MAX_STRING_LENGTH &&
!containsQueryOrDisallowedChars(value)
)
}
return false
}
/**
* Drops the whole event if its name isn't recognized, then drops any
* property that isn't in the schema or fails its type/enum check. Never
* throws — a malformed call site loses data, it never crashes the app.
*/
export const sanitizeProperties = (schemas, eventName, properties = {}) => {
const schema = schemas[eventName]
if (!schema) {
if (import.meta.env.DEV) {
console.warn(`analytics: unknown event "${eventName}", dropping`)
}
return null
}
const sanitized = {}
for (const [key, value] of Object.entries(properties || {})) {
const type = schema[key]
if (!type) {
if (import.meta.env.DEV) {
console.warn(
`analytics: dropping unknown property "${key}" on "${eventName}"`,
)
}
continue
}
if (!isValidValue(value, type)) {
if (import.meta.env.DEV) {
console.warn(
`analytics: dropping invalid property "${key}" on "${eventName}"`,
)
}
continue
}
sanitized[key] = value
}
return sanitized
}
export const sanitizeEventProperties = (eventName, properties) =>
sanitizeProperties(EVENT_SCHEMAS, eventName, properties)
export const sanitizeErrorProperties = (errorType, properties) =>
sanitizeProperties(ERROR_SCHEMAS, errorType, properties)