mirror of
				https://github.com/zulip/zulip.git
				synced 2025-11-04 05:53:43 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			169 lines
		
	
	
		
			7.2 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			169 lines
		
	
	
		
			7.2 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
import logging
 | 
						|
import requests
 | 
						|
import ujson
 | 
						|
import urllib
 | 
						|
from typing import Any, Dict, List, Optional, Tuple, Union
 | 
						|
 | 
						|
from django.conf import settings
 | 
						|
from django.forms.models import model_to_dict
 | 
						|
from django.utils.translation import ugettext as _
 | 
						|
 | 
						|
from analytics.models import InstallationCount, RealmCount
 | 
						|
from version import ZULIP_VERSION
 | 
						|
from zerver.lib.exceptions import JsonableError
 | 
						|
from zerver.lib.export import floatify_datetime_fields
 | 
						|
from zerver.models import RealmAuditLog
 | 
						|
 | 
						|
class PushNotificationBouncerException(Exception):
 | 
						|
    pass
 | 
						|
 | 
						|
class PushNotificationBouncerRetryLaterError(JsonableError):
 | 
						|
    http_status_code = 502
 | 
						|
 | 
						|
def send_to_push_bouncer(method: str,
 | 
						|
                         endpoint: str,
 | 
						|
                         post_data: Union[str, Dict[str, Any]],
 | 
						|
                         extra_headers: Optional[Dict[str, Any]]=None) -> Dict[str, Any]:
 | 
						|
    """While it does actually send the notice, this function has a lot of
 | 
						|
    code and comments around error handling for the push notifications
 | 
						|
    bouncer.  There are several classes of failures, each with its own
 | 
						|
    potential solution:
 | 
						|
 | 
						|
    * Network errors with requests.request.  We raise an exception to signal
 | 
						|
      it to the callers.
 | 
						|
 | 
						|
    * 500 errors from the push bouncer or other unexpected responses;
 | 
						|
      we don't try to parse the response, but do make clear the cause.
 | 
						|
 | 
						|
    * 400 errors from the push bouncer.  Here there are 2 categories:
 | 
						|
      Our server failed to connect to the push bouncer (should throw)
 | 
						|
      vs. client-side errors like and invalid token.
 | 
						|
 | 
						|
    """
 | 
						|
    url = urllib.parse.urljoin(settings.PUSH_NOTIFICATION_BOUNCER_URL,
 | 
						|
                               '/api/v1/remotes/' + endpoint)
 | 
						|
    api_auth = requests.auth.HTTPBasicAuth(settings.ZULIP_ORG_ID,
 | 
						|
                                           settings.ZULIP_ORG_KEY)
 | 
						|
 | 
						|
    headers = {"User-agent": f"ZulipServer/{ZULIP_VERSION}"}
 | 
						|
    if extra_headers is not None:
 | 
						|
        headers.update(extra_headers)
 | 
						|
 | 
						|
    try:
 | 
						|
        res = requests.request(method,
 | 
						|
                               url,
 | 
						|
                               data=post_data,
 | 
						|
                               auth=api_auth,
 | 
						|
                               timeout=30,
 | 
						|
                               verify=True,
 | 
						|
                               headers=headers)
 | 
						|
    except (requests.exceptions.Timeout, requests.exceptions.SSLError,
 | 
						|
            requests.exceptions.ConnectionError) as e:
 | 
						|
        raise PushNotificationBouncerRetryLaterError(
 | 
						|
            f"{e.__class__.__name__} while trying to connect to push notification bouncer")
 | 
						|
 | 
						|
    if res.status_code >= 500:
 | 
						|
        # 500s should be resolved by the people who run the push
 | 
						|
        # notification bouncer service, and they'll get an appropriate
 | 
						|
        # error notification from the server. We raise an exception to signal
 | 
						|
        # to the callers that the attempt failed and they can retry.
 | 
						|
        error_msg = "Received 500 from push notification bouncer"
 | 
						|
        logging.warning(error_msg)
 | 
						|
        raise PushNotificationBouncerRetryLaterError(error_msg)
 | 
						|
    elif res.status_code >= 400:
 | 
						|
        # If JSON parsing errors, just let that exception happen
 | 
						|
        result_dict = ujson.loads(res.content)
 | 
						|
        msg = result_dict['msg']
 | 
						|
        if 'code' in result_dict and result_dict['code'] == 'INVALID_ZULIP_SERVER':
 | 
						|
            # Invalid Zulip server credentials should email this server's admins
 | 
						|
            raise PushNotificationBouncerException(
 | 
						|
                _("Push notifications bouncer error: %s") % (msg,))
 | 
						|
        else:
 | 
						|
            # But most other errors coming from the push bouncer
 | 
						|
            # server are client errors (e.g. never-registered token)
 | 
						|
            # and should be handled as such.
 | 
						|
            raise JsonableError(msg)
 | 
						|
    elif res.status_code != 200:
 | 
						|
        # Anything else is unexpected and likely suggests a bug in
 | 
						|
        # this version of Zulip, so we throw an exception that will
 | 
						|
        # email the server admins.
 | 
						|
        raise PushNotificationBouncerException(
 | 
						|
            f"Push notification bouncer returned unexpected status code {res.status_code}")
 | 
						|
 | 
						|
    # If we don't throw an exception, it's a successful bounce!
 | 
						|
    return ujson.loads(res.content)
 | 
						|
 | 
						|
