kansoku-api-0.2.0.0: src/Kansoku/Event/Validation.hs
-- | Pure validation for analytics events.
module Kansoku.Event.Validation
( ValidationError (..),
renderValidationError,
validationReason,
declaredSchemaError,
validateEvent,
validateEventWith,
governedTraitEligible,
)
where
import Data.Aeson (Value (..))
import Data.Aeson.Key qualified as Key
import Data.Aeson.KeyMap qualified as KeyMap
import Data.Char (isAsciiLower, isDigit)
import Data.Generics.Labels ()
import Data.Map.Strict qualified as Map
import Data.Scientific qualified as Scientific
import Data.Set qualified as Set
import Data.Text qualified as Text
import Data.Time.Format (defaultTimeLocale, formatTime)
import Kansoku.Event.Registry
import Kansoku.Event.Types
import Kansoku.Prelude
data ValidationError
= EmptyEventId
| EventIdConflict
| EmptyEventName
| ReceivedBeforeOccurred UTCTime UTCTime
| UnsupportedTimestamp Text UTCTime
| InvalidSchemaVersion Int
| UnknownSchemaVersion EventName Int [Int]
| UnknownIdentifierKind Text [Text]
| EmptyIdentifierValue Text
| IdentifierValueTooLong Text Int Int
| IdentifierValueBadCharset Text
| MissingRequiredProperty EventName Text
| WrongPropertyType EventName Text PropertyType
| InvalidPropertyValue EventName Text Text
| InvalidIdentityBinding Text
| IncompatibleJsonPaths Text
deriving stock (Generic, Eq, Show)
-- | Validate against an explicit registry. Unknown event names are allowed; the
-- registry adds stricter property checks for names it knows.
validateEventWith :: EventRegistry -> AnalyticsEvent -> Either [ValidationError] AnalyticsEvent
validateEventWith registry event =
case structuralErrors <> identifierErrors <> schemaErrors <> identityErrors of
[] -> Right event
errs -> Left errs
where
structuralErrors =
[EmptyEventId | unEventId ((event ^. #eventId)) == ""]
<> [EmptyEventName | unEventName ((event ^. #eventName)) == ""]
<> [ ReceivedBeforeOccurred ((event ^. #occurredAt)) ((event ^. #receivedAt))
| (event ^. #receivedAt) < (event ^. #occurredAt)
]
<> [ UnsupportedTimestamp field value
| (field, value) <- [("occurredAt", event ^. #occurredAt), ("receivedAt", event ^. #receivedAt)],
value < read "1970-01-01 00:00:00 UTC" || value >= read "2100-01-01 00:00:00 UTC"
]
<> [ InvalidSchemaVersion version
| Just version <- [event ^. #schemaVersion],
not (validSchemaVersion version)
]
<> [ IncompatibleJsonPaths field
| (field, object) <- [("properties", event ^. #properties), ("context", event ^. #context)],
incompatibleObject object
]
identifierErrors =
case (event ^. #identifiers) of
Nothing -> []
Just ids -> concatMap identifierError (Map.toList ids)
identifierError (kind, value) =
[ UnknownIdentifierKind kind allowedIdentifierKindNames
| isNothing (identifierKindFromText kind)
]
<> [EmptyIdentifierValue kind | Text.null value]
<> [ IdentifierValueTooLong kind (Text.length value) maxIdentifierValueLength
| Text.length value > maxIdentifierValueLength
]
<> [ IdentifierValueBadCharset kind
| not (Text.all isIdentifierValueChar value)
]
schemaErrors =
case lookupVersions ((event ^. #eventName)) registry of
Nothing -> []
Just _
| not (maybe True validSchemaVersion (event ^. #schemaVersion)) -> []
| otherwise ->
let requested = fromMaybe 1 (event ^. #schemaVersion)
in case lookupSchemaVersion ((event ^. #eventName)) requested registry of
Nothing -> [UnknownSchemaVersion ((event ^. #eventName)) requested (knownVersions ((event ^. #eventName)) registry)]
Just schema -> concatMap validateProperty ((schema ^. #properties))
identityErrors = case unEventName (event ^. #eventName) of
"alias" ->
[ InvalidIdentityBinding field
| (field, top) <- [("previousId", unAnonymousId <$> event ^. #anonymousId), ("newId", unUserId <$> event ^. #userId)],
case (top, KeyMap.lookup (Key.fromText field) (event ^. #properties)) of
(Just value, Just (String property)) -> Text.null value || value /= property
_ -> True
]
"identify" | fromMaybe 1 (event ^. #schemaVersion) == 2 -> traitIdentityErrors
"identify_once" -> traitIdentityErrors
"group" -> [InvalidIdentityBinding "organizationId" | maybe True (Text.null . unOrganizationId) (event ^. #organizationId)]
_ -> []
traitIdentityErrors = [InvalidIdentityBinding "userId" | maybe True (Text.null . unUserId) (event ^. #userId)]
validateProperty :: PropertySpec -> [ValidationError]
validateProperty spec =
case KeyMap.lookup (Key.fromText ((spec ^. #name))) ((event ^. #properties)) of
Nothing
| (spec ^. #required) -> [MissingRequiredProperty ((event ^. #eventName)) ((spec ^. #name))]
| otherwise -> []
Just value
| not (propertyMatches ((spec ^. #propertyType)) value) ->
[WrongPropertyType ((event ^. #eventName)) ((spec ^. #name)) ((spec ^. #propertyType))]
| not (propertyPatternMatches ((spec ^. #validationPattern)) value) ->
[InvalidPropertyValue ((event ^. #eventName)) ((spec ^. #name)) ("expected " <> maybe "valid value" propertyPatternText (spec ^. #validationPattern))]
| otherwise -> []
-- | Server-owned eligibility. Never infer governed state from legacy v1
-- identify data or from Float64-rounded native JSON values.
governedTraitEligible :: AnalyticsEvent -> Bool
governedTraitEligible event =
let operation = (unEventName (event ^. #eventName), fromMaybe 1 (event ^. #schemaVersion))
in operation `elem` [("identify", 2), ("identify_once", 1), ("group", 2)] && case validateEvent event of
Right _ -> True
Left _ -> False
validateEvent :: AnalyticsEvent -> Either [ValidationError] AnalyticsEvent
validateEvent = validateEventWith defaultRegistry
-- | Native JSON flattens object keys with dots. Only non-null leaves occupy
-- paths (arrays, including empty arrays, are leaves). Prefixes may coexist.
-- Each object inside an array starts an independent path scope, even in mixed
-- or nested arrays. Do not merge paths across array elements.
-- Keep diagnostics bounded: at most one fixed error per public object, with no
-- producer-controlled keys or values copied into responses or telemetry.
incompatibleObject :: KeyMap.KeyMap Value -> Bool
incompatibleObject object = duplicate Set.empty (leaves Nothing (Object object))
where
duplicate _ [] = False
duplicate seen ((path, value) : rest) =
Set.member path seen || incompatibleArray value || duplicate (Set.insert path seen) rest
leaves prefix (Object fields) = concatMap (\(key, value) -> leaves (Just (maybe "" (<> ".") prefix <> Key.toText key)) value) (KeyMap.toList fields)
leaves _ Null = []
leaves (Just path) value = [(path, value)]
leaves Nothing _ = []
incompatibleArray (Array values) = any incompatibleValue values
incompatibleArray _ = False
incompatibleValue (Object fields) = incompatibleObject fields
incompatibleValue value = incompatibleArray value
-- | Render a validation failure for logs and rejection responses. Callers may
-- show this text to people, but must use a rejected event's index for machine
-- correlation rather than parsing this prose.
renderValidationError :: ValidationError -> Text
renderValidationError = \case
EmptyEventId ->
"eventId must not be empty"
EventIdConflict ->
"event_id_conflict: eventId already identifies different producer content"
IncompatibleJsonPaths field ->
field <> " contains incompatible JSON paths: non-null values must have distinct flattened paths"
EmptyEventName ->
"eventName must not be empty"
ReceivedBeforeOccurred occurred received ->
"receivedAt ("
<> renderTimestamp received
<> ") precedes occurredAt ("
<> renderTimestamp occurred
<> "); occurredAt is in the future relative to the server clock"
InvalidIdentityBinding field ->
"invalid top-level identity binding for " <> field
UnsupportedTimestamp field _ ->
field <> " must be in the supported UTC range [1970-01-01, 2100-01-01)"
InvalidSchemaVersion version ->
"schemaVersion "
<> Text.pack (show version)
<> " is outside the supported range 1..65535"
UnknownSchemaVersion name requested known ->
"event '"
<> unEventName name
<> "': schemaVersion "
<> Text.pack (show requested)
<> " is unknown; known versions: "
<> Text.pack (show known)
UnknownIdentifierKind kind allowed ->
"identifier kind '"
<> kind
<> "' is not supported; allowed kinds: "
<> Text.intercalate ", " allowed
EmptyIdentifierValue kind ->
"identifier '" <> kind <> "' must not be empty"
IdentifierValueTooLong kind actual maxAllowed ->
"identifier '"
<> kind
<> "' is "
<> Text.pack (show actual)
<> " characters; maximum is "
<> Text.pack (show maxAllowed)
IdentifierValueBadCharset kind ->
"identifier '"
<> kind
<> "' must contain only printable non-space ASCII characters"
MissingRequiredProperty name propertyName ->
"event '"
<> unEventName name
<> "': missing required property '"
<> propertyName
<> "'"
WrongPropertyType name propertyName expected ->
"event '"
<> unEventName name
<> "': property '"
<> propertyName
<> "' must be of type "
<> renderPropertyType expected
InvalidPropertyValue name propertyName rule ->
"event '"
<> unEventName name
<> "': property '"
<> propertyName
<> "' has an invalid value; "
<> rule
where
renderTimestamp = Text.pack . formatTime defaultTimeLocale "%FT%T%QZ"
maxIdentifierValueLength :: Int
maxIdentifierValueLength = 256
validSchemaVersion :: Int -> Bool
validSchemaVersion version = version >= 1 && version <= 65535
isIdentifierValueChar :: Char -> Bool
isIdentifierValueChar char = char > ' ' && char <= '~'
renderPropertyType :: PropertyType -> Text
renderPropertyType = \case
PTString -> "string"
PTNumber -> "number"
PTBool -> "boolean"
PTObject -> "object"
PTArray -> "array"
PTAny -> "any"
propertyMatches :: PropertyType -> Value -> Bool
propertyMatches expected value =
case expected of
PTString -> case value of
String _ -> True
_ -> False
PTNumber -> case value of
Number _ -> True
_ -> False
PTBool -> case value of
Bool _ -> True
_ -> False
PTObject -> case value of
Object _ -> True
_ -> False
PTArray -> case value of
Array _ -> True
_ -> False
PTAny -> True
propertyPatternMatches :: Maybe PropertyPattern -> Value -> Bool
propertyPatternMatches Nothing _ = True
propertyPatternMatches (Just (BoundedStringPattern limit)) (String value) = not (Text.null value) && Text.length value <= limit
propertyPatternMatches (Just WebVitalMetricPattern) (String value) = value `elem` ["LCP", "INP", "CLS", "FCP", "TTFB"]
propertyPatternMatches (Just NonEmptyStringPattern) (String value) = not (Text.null value)
propertyPatternMatches (Just NonNegativeNumberPattern) (Number value) = value >= 0
propertyPatternMatches (Just NonNegativeIntegerPattern) (Number value) =
let numeric = Scientific.toRealFloat value :: Double
in not (isInfinite numeric || isNaN numeric) && value >= 0 && Scientific.isInteger value
propertyPatternMatches (Just SqlNamePattern) (String value) = isSqlSafeName value
propertyPatternMatches (Just _) _ = False
-- Keep this predicate aligned with Kansoku.Metrics.Query.Validate.mkSqlName.
-- It lives in kansoku-api because this package cannot depend on kansoku-core.
isSqlSafeName :: Text -> Bool
isSqlSafeName value =
not (Text.null value)
&& Text.length value <= 128
&& Text.all allowed value
where
allowed char =
isAsciiLower char
|| isDigit char
|| char `elem` ("_.:-" :: String)
-- | Finite reason codes: never derive telemetry labels from payload values.
validationReason :: ValidationError -> Text
validationReason = \case
EmptyEventId -> "empty_event_id"
EventIdConflict -> "event_id_conflict"
IncompatibleJsonPaths {} -> "incompatible_json_paths"
EmptyEventName -> "empty_event_name"
ReceivedBeforeOccurred {} -> "future_timestamp"
UnsupportedTimestamp {} -> "unsupported_timestamp"
InvalidSchemaVersion {} -> "invalid_schema_version"
UnknownSchemaVersion {} -> "unknown_schema_version"
UnknownIdentifierKind {} -> "unknown_identifier_kind"
EmptyIdentifierValue {} -> "empty_identifier_value"
IdentifierValueTooLong {} -> "identifier_value_too_long"
IdentifierValueBadCharset {} -> "identifier_value_bad_charset"
MissingRequiredProperty {} -> "missing_required_property"
WrongPropertyType {} -> "wrong_property_type"
InvalidPropertyValue {} -> "invalid_property_value"
InvalidIdentityBinding {} -> "invalid_identity_binding"
declaredSchemaError :: ValidationError -> Bool
declaredSchemaError = \case
UnknownSchemaVersion {} -> True
InvalidSchemaVersion {} -> True
MissingRequiredProperty {} -> True
WrongPropertyType {} -> True
InvalidPropertyValue {} -> True
InvalidIdentityBinding {} -> True
_ -> False