1086 lines
38 KiB
Python
1086 lines
38 KiB
Python
|
|
import atexit
|
||
|
|
import logging
|
||
|
|
import numbers
|
||
|
|
import os
|
||
|
|
import platform
|
||
|
|
import sys
|
||
|
|
import warnings
|
||
|
|
from datetime import datetime, timedelta
|
||
|
|
from typing import Any
|
||
|
|
from uuid import UUID, uuid4
|
||
|
|
|
||
|
|
import distro # For Linux OS detection
|
||
|
|
from dateutil.tz import tzutc
|
||
|
|
from six import string_types
|
||
|
|
|
||
|
|
from posthog.consumer import Consumer
|
||
|
|
from posthog.exception_capture import ExceptionCapture
|
||
|
|
from posthog.exception_utils import exc_info_from_error, exceptions_from_error_tuple, handle_in_app
|
||
|
|
from posthog.feature_flags import InconclusiveMatchError, match_feature_flag_properties
|
||
|
|
from posthog.poller import Poller
|
||
|
|
from posthog.request import DEFAULT_HOST, APIError, batch_post, decide, determine_server_host, get, remote_config
|
||
|
|
from posthog.utils import SizeLimitedDict, clean, guess_timezone, remove_trailing_slash
|
||
|
|
from posthog.version import VERSION
|
||
|
|
|
||
|
|
try:
|
||
|
|
import queue
|
||
|
|
except ImportError:
|
||
|
|
import Queue as queue
|
||
|
|
|
||
|
|
|
||
|
|
ID_TYPES = (numbers.Number, string_types, UUID)
|
||
|
|
MAX_DICT_SIZE = 50_000
|
||
|
|
|
||
|
|
|
||
|
|
def get_os_info():
|
||
|
|
"""
|
||
|
|
Returns standardized OS name and version information.
|
||
|
|
Similar to how user agent parsing works in JS.
|
||
|
|
"""
|
||
|
|
os_name = ""
|
||
|
|
os_version = ""
|
||
|
|
|
||
|
|
platform_name = sys.platform
|
||
|
|
|
||
|
|
if platform_name.startswith("win"):
|
||
|
|
os_name = "Windows"
|
||
|
|
if hasattr(platform, "win32_ver"):
|
||
|
|
win_version = platform.win32_ver()[0]
|
||
|
|
if win_version:
|
||
|
|
os_version = win_version
|
||
|
|
|
||
|
|
elif platform_name == "darwin":
|
||
|
|
os_name = "Mac OS X"
|
||
|
|
if hasattr(platform, "mac_ver"):
|
||
|
|
mac_version = platform.mac_ver()[0]
|
||
|
|
if mac_version:
|
||
|
|
os_version = mac_version
|
||
|
|
|
||
|
|
elif platform_name.startswith("linux"):
|
||
|
|
os_name = "Linux"
|
||
|
|
linux_info = distro.info()
|
||
|
|
if linux_info["version"]:
|
||
|
|
os_version = linux_info["version"]
|
||
|
|
|
||
|
|
elif platform_name.startswith("freebsd"):
|
||
|
|
os_name = "FreeBSD"
|
||
|
|
if hasattr(platform, "release"):
|
||
|
|
os_version = platform.release()
|
||
|
|
|
||
|
|
else:
|
||
|
|
os_name = platform_name
|
||
|
|
if hasattr(platform, "release"):
|
||
|
|
os_version = platform.release()
|
||
|
|
|
||
|
|
return os_name, os_version
|
||
|
|
|
||
|
|
|
||
|
|
def system_context() -> dict[str, Any]:
|
||
|
|
os_name, os_version = get_os_info()
|
||
|
|
|
||
|
|
return {
|
||
|
|
"$python_runtime": platform.python_implementation(),
|
||
|
|
"$python_version": "%s.%s.%s" % (sys.version_info[:3]),
|
||
|
|
"$os": os_name,
|
||
|
|
"$os_version": os_version,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
class Client(object):
|
||
|
|
"""Create a new PostHog client."""
|
||
|
|
|
||
|
|
log = logging.getLogger("posthog")
|
||
|
|
|
||
|
|
def __init__(
|
||
|
|
self,
|
||
|
|
api_key=None,
|
||
|
|
host=None,
|
||
|
|
debug=False,
|
||
|
|
max_queue_size=10000,
|
||
|
|
send=True,
|
||
|
|
on_error=None,
|
||
|
|
flush_at=100,
|
||
|
|
flush_interval=0.5,
|
||
|
|
gzip=False,
|
||
|
|
max_retries=3,
|
||
|
|
sync_mode=False,
|
||
|
|
timeout=15,
|
||
|
|
thread=1,
|
||
|
|
poll_interval=30,
|
||
|
|
personal_api_key=None,
|
||
|
|
project_api_key=None,
|
||
|
|
disabled=False,
|
||
|
|
disable_geoip=True,
|
||
|
|
historical_migration=False,
|
||
|
|
feature_flags_request_timeout_seconds=3,
|
||
|
|
super_properties=None,
|
||
|
|
enable_exception_autocapture=False,
|
||
|
|
exception_autocapture_integrations=None,
|
||
|
|
project_root=None,
|
||
|
|
privacy_mode=False,
|
||
|
|
):
|
||
|
|
self.queue = queue.Queue(max_queue_size)
|
||
|
|
|
||
|
|
# api_key: This should be the Team API Key (token), public
|
||
|
|
self.api_key = project_api_key or api_key
|
||
|
|
|
||
|
|
require("api_key", self.api_key, string_types)
|
||
|
|
|
||
|
|
self.on_error = on_error
|
||
|
|
self.debug = debug
|
||
|
|
self.send = send
|
||
|
|
self.sync_mode = sync_mode
|
||
|
|
# Used for session replay URL generation - we don't want the server host here.
|
||
|
|
self.raw_host = host or DEFAULT_HOST
|
||
|
|
self.host = determine_server_host(host)
|
||
|
|
self.gzip = gzip
|
||
|
|
self.timeout = timeout
|
||
|
|
self.feature_flags = None
|
||
|
|
self.feature_flags_by_key = None
|
||
|
|
self.group_type_mapping = None
|
||
|
|
self.cohorts = None
|
||
|
|
self.poll_interval = poll_interval
|
||
|
|
self.feature_flags_request_timeout_seconds = feature_flags_request_timeout_seconds
|
||
|
|
self.poller = None
|
||
|
|
self.distinct_ids_feature_flags_reported = SizeLimitedDict(MAX_DICT_SIZE, set)
|
||
|
|
self.disabled = disabled
|
||
|
|
self.disable_geoip = disable_geoip
|
||
|
|
self.historical_migration = historical_migration
|
||
|
|
self.super_properties = super_properties
|
||
|
|
self.enable_exception_autocapture = enable_exception_autocapture
|
||
|
|
self.exception_autocapture_integrations = exception_autocapture_integrations
|
||
|
|
self.exception_capture = None
|
||
|
|
self.privacy_mode = privacy_mode
|
||
|
|
|
||
|
|
if project_root is None:
|
||
|
|
try:
|
||
|
|
project_root = os.getcwd()
|
||
|
|
except Exception:
|
||
|
|
project_root = None
|
||
|
|
|
||
|
|
self.project_root = project_root
|
||
|
|
|
||
|
|
# personal_api_key: This should be a generated Personal API Key, private
|
||
|
|
self.personal_api_key = personal_api_key
|
||
|
|
if debug:
|
||
|
|
# Ensures that debug level messages are logged when debug mode is on.
|
||
|
|
# Otherwise, defaults to WARNING level. See https://docs.python.org/3/howto/logging.html#what-happens-if-no-configuration-is-provided
|
||
|
|
logging.basicConfig()
|
||
|
|
self.log.setLevel(logging.DEBUG)
|
||
|
|
else:
|
||
|
|
self.log.setLevel(logging.WARNING)
|
||
|
|
|
||
|
|
if self.enable_exception_autocapture:
|
||
|
|
self.exception_capture = ExceptionCapture(self, integrations=self.exception_autocapture_integrations)
|
||
|
|
|
||
|
|
if sync_mode:
|
||
|
|
self.consumers = None
|
||
|
|
else:
|
||
|
|
# On program exit, allow the consumer thread to exit cleanly.
|
||
|
|
# This prevents exceptions and a messy shutdown when the
|
||
|
|
# interpreter is destroyed before the daemon thread finishes
|
||
|
|
# execution. However, it is *not* the same as flushing the queue!
|
||
|
|
# To guarantee all messages have been delivered, you'll still need
|
||
|
|
# to call flush().
|
||
|
|
if send:
|
||
|
|
atexit.register(self.join)
|
||
|
|
for n in range(thread):
|
||
|
|
self.consumers = []
|
||
|
|
consumer = Consumer(
|
||
|
|
self.queue,
|
||
|
|
self.api_key,
|
||
|
|
host=self.host,
|
||
|
|
on_error=on_error,
|
||
|
|
flush_at=flush_at,
|
||
|
|
flush_interval=flush_interval,
|
||
|
|
gzip=gzip,
|
||
|
|
retries=max_retries,
|
||
|
|
timeout=timeout,
|
||
|
|
historical_migration=historical_migration,
|
||
|
|
)
|
||
|
|
self.consumers.append(consumer)
|
||
|
|
|
||
|
|
# if we've disabled sending, just don't start the consumer
|
||
|
|
if send:
|
||
|
|
consumer.start()
|
||
|
|
|
||
|
|
def identify(self, distinct_id=None, properties=None, context=None, timestamp=None, uuid=None, disable_geoip=None):
|
||
|
|
if context is not None:
|
||
|
|
warnings.warn(
|
||
|
|
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||
|
|
DeprecationWarning,
|
||
|
|
stacklevel=2,
|
||
|
|
)
|
||
|
|
|
||
|
|
properties = properties or {}
|
||
|
|
require("distinct_id", distinct_id, ID_TYPES)
|
||
|
|
require("properties", properties, dict)
|
||
|
|
|
||
|
|
msg = {
|
||
|
|
"timestamp": timestamp,
|
||
|
|
"distinct_id": distinct_id,
|
||
|
|
"$set": properties,
|
||
|
|
"event": "$identify",
|
||
|
|
"uuid": uuid,
|
||
|
|
}
|
||
|
|
|
||
|
|
return self._enqueue(msg, disable_geoip)
|
||
|
|
|
||
|
|
def get_feature_variants(
|
||
|
|
self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None
|
||
|
|
):
|
||
|
|
resp_data = self.get_decide(distinct_id, groups, person_properties, group_properties, disable_geoip)
|
||
|
|
return resp_data["featureFlags"]
|
||
|
|
|
||
|
|
def get_feature_payloads(
|
||
|
|
self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None
|
||
|
|
):
|
||
|
|
resp_data = self.get_decide(distinct_id, groups, person_properties, group_properties, disable_geoip)
|
||
|
|
return resp_data["featureFlagPayloads"]
|
||
|
|
|
||
|
|
def get_feature_flags_and_payloads(
|
||
|
|
self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None
|
||
|
|
):
|
||
|
|
resp_data = self.get_decide(distinct_id, groups, person_properties, group_properties, disable_geoip)
|
||
|
|
return {
|
||
|
|
"featureFlags": resp_data["featureFlags"],
|
||
|
|
"featureFlagPayloads": resp_data["featureFlagPayloads"],
|
||
|
|
}
|
||
|
|
|
||
|
|
def get_decide(self, distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None):
|
||
|
|
require("distinct_id", distinct_id, ID_TYPES)
|
||
|
|
|
||
|
|
if disable_geoip is None:
|
||
|
|
disable_geoip = self.disable_geoip
|
||
|
|
|
||
|
|
if groups:
|
||
|
|
require("groups", groups, dict)
|
||
|
|
else:
|
||
|
|
groups = {}
|
||
|
|
|
||
|
|
request_data = {
|
||
|
|
"distinct_id": distinct_id,
|
||
|
|
"groups": groups,
|
||
|
|
"person_properties": person_properties,
|
||
|
|
"group_properties": group_properties,
|
||
|
|
"disable_geoip": disable_geoip,
|
||
|
|
}
|
||
|
|
resp_data = decide(self.api_key, self.host, timeout=self.feature_flags_request_timeout_seconds, **request_data)
|
||
|
|
|
||
|
|
return resp_data
|
||
|
|
|
||
|
|
def capture(
|
||
|
|
self,
|
||
|
|
distinct_id=None,
|
||
|
|
event=None,
|
||
|
|
properties=None,
|
||
|
|
context=None,
|
||
|
|
timestamp=None,
|
||
|
|
uuid=None,
|
||
|
|
groups=None,
|
||
|
|
send_feature_flags=False,
|
||
|
|
disable_geoip=None,
|
||
|
|
):
|
||
|
|
if context is not None:
|
||
|
|
warnings.warn(
|
||
|
|
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||
|
|
DeprecationWarning,
|
||
|
|
stacklevel=2,
|
||
|
|
)
|
||
|
|
|
||
|
|
properties = {**(properties or {}), **system_context()}
|
||
|
|
|
||
|
|
require("distinct_id", distinct_id, ID_TYPES)
|
||
|
|
require("properties", properties, dict)
|
||
|
|
require("event", event, string_types)
|
||
|
|
|
||
|
|
msg = {
|
||
|
|
"properties": properties,
|
||
|
|
"timestamp": timestamp,
|
||
|
|
"distinct_id": distinct_id,
|
||
|
|
"event": event,
|
||
|
|
"uuid": uuid,
|
||
|
|
}
|
||
|
|
|
||
|
|
if groups:
|
||
|
|
require("groups", groups, dict)
|
||
|
|
msg["properties"]["$groups"] = groups
|
||
|
|
|
||
|
|
extra_properties = {}
|
||
|
|
feature_variants = {}
|
||
|
|
if send_feature_flags:
|
||
|
|
try:
|
||
|
|
feature_variants = self.get_feature_variants(distinct_id, groups, disable_geoip=disable_geoip)
|
||
|
|
except Exception as e:
|
||
|
|
self.log.exception(f"[FEATURE FLAGS] Unable to get feature variants: {e}")
|
||
|
|
|
||
|
|
elif self.feature_flags and event != "$feature_flag_called":
|
||
|
|
# Local evaluation is enabled, flags are loaded, so try and get all flags we can without going to the server
|
||
|
|
feature_variants = self.get_all_flags(
|
||
|
|
distinct_id, groups=(groups or {}), disable_geoip=disable_geoip, only_evaluate_locally=True
|
||
|
|
)
|
||
|
|
|
||
|
|
for feature, variant in feature_variants.items():
|
||
|
|
extra_properties[f"$feature/{feature}"] = variant
|
||
|
|
|
||
|
|
active_feature_flags = [key for (key, value) in feature_variants.items() if value is not False]
|
||
|
|
if active_feature_flags:
|
||
|
|
extra_properties["$active_feature_flags"] = active_feature_flags
|
||
|
|
|
||
|
|
if extra_properties:
|
||
|
|
msg["properties"] = {**extra_properties, **msg["properties"]}
|
||
|
|
|
||
|
|
return self._enqueue(msg, disable_geoip)
|
||
|
|
|
||
|
|
def set(self, distinct_id=None, properties=None, context=None, timestamp=None, uuid=None, disable_geoip=None):
|
||
|
|
if context is not None:
|
||
|
|
warnings.warn(
|
||
|
|
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||
|
|
DeprecationWarning,
|
||
|
|
stacklevel=2,
|
||
|
|
)
|
||
|
|
|
||
|
|
properties = properties or {}
|
||
|
|
require("distinct_id", distinct_id, ID_TYPES)
|
||
|
|
require("properties", properties, dict)
|
||
|
|
|
||
|
|
msg = {
|
||
|
|
"timestamp": timestamp,
|
||
|
|
"distinct_id": distinct_id,
|
||
|
|
"$set": properties,
|
||
|
|
"event": "$set",
|
||
|
|
"uuid": uuid,
|
||
|
|
}
|
||
|
|
|
||
|
|
return self._enqueue(msg, disable_geoip)
|
||
|
|
|
||
|
|
def set_once(self, distinct_id=None, properties=None, context=None, timestamp=None, uuid=None, disable_geoip=None):
|
||
|
|
if context is not None:
|
||
|
|
warnings.warn(
|
||
|
|
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||
|
|
DeprecationWarning,
|
||
|
|
stacklevel=2,
|
||
|
|
)
|
||
|
|
|
||
|
|
properties = properties or {}
|
||
|
|
require("distinct_id", distinct_id, ID_TYPES)
|
||
|
|
require("properties", properties, dict)
|
||
|
|
|
||
|
|
msg = {
|
||
|
|
"timestamp": timestamp,
|
||
|
|
"distinct_id": distinct_id,
|
||
|
|
"$set_once": properties,
|
||
|
|
"event": "$set_once",
|
||
|
|
"uuid": uuid,
|
||
|
|
}
|
||
|
|
|
||
|
|
return self._enqueue(msg, disable_geoip)
|
||
|
|
|
||
|
|
def group_identify(
|
||
|
|
self,
|
||
|
|
group_type=None,
|
||
|
|
group_key=None,
|
||
|
|
properties=None,
|
||
|
|
context=None,
|
||
|
|
timestamp=None,
|
||
|
|
uuid=None,
|
||
|
|
disable_geoip=None,
|
||
|
|
distinct_id=None,
|
||
|
|
):
|
||
|
|
if context is not None:
|
||
|
|
warnings.warn(
|
||
|
|
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||
|
|
DeprecationWarning,
|
||
|
|
stacklevel=2,
|
||
|
|
)
|
||
|
|
properties = properties or {}
|
||
|
|
require("group_type", group_type, ID_TYPES)
|
||
|
|
require("group_key", group_key, ID_TYPES)
|
||
|
|
require("properties", properties, dict)
|
||
|
|
|
||
|
|
if distinct_id:
|
||
|
|
require("distinct_id", distinct_id, ID_TYPES)
|
||
|
|
else:
|
||
|
|
distinct_id = "${}_{}".format(group_type, group_key)
|
||
|
|
|
||
|
|
msg = {
|
||
|
|
"event": "$groupidentify",
|
||
|
|
"properties": {
|
||
|
|
"$group_type": group_type,
|
||
|
|
"$group_key": group_key,
|
||
|
|
"$group_set": properties,
|
||
|
|
},
|
||
|
|
"distinct_id": distinct_id,
|
||
|
|
"timestamp": timestamp,
|
||
|
|
"uuid": uuid,
|
||
|
|
}
|
||
|
|
|
||
|
|
return self._enqueue(msg, disable_geoip)
|
||
|
|
|
||
|
|
def alias(self, previous_id=None, distinct_id=None, context=None, timestamp=None, uuid=None, disable_geoip=None):
|
||
|
|
if context is not None:
|
||
|
|
warnings.warn(
|
||
|
|
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||
|
|
DeprecationWarning,
|
||
|
|
stacklevel=2,
|
||
|
|
)
|
||
|
|
|
||
|
|
require("previous_id", previous_id, ID_TYPES)
|
||
|
|
require("distinct_id", distinct_id, ID_TYPES)
|
||
|
|
|
||
|
|
msg = {
|
||
|
|
"properties": {
|
||
|
|
"distinct_id": previous_id,
|
||
|
|
"alias": distinct_id,
|
||
|
|
},
|
||
|
|
"timestamp": timestamp,
|
||
|
|
"event": "$create_alias",
|
||
|
|
"distinct_id": previous_id,
|
||
|
|
}
|
||
|
|
|
||
|
|
return self._enqueue(msg, disable_geoip)
|
||
|
|
|
||
|
|
def page(
|
||
|
|
self, distinct_id=None, url=None, properties=None, context=None, timestamp=None, uuid=None, disable_geoip=None
|
||
|
|
):
|
||
|
|
if context is not None:
|
||
|
|
warnings.warn(
|
||
|
|
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||
|
|
DeprecationWarning,
|
||
|
|
stacklevel=2,
|
||
|
|
)
|
||
|
|
|
||
|
|
properties = properties or {}
|
||
|
|
require("distinct_id", distinct_id, ID_TYPES)
|
||
|
|
require("properties", properties, dict)
|
||
|
|
|
||
|
|
require("url", url, string_types)
|
||
|
|
properties["$current_url"] = url
|
||
|
|
|
||
|
|
msg = {
|
||
|
|
"event": "$pageview",
|
||
|
|
"properties": properties,
|
||
|
|
"timestamp": timestamp,
|
||
|
|
"distinct_id": distinct_id,
|
||
|
|
"uuid": uuid,
|
||
|
|
}
|
||
|
|
|
||
|
|
return self._enqueue(msg, disable_geoip)
|
||
|
|
|
||
|
|
def capture_exception(
|
||
|
|
self,
|
||
|
|
exception=None,
|
||
|
|
distinct_id=None,
|
||
|
|
properties=None,
|
||
|
|
context=None,
|
||
|
|
timestamp=None,
|
||
|
|
uuid=None,
|
||
|
|
groups=None,
|
||
|
|
):
|
||
|
|
if context is not None:
|
||
|
|
warnings.warn(
|
||
|
|
"The 'context' parameter is deprecated and will be removed in a future version.",
|
||
|
|
DeprecationWarning,
|
||
|
|
stacklevel=2,
|
||
|
|
)
|
||
|
|
|
||
|
|
# this function shouldn't ever throw an error, so it logs exceptions instead of raising them.
|
||
|
|
# this is important to ensure we don't unexpectedly re-raise exceptions in the user's code.
|
||
|
|
try:
|
||
|
|
properties = properties or {}
|
||
|
|
|
||
|
|
# if there's no distinct_id, we'll generate one and set personless mode
|
||
|
|
# via $process_person_profile = false
|
||
|
|
if distinct_id is None:
|
||
|
|
properties["$process_person_profile"] = False
|
||
|
|
distinct_id = uuid4()
|
||
|
|
|
||
|
|
require("distinct_id", distinct_id, ID_TYPES)
|
||
|
|
require("properties", properties, dict)
|
||
|
|
|
||
|
|
if exception is not None:
|
||
|
|
exc_info = exc_info_from_error(exception)
|
||
|
|
else:
|
||
|
|
exc_info = sys.exc_info()
|
||
|
|
|
||
|
|
if exc_info is None or exc_info == (None, None, None):
|
||
|
|
self.log.warning("No exception information available")
|
||
|
|
return
|
||
|
|
|
||
|
|
# Format stack trace for cymbal
|
||
|
|
all_exceptions_with_trace = exceptions_from_error_tuple(exc_info)
|
||
|
|
|
||
|
|
# Add in-app property to frames in the exceptions
|
||
|
|
event = handle_in_app(
|
||
|
|
{
|
||
|
|
"exception": {
|
||
|
|
"values": all_exceptions_with_trace,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
project_root=self.project_root,
|
||
|
|
)
|
||
|
|
all_exceptions_with_trace_and_in_app = event["exception"]["values"]
|
||
|
|
|
||
|
|
properties = {
|
||
|
|
"$exception_type": all_exceptions_with_trace_and_in_app[0].get("type"),
|
||
|
|
"$exception_message": all_exceptions_with_trace_and_in_app[0].get("value"),
|
||
|
|
"$exception_list": all_exceptions_with_trace_and_in_app,
|
||
|
|
"$exception_personURL": f"{remove_trailing_slash(self.raw_host)}/project/{self.api_key}/person/{distinct_id}",
|
||
|
|
**properties,
|
||
|
|
}
|
||
|
|
|
||
|
|
return self.capture(distinct_id, "$exception", properties, context, timestamp, uuid, groups)
|
||
|
|
except Exception as e:
|
||
|
|
self.log.exception(f"Failed to capture exception: {e}")
|
||
|
|
|
||
|
|
def _enqueue(self, msg, disable_geoip):
|
||
|
|
"""Push a new `msg` onto the queue, return `(success, msg)`"""
|
||
|
|
|
||
|
|
if self.disabled:
|
||
|
|
return False, "disabled"
|
||
|
|
|
||
|
|
timestamp = msg["timestamp"]
|
||
|
|
if timestamp is None:
|
||
|
|
timestamp = datetime.now(tz=tzutc())
|
||
|
|
|
||
|
|
require("timestamp", timestamp, datetime)
|
||
|
|
|
||
|
|
# add common
|
||
|
|
timestamp = guess_timezone(timestamp)
|
||
|
|
msg["timestamp"] = timestamp.isoformat()
|
||
|
|
|
||
|
|
# only send if "uuid" is truthy
|
||
|
|
if "uuid" in msg:
|
||
|
|
uuid = msg.pop("uuid")
|
||
|
|
if uuid:
|
||
|
|
msg["uuid"] = stringify_id(uuid)
|
||
|
|
|
||
|
|
if not msg.get("properties"):
|
||
|
|
msg["properties"] = {}
|
||
|
|
msg["properties"]["$lib"] = "posthog-python"
|
||
|
|
msg["properties"]["$lib_version"] = VERSION
|
||
|
|
|
||
|
|
if disable_geoip is None:
|
||
|
|
disable_geoip = self.disable_geoip
|
||
|
|
|
||
|
|
if disable_geoip:
|
||
|
|
msg["properties"]["$geoip_disable"] = True
|
||
|
|
|
||
|
|
if self.super_properties:
|
||
|
|
msg["properties"] = {**msg["properties"], **self.super_properties}
|
||
|
|
|
||
|
|
msg["distinct_id"] = stringify_id(msg.get("distinct_id", None))
|
||
|
|
|
||
|
|
msg = clean(msg)
|
||
|
|
self.log.debug("queueing: %s", msg)
|
||
|
|
|
||
|
|
# if send is False, return msg as if it was successfully queued
|
||
|
|
if not self.send:
|
||
|
|
return True, msg
|
||
|
|
|
||
|
|
if self.sync_mode:
|
||
|
|
self.log.debug("enqueued with blocking %s.", msg["event"])
|
||
|
|
batch_post(
|
||
|
|
self.api_key,
|
||
|
|
self.host,
|
||
|
|
gzip=self.gzip,
|
||
|
|
timeout=self.timeout,
|
||
|
|
batch=[msg],
|
||
|
|
historical_migration=self.historical_migration,
|
||
|
|
)
|
||
|
|
|
||
|
|
return True, msg
|
||
|
|
|
||
|
|
try:
|
||
|
|
self.queue.put(msg, block=False)
|
||
|
|
self.log.debug("enqueued %s.", msg["event"])
|
||
|
|
return True, msg
|
||
|
|
except queue.Full:
|
||
|
|
self.log.warning("analytics-python queue is full")
|
||
|
|
return False, msg
|
||
|
|
|
||
|
|
def flush(self):
|
||
|
|
"""Forces a flush from the internal queue to the server"""
|
||
|
|
queue = self.queue
|
||
|
|
size = queue.qsize()
|
||
|
|
queue.join()
|
||
|
|
# Note that this message may not be precise, because of threading.
|
||
|
|
self.log.debug("successfully flushed about %s items.", size)
|
||
|
|
|
||
|
|
def join(self):
|
||
|
|
"""Ends the consumer thread once the queue is empty.
|
||
|
|
Blocks execution until finished
|
||
|
|
"""
|
||
|
|
for consumer in self.consumers:
|
||
|
|
consumer.pause()
|
||
|
|
try:
|
||
|
|
consumer.join()
|
||
|
|
except RuntimeError:
|
||
|
|
# consumer thread has not started
|
||
|
|
pass
|
||
|
|
|
||
|
|
if self.poller:
|
||
|
|
self.poller.stop()
|
||
|
|
|
||
|
|
def shutdown(self):
|
||
|
|
"""Flush all messages and cleanly shutdown the client"""
|
||
|
|
self.flush()
|
||
|
|
self.join()
|
||
|
|
|
||
|
|
if self.exception_capture:
|
||
|
|
self.exception_capture.close()
|
||
|
|
|
||
|
|
def _load_feature_flags(self):
|
||
|
|
try:
|
||
|
|
response = get(
|
||
|
|
self.personal_api_key,
|
||
|
|
f"/api/feature_flag/local_evaluation/?token={self.api_key}&send_cohorts",
|
||
|
|
self.host,
|
||
|
|
timeout=10,
|
||
|
|
)
|
||
|
|
|
||
|
|
self.feature_flags = response["flags"] or []
|
||
|
|
self.feature_flags_by_key = {
|
||
|
|
flag["key"]: flag for flag in self.feature_flags if flag.get("key") is not None
|
||
|
|
}
|
||
|
|
self.group_type_mapping = response["group_type_mapping"] or {}
|
||
|
|
self.cohorts = response["cohorts"] or {}
|
||
|
|
|
||
|
|
except APIError as e:
|
||
|
|
if e.status == 401:
|
||
|
|
self.log.error(
|
||
|
|
"[FEATURE FLAGS] Error loading feature flags: To use feature flags, please set a valid personal_api_key. More information: https://posthog.com/docs/api/overview"
|
||
|
|
)
|
||
|
|
if self.debug:
|
||
|
|
raise APIError(
|
||
|
|
status=401,
|
||
|
|
message="You are using a write-only key with feature flags. "
|
||
|
|
"To use feature flags, please set a personal_api_key "
|
||
|
|
"More information: https://posthog.com/docs/api/overview",
|
||
|
|
)
|
||
|
|
elif e.status == 402:
|
||
|
|
self.log.warning(
|
||
|
|
"[FEATURE FLAGS] PostHog feature flags quota limited, resetting feature flag data. Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts"
|
||
|
|
)
|
||
|
|
# Reset all feature flag data when quota limited
|
||
|
|
self.feature_flags = []
|
||
|
|
self.feature_flags_by_key = {}
|
||
|
|
self.group_type_mapping = {}
|
||
|
|
self.cohorts = {}
|
||
|
|
|
||
|
|
if self.debug:
|
||
|
|
raise APIError(
|
||
|
|
status=402,
|
||
|
|
message="PostHog feature flags quota limited",
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
self.log.error(f"[FEATURE FLAGS] Error loading feature flags: {e}")
|
||
|
|
except Exception as e:
|
||
|
|
self.log.warning(
|
||
|
|
"[FEATURE FLAGS] Fetching feature flags failed with following error. We will retry in %s seconds."
|
||
|
|
% self.poll_interval
|
||
|
|
)
|
||
|
|
self.log.warning(e)
|
||
|
|
|
||
|
|
self._last_feature_flag_poll = datetime.now(tz=tzutc())
|
||
|
|
|
||
|
|
def load_feature_flags(self):
|
||
|
|
if not self.personal_api_key:
|
||
|
|
self.log.warning("[FEATURE FLAGS] You have to specify a personal_api_key to use feature flags.")
|
||
|
|
self.feature_flags = []
|
||
|
|
return
|
||
|
|
|
||
|
|
self._load_feature_flags()
|
||
|
|
if not (self.poller and self.poller.is_alive()):
|
||
|
|
self.poller = Poller(interval=timedelta(seconds=self.poll_interval), execute=self._load_feature_flags)
|
||
|
|
self.poller.start()
|
||
|
|
|
||
|
|
def _compute_flag_locally(
|
||
|
|
self,
|
||
|
|
feature_flag,
|
||
|
|
distinct_id,
|
||
|
|
*,
|
||
|
|
groups={},
|
||
|
|
person_properties={},
|
||
|
|
group_properties={},
|
||
|
|
warn_on_unknown_groups=True,
|
||
|
|
):
|
||
|
|
if feature_flag.get("ensure_experience_continuity", False):
|
||
|
|
raise InconclusiveMatchError("Flag has experience continuity enabled")
|
||
|
|
|
||
|
|
if not feature_flag.get("active"):
|
||
|
|
return False
|
||
|
|
|
||
|
|
flag_filters = feature_flag.get("filters") or {}
|
||
|
|
aggregation_group_type_index = flag_filters.get("aggregation_group_type_index")
|
||
|
|
if aggregation_group_type_index is not None:
|
||
|
|
group_name = self.group_type_mapping.get(str(aggregation_group_type_index))
|
||
|
|
|
||
|
|
if not group_name:
|
||
|
|
self.log.warning(
|
||
|
|
f"[FEATURE FLAGS] Unknown group type index {aggregation_group_type_index} for feature flag {feature_flag['key']}"
|
||
|
|
)
|
||
|
|
# failover to `/decide/`
|
||
|
|
raise InconclusiveMatchError("Flag has unknown group type index")
|
||
|
|
|
||
|
|
if group_name not in groups:
|
||
|
|
# Group flags are never enabled in `groups` aren't passed in
|
||
|
|
# don't failover to `/decide/`, since response will be the same
|
||
|
|
if warn_on_unknown_groups:
|
||
|
|
self.log.warning(
|
||
|
|
f"[FEATURE FLAGS] Can't compute group feature flag: {feature_flag['key']} without group names passed in"
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
self.log.debug(
|
||
|
|
f"[FEATURE FLAGS] Can't compute group feature flag: {feature_flag['key']} without group names passed in"
|
||
|
|
)
|
||
|
|
return False
|
||
|
|
|
||
|
|
focused_group_properties = group_properties[group_name]
|
||
|
|
return match_feature_flag_properties(feature_flag, groups[group_name], focused_group_properties)
|
||
|
|
else:
|
||
|
|
return match_feature_flag_properties(feature_flag, distinct_id, person_properties, self.cohorts)
|
||
|
|
|
||
|
|
def feature_enabled(
|
||
|
|
self,
|
||
|
|
key,
|
||
|
|
distinct_id,
|
||
|
|
*,
|
||
|
|
groups={},
|
||
|
|
person_properties={},
|
||
|
|
group_properties={},
|
||
|
|
only_evaluate_locally=False,
|
||
|
|
send_feature_flag_events=True,
|
||
|
|
disable_geoip=None,
|
||
|
|
):
|
||
|
|
response = self.get_feature_flag(
|
||
|
|
key,
|
||
|
|
distinct_id,
|
||
|
|
groups=groups,
|
||
|
|
person_properties=person_properties,
|
||
|
|
group_properties=group_properties,
|
||
|
|
only_evaluate_locally=only_evaluate_locally,
|
||
|
|
send_feature_flag_events=send_feature_flag_events,
|
||
|
|
disable_geoip=disable_geoip,
|
||
|
|
)
|
||
|
|
|
||
|
|
if response is None:
|
||
|
|
return None
|
||
|
|
return bool(response)
|
||
|
|
|
||
|
|
def get_feature_flag(
|
||
|
|
self,
|
||
|
|
key,
|
||
|
|
distinct_id,
|
||
|
|
*,
|
||
|
|
groups={},
|
||
|
|
person_properties={},
|
||
|
|
group_properties={},
|
||
|
|
only_evaluate_locally=False,
|
||
|
|
send_feature_flag_events=True,
|
||
|
|
disable_geoip=None,
|
||
|
|
):
|
||
|
|
require("key", key, string_types)
|
||
|
|
require("distinct_id", distinct_id, ID_TYPES)
|
||
|
|
require("groups", groups, dict)
|
||
|
|
|
||
|
|
if self.disabled:
|
||
|
|
return None
|
||
|
|
|
||
|
|
person_properties, group_properties = self._add_local_person_and_group_properties(
|
||
|
|
distinct_id, groups, person_properties, group_properties
|
||
|
|
)
|
||
|
|
|
||
|
|
if self.feature_flags is None and self.personal_api_key:
|
||
|
|
self.load_feature_flags()
|
||
|
|
response = None
|
||
|
|
|
||
|
|
if self.feature_flags:
|
||
|
|
for flag in self.feature_flags:
|
||
|
|
if flag["key"] == key:
|
||
|
|
try:
|
||
|
|
response = self._compute_flag_locally(
|
||
|
|
flag,
|
||
|
|
distinct_id,
|
||
|
|
groups=groups,
|
||
|
|
person_properties=person_properties,
|
||
|
|
group_properties=group_properties,
|
||
|
|
)
|
||
|
|
self.log.debug(f"Successfully computed flag locally: {key} -> {response}")
|
||
|
|
except InconclusiveMatchError as e:
|
||
|
|
self.log.debug(f"Failed to compute flag {key} locally: {e}")
|
||
|
|
continue
|
||
|
|
except Exception as e:
|
||
|
|
self.log.exception(f"[FEATURE FLAGS] Error while computing variant locally: {e}")
|
||
|
|
continue
|
||
|
|
break
|
||
|
|
|
||
|
|
flag_was_locally_evaluated = response is not None
|
||
|
|
if not flag_was_locally_evaluated and not only_evaluate_locally:
|
||
|
|
try:
|
||
|
|
feature_flags = self.get_feature_variants(
|
||
|
|
distinct_id,
|
||
|
|
groups=groups,
|
||
|
|
person_properties=person_properties,
|
||
|
|
group_properties=group_properties,
|
||
|
|
disable_geoip=disable_geoip,
|
||
|
|
)
|
||
|
|
response = feature_flags.get(key)
|
||
|
|
if response is None:
|
||
|
|
response = False
|
||
|
|
self.log.debug(f"Successfully computed flag remotely: #{key} -> #{response}")
|
||
|
|
except Exception as e:
|
||
|
|
self.log.exception(f"[FEATURE FLAGS] Unable to get flag remotely: {e}")
|
||
|
|
|
||
|
|
feature_flag_reported_key = f"{key}_{str(response)}"
|
||
|
|
if (
|
||
|
|
feature_flag_reported_key not in self.distinct_ids_feature_flags_reported[distinct_id]
|
||
|
|
and send_feature_flag_events # noqa: W503
|
||
|
|
):
|
||
|
|
self.capture(
|
||
|
|
distinct_id,
|
||
|
|
"$feature_flag_called",
|
||
|
|
{
|
||
|
|
"$feature_flag": key,
|
||
|
|
"$feature_flag_response": response,
|
||
|
|
"locally_evaluated": flag_was_locally_evaluated,
|
||
|
|
f"$feature/{key}": response,
|
||
|
|
},
|
||
|
|
groups=groups,
|
||
|
|
disable_geoip=disable_geoip,
|
||
|
|
)
|
||
|
|
self.distinct_ids_feature_flags_reported[distinct_id].add(feature_flag_reported_key)
|
||
|
|
return response
|
||
|
|
|
||
|
|
def get_feature_flag_payload(
|
||
|
|
self,
|
||
|
|
key,
|
||
|
|
distinct_id,
|
||
|
|
*,
|
||
|
|
match_value=None,
|
||
|
|
groups={},
|
||
|
|
person_properties={},
|
||
|
|
group_properties={},
|
||
|
|
only_evaluate_locally=False,
|
||
|
|
send_feature_flag_events=True,
|
||
|
|
disable_geoip=None,
|
||
|
|
):
|
||
|
|
if self.disabled:
|
||
|
|
return None
|
||
|
|
|
||
|
|
if match_value is None:
|
||
|
|
match_value = self.get_feature_flag(
|
||
|
|
key,
|
||
|
|
distinct_id,
|
||
|
|
groups=groups,
|
||
|
|
person_properties=person_properties,
|
||
|
|
group_properties=group_properties,
|
||
|
|
send_feature_flag_events=False,
|
||
|
|
# Disable automatic sending of feature flag events because we're manually handling event dispatch.
|
||
|
|
# This prevents sending events with empty data when `get_feature_flag` cannot be evaluated locally.
|
||
|
|
only_evaluate_locally=True, # Enable local evaluation of feature flags to avoid making multiple requests to `/decide`.
|
||
|
|
disable_geoip=disable_geoip,
|
||
|
|
)
|
||
|
|
|
||
|
|
response = None
|
||
|
|
payload = None
|
||
|
|
|
||
|
|
if match_value is not None:
|
||
|
|
payload = self._compute_payload_locally(key, match_value)
|
||
|
|
|
||
|
|
flag_was_locally_evaluated = payload is not None
|
||
|
|
if not flag_was_locally_evaluated and not only_evaluate_locally:
|
||
|
|
try:
|
||
|
|
responses_and_payloads = self.get_feature_flags_and_payloads(
|
||
|
|
distinct_id, groups, person_properties, group_properties, disable_geoip
|
||
|
|
)
|
||
|
|
response = responses_and_payloads["featureFlags"].get(key, None)
|
||
|
|
payload = responses_and_payloads["featureFlagPayloads"].get(str(key), None)
|
||
|
|
except Exception as e:
|
||
|
|
self.log.exception(f"[FEATURE FLAGS] Unable to get feature flags and payloads: {e}")
|
||
|
|
|
||
|
|
feature_flag_reported_key = f"{key}_{str(response)}"
|
||
|
|
|
||
|
|
if (
|
||
|
|
feature_flag_reported_key not in self.distinct_ids_feature_flags_reported[distinct_id]
|
||
|
|
and send_feature_flag_events # noqa: W503
|
||
|
|
):
|
||
|
|
self.capture(
|
||
|
|
distinct_id,
|
||
|
|
"$feature_flag_called",
|
||
|
|
{
|
||
|
|
"$feature_flag": key,
|
||
|
|
"$feature_flag_response": response,
|
||
|
|
"$feature_flag_payload": payload,
|
||
|
|
"locally_evaluated": flag_was_locally_evaluated,
|
||
|
|
f"$feature/{key}": response,
|
||
|
|
},
|
||
|
|
groups=groups,
|
||
|
|
disable_geoip=disable_geoip,
|
||
|
|
)
|
||
|
|
self.distinct_ids_feature_flags_reported[distinct_id].add(feature_flag_reported_key)
|
||
|
|
|
||
|
|
return payload
|
||
|
|
|
||
|
|
def get_remote_config_payload(self, key: str):
|
||
|
|
if self.disabled:
|
||
|
|
return None
|
||
|
|
|
||
|
|
if self.personal_api_key is None:
|
||
|
|
self.log.warning(
|
||
|
|
"[FEATURE FLAGS] You have to specify a personal_api_key to fetch decrypted feature flag payloads."
|
||
|
|
)
|
||
|
|
return None
|
||
|
|
|
||
|
|
try:
|
||
|
|
return remote_config(
|
||
|
|
self.personal_api_key,
|
||
|
|
self.host,
|
||
|
|
key,
|
||
|
|
timeout=self.feature_flags_request_timeout_seconds,
|
||
|
|
)
|
||
|
|
except Exception as e:
|
||
|
|
self.log.exception(f"[FEATURE FLAGS] Unable to get decrypted feature flag payload: {e}")
|
||
|
|
|
||
|
|
def _compute_payload_locally(self, key, match_value):
|
||
|
|
payload = None
|
||
|
|
|
||
|
|
if self.feature_flags_by_key is None:
|
||
|
|
return payload
|
||
|
|
|
||
|
|
flag_definition = self.feature_flags_by_key.get(key)
|
||
|
|
if flag_definition:
|
||
|
|
flag_filters = flag_definition.get("filters") or {}
|
||
|
|
flag_payloads = flag_filters.get("payloads") or {}
|
||
|
|
# For boolean flags, convert True to "true"
|
||
|
|
# For multivariate flags, use the variant string as-is
|
||
|
|
lookup_value = "true" if isinstance(match_value, bool) and match_value else str(match_value)
|
||
|
|
payload = flag_payloads.get(lookup_value, None)
|
||
|
|
return payload
|
||
|
|
|
||
|
|
def get_all_flags(
|
||
|
|
self,
|
||
|
|
distinct_id,
|
||
|
|
*,
|
||
|
|
groups={},
|
||
|
|
person_properties={},
|
||
|
|
group_properties={},
|
||
|
|
only_evaluate_locally=False,
|
||
|
|
disable_geoip=None,
|
||
|
|
):
|
||
|
|
flags = self.get_all_flags_and_payloads(
|
||
|
|
distinct_id,
|
||
|
|
groups=groups,
|
||
|
|
person_properties=person_properties,
|
||
|
|
group_properties=group_properties,
|
||
|
|
only_evaluate_locally=only_evaluate_locally,
|
||
|
|
disable_geoip=disable_geoip,
|
||
|
|
)
|
||
|
|
return flags["featureFlags"]
|
||
|
|
|
||
|
|
def get_all_flags_and_payloads(
|
||
|
|
self,
|
||
|
|
distinct_id,
|
||
|
|
*,
|
||
|
|
groups={},
|
||
|
|
person_properties={},
|
||
|
|
group_properties={},
|
||
|
|
only_evaluate_locally=False,
|
||
|
|
disable_geoip=None,
|
||
|
|
):
|
||
|
|
if self.disabled:
|
||
|
|
return {"featureFlags": None, "featureFlagPayloads": None}
|
||
|
|
|
||
|
|
person_properties, group_properties = self._add_local_person_and_group_properties(
|
||
|
|
distinct_id, groups, person_properties, group_properties
|
||
|
|
)
|
||
|
|
|
||
|
|
flags, payloads, fallback_to_decide = self._get_all_flags_and_payloads_locally(
|
||
|
|
distinct_id, groups=groups, person_properties=person_properties, group_properties=group_properties
|
||
|
|
)
|
||
|
|
response = {"featureFlags": flags, "featureFlagPayloads": payloads}
|
||
|
|
|
||
|
|
if fallback_to_decide and not only_evaluate_locally:
|
||
|
|
try:
|
||
|
|
flags_and_payloads = self.get_decide(
|
||
|
|
distinct_id,
|
||
|
|
groups=groups,
|
||
|
|
person_properties=person_properties,
|
||
|
|
group_properties=group_properties,
|
||
|
|
disable_geoip=disable_geoip,
|
||
|
|
)
|
||
|
|
response = flags_and_payloads
|
||
|
|
except Exception as e:
|
||
|
|
self.log.exception(f"[FEATURE FLAGS] Unable to get feature flags and payloads: {e}")
|
||
|
|
|
||
|
|
return response
|
||
|
|
|
||
|
|
def _get_all_flags_and_payloads_locally(
|
||
|
|
self, distinct_id, *, groups={}, person_properties={}, group_properties={}, warn_on_unknown_groups=False
|
||
|
|
):
|
||
|
|
require("distinct_id", distinct_id, ID_TYPES)
|
||
|
|
require("groups", groups, dict)
|
||
|
|
|
||
|
|
if self.feature_flags is None and self.personal_api_key:
|
||
|
|
self.load_feature_flags()
|
||
|
|
|
||
|
|
flags = {}
|
||
|
|
payloads = {}
|
||
|
|
fallback_to_decide = False
|
||
|
|
# If loading in previous line failed
|
||
|
|
if self.feature_flags:
|
||
|
|
for flag in self.feature_flags:
|
||
|
|
try:
|
||
|
|
flags[flag["key"]] = self._compute_flag_locally(
|
||
|
|
flag,
|
||
|
|
distinct_id,
|
||
|
|
groups=groups,
|
||
|
|
person_properties=person_properties,
|
||
|
|
group_properties=group_properties,
|
||
|
|
warn_on_unknown_groups=warn_on_unknown_groups,
|
||
|
|
)
|
||
|
|
matched_payload = self._compute_payload_locally(flag["key"], flags[flag["key"]])
|
||
|
|
if matched_payload:
|
||
|
|
payloads[flag["key"]] = matched_payload
|
||
|
|
except InconclusiveMatchError:
|
||
|
|
# No need to log this, since it's just telling us to fall back to `/decide`
|
||
|
|
fallback_to_decide = True
|
||
|
|
except Exception as e:
|
||
|
|
self.log.exception(f"[FEATURE FLAGS] Error while computing variant and payload: {e}")
|
||
|
|
fallback_to_decide = True
|
||
|
|
else:
|
||
|
|
fallback_to_decide = True
|
||
|
|
|
||
|
|
return flags, payloads, fallback_to_decide
|
||
|
|
|
||
|
|
def feature_flag_definitions(self):
|
||
|
|
return self.feature_flags
|
||
|
|
|
||
|
|
def _add_local_person_and_group_properties(self, distinct_id, groups, person_properties, group_properties):
|
||
|
|
all_person_properties = {"distinct_id": distinct_id, **(person_properties or {})}
|
||
|
|
|
||
|
|
all_group_properties = {}
|
||
|
|
if groups:
|
||
|
|
for group_name in groups:
|
||
|
|
all_group_properties[group_name] = {
|
||
|
|
"$group_key": groups[group_name],
|
||
|
|
**(group_properties.get(group_name) or {}),
|
||
|
|
}
|
||
|
|
|
||
|
|
return all_person_properties, all_group_properties
|
||
|
|
|
||
|
|
|
||
|
|
def require(name, field, data_type):
|
||
|
|
"""Require that the named `field` has the right `data_type`"""
|
||
|
|
if not isinstance(field, data_type):
|
||
|
|
msg = "{0} must have {1}, got: {2}".format(name, data_type, field)
|
||
|
|
raise AssertionError(msg)
|
||
|
|
|
||
|
|
|
||
|
|
def stringify_id(val):
|
||
|
|
if val is None:
|
||
|
|
return None
|
||
|
|
if isinstance(val, string_types):
|
||
|
|
return val
|
||
|
|
return str(val)
|