def send_json_to_push_bouncer(method: str, endpoint: str, post_data: Dict[str, Any]) -> None:
 | 
						|
    send_to_push_bouncer(
 | 
						|
        method,
 | 
						|
        endpoint,
 | 
						|
        ujson.dumps(post_data),
 | 
						|
        extra_headers={"Content-type": "application/json"},
 | 
						|
    )
 | 
						|
 | 
						|
REALMAUDITLOG_PUSHED_FIELDS = ['id', 'realm', 'event_time', 'backfilled', 'extra_data', 'event_type']
 | 
						|
 | 
						|
def build_analytics_data(realm_count_query: Any,
 | 
						|
                         installation_count_query: Any,
 | 
						|
                         realmauditlog_query: Any) -> Tuple[List[Dict[str, Any]],
 | 
						|
                                                            List[Dict[str, Any]],
 | 
						|
                                                            List[Dict[str, Any]]]:
 | 
						|
    # We limit the batch size on the client side to avoid OOM kills timeouts, etc.
 | 
						|
    MAX_CLIENT_BATCH_SIZE = 10000
 | 
						|
    data = {}
 | 
						|
    data['analytics_realmcount'] = [
 | 
						|
        model_to_dict(row) for row in
 | 
						|
        realm_count_query.order_by("id")[0:MAX_CLIENT_BATCH_SIZE]
 | 
						|
    ]
 | 
						|
    data['analytics_installationcount'] = [
 | 
						|
        model_to_dict(row) for row in
 | 
						|
        installation_count_query.order_by("id")[0:MAX_CLIENT_BATCH_SIZE]
 | 
						|
    ]
 | 
						|
    data['zerver_realmauditlog'] = [
 | 
						|
        model_to_dict(row, fields=REALMAUDITLOG_PUSHED_FIELDS) for row in
 | 
						|
        realmauditlog_query.order_by("id")[0:MAX_CLIENT_BATCH_SIZE]
 | 
						|
    ]
 | 
						|
 | 
						|
    floatify_datetime_fields(data, 'analytics_realmcount')
 | 
						|
    floatify_datetime_fields(data, 'analytics_installationcount')
 | 
						|
    floatify_datetime_fields(data, 'zerver_realmauditlog')
 | 
						|
    return (data['analytics_realmcount'], data['analytics_installationcount'],
 | 
						|
            data['zerver_realmauditlog'])
 | 
						|
 | 
						|
def send_analytics_to_remote_server() -> None:
 | 
						|
    # first, check what's latest
 | 
						|
    try:
 | 
						|
        result = send_to_push_bouncer("GET", "server/analytics/status", {})
 | 
						|
    except PushNotificationBouncerRetryLaterError as e:
 | 
						|
        logging.warning(e.msg)
 | 
						|
        return
 | 
						|
 | 
						|
    last_acked_realm_count_id = result['last_realm_count_id']
 | 
						|
    last_acked_installation_count_id = result['last_installation_count_id']
 | 
						|
    last_acked_realmauditlog_id = result['last_realmauditlog_id']
 | 
						|
 | 
						|
    (realm_count_data, installation_count_data, realmauditlog_data) = build_analytics_data(
 | 
						|
        realm_count_query=RealmCount.objects.filter(
 | 
						|
            id__gt=last_acked_realm_count_id),
 | 
						|
        installation_count_query=InstallationCount.objects.filter(
 | 
						|
            id__gt=last_acked_installation_count_id),
 | 
						|
        realmauditlog_query=RealmAuditLog.objects.filter(
 | 
						|
            event_type__in=RealmAuditLog.SYNCED_BILLING_EVENTS,
 | 
						|
            id__gt=last_acked_realmauditlog_id))
 | 
						|
 | 
						|
    if len(realm_count_data) + len(installation_count_data) + len(realmauditlog_data) == 0:
 | 
						|
        return
 | 
						|
 | 
						|
    request = {
 | 
						|
        'realm_counts': ujson.dumps(realm_count_data),
 | 
						|
        'installation_counts': ujson.dumps(installation_count_data),
 | 
						|
        'realmauditlog_rows': ujson.dumps(realmauditlog_data),
 | 
						|
        'version': ujson.dumps(ZULIP_VERSION),
 | 
						|
    }
 | 
						|
 | 
						|
    # Gather only entries with an ID greater than last_realm_count_id
 | 
						|
    try:
 | 
						|
        send_to_push_bouncer("POST", "server/analytics", request)
 | 
						|
    except JsonableError as e:
 | 
						|
        logging.warning(e.msg)
 |