rudder-sdk-python 2.1.2__tar.gz → 2.1.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {rudder_sdk_python-2.1.2 → rudder_sdk_python-2.1.3}/PKG-INFO +3 -1
- {rudder_sdk_python-2.1.2 → rudder_sdk_python-2.1.3}/rudder_sdk_python.egg-info/PKG-INFO +3 -1
- rudder_sdk_python-2.1.3/rudder_sdk_python.egg-info/SOURCES.txt +24 -0
- {rudder_sdk_python-2.1.2 → rudder_sdk_python-2.1.3}/rudder_sdk_python.egg-info/requires.txt +2 -0
- rudder_sdk_python-2.1.3/rudder_sdk_python.egg-info/top_level.txt +1 -0
- rudder_sdk_python-2.1.3/rudderstack/analytics/__init__.py +113 -0
- rudder_sdk_python-2.1.3/rudderstack/analytics/client.py +338 -0
- rudder_sdk_python-2.1.3/rudderstack/analytics/consumer.py +134 -0
- rudder_sdk_python-2.1.3/rudderstack/analytics/get_env.py +7 -0
- rudder_sdk_python-2.1.3/rudderstack/analytics/request.py +82 -0
- rudder_sdk_python-2.1.3/rudderstack/analytics/test/__init__.py +82 -0
- rudder_sdk_python-2.1.3/rudderstack/analytics/test/test_client.py +355 -0
- rudder_sdk_python-2.1.3/rudderstack/analytics/test/test_constants.py +1 -0
- rudder_sdk_python-2.1.3/rudderstack/analytics/test/test_consumer.py +226 -0
- rudder_sdk_python-2.1.3/rudderstack/analytics/test/test_module.py +49 -0
- rudder_sdk_python-2.1.3/rudderstack/analytics/test/test_request.py +77 -0
- rudder_sdk_python-2.1.3/rudderstack/analytics/test/test_utils.py +73 -0
- rudder_sdk_python-2.1.3/rudderstack/analytics/utils.py +88 -0
- rudder_sdk_python-2.1.3/rudderstack/analytics/version.py +1 -0
- {rudder_sdk_python-2.1.2 → rudder_sdk_python-2.1.3}/setup.py +4 -2
- rudder_sdk_python-2.1.2/rudder_sdk_python.egg-info/SOURCES.txt +0 -10
- rudder_sdk_python-2.1.2/rudder_sdk_python.egg-info/top_level.txt +0 -1
- {rudder_sdk_python-2.1.2 → rudder_sdk_python-2.1.3}/LICENSE.md +0 -0
- {rudder_sdk_python-2.1.2 → rudder_sdk_python-2.1.3}/README.md +0 -0
- {rudder_sdk_python-2.1.2 → rudder_sdk_python-2.1.3}/rudder_sdk_python.egg-info/dependency_links.txt +0 -0
- {rudder_sdk_python-2.1.2 → rudder_sdk_python-2.1.3}/rudder_sdk_python.egg-info/not-zip-safe +0 -0
- {rudder_sdk_python-2.1.2 → rudder_sdk_python-2.1.3}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: rudder-sdk-python
|
|
3
|
-
Version: 2.1.
|
|
3
|
+
Version: 2.1.3
|
|
4
4
|
Summary: RudderStack is an open-source Segment alternative written in Go, built for the enterprise.
|
|
5
5
|
Home-page: https://github.com/rudderlabs/rudder-sdk-python
|
|
6
6
|
Author: RudderStack
|
|
@@ -27,6 +27,8 @@ License-File: LICENSE.md
|
|
|
27
27
|
Requires-Dist: requests<3.0,>=2.32.3
|
|
28
28
|
Requires-Dist: monotonic<2.0,>=1.6
|
|
29
29
|
Requires-Dist: backoff<3.0,>=2.2.1
|
|
30
|
+
Requires-Dist: python-dateutil<3.0,>=2.2
|
|
31
|
+
Requires-Dist: python-dotenv<2.0.0,>=1.0.1
|
|
30
32
|
Requires-Dist: deprecation<3.0.0,>=2.1.0
|
|
31
33
|
Provides-Extra: test
|
|
32
34
|
Requires-Dist: mock==4.0.3; extra == "test"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: rudder-sdk-python
|
|
3
|
-
Version: 2.1.
|
|
3
|
+
Version: 2.1.3
|
|
4
4
|
Summary: RudderStack is an open-source Segment alternative written in Go, built for the enterprise.
|
|
5
5
|
Home-page: https://github.com/rudderlabs/rudder-sdk-python
|
|
6
6
|
Author: RudderStack
|
|
@@ -27,6 +27,8 @@ License-File: LICENSE.md
|
|
|
27
27
|
Requires-Dist: requests<3.0,>=2.32.3
|
|
28
28
|
Requires-Dist: monotonic<2.0,>=1.6
|
|
29
29
|
Requires-Dist: backoff<3.0,>=2.2.1
|
|
30
|
+
Requires-Dist: python-dateutil<3.0,>=2.2
|
|
31
|
+
Requires-Dist: python-dotenv<2.0.0,>=1.0.1
|
|
30
32
|
Requires-Dist: deprecation<3.0.0,>=2.1.0
|
|
31
33
|
Provides-Extra: test
|
|
32
34
|
Requires-Dist: mock==4.0.3; extra == "test"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
LICENSE.md
|
|
2
|
+
README.md
|
|
3
|
+
setup.cfg
|
|
4
|
+
setup.py
|
|
5
|
+
rudder_sdk_python.egg-info/PKG-INFO
|
|
6
|
+
rudder_sdk_python.egg-info/SOURCES.txt
|
|
7
|
+
rudder_sdk_python.egg-info/dependency_links.txt
|
|
8
|
+
rudder_sdk_python.egg-info/not-zip-safe
|
|
9
|
+
rudder_sdk_python.egg-info/requires.txt
|
|
10
|
+
rudder_sdk_python.egg-info/top_level.txt
|
|
11
|
+
rudderstack/analytics/__init__.py
|
|
12
|
+
rudderstack/analytics/client.py
|
|
13
|
+
rudderstack/analytics/consumer.py
|
|
14
|
+
rudderstack/analytics/get_env.py
|
|
15
|
+
rudderstack/analytics/request.py
|
|
16
|
+
rudderstack/analytics/utils.py
|
|
17
|
+
rudderstack/analytics/version.py
|
|
18
|
+
rudderstack/analytics/test/__init__.py
|
|
19
|
+
rudderstack/analytics/test/test_client.py
|
|
20
|
+
rudderstack/analytics/test/test_constants.py
|
|
21
|
+
rudderstack/analytics/test/test_consumer.py
|
|
22
|
+
rudderstack/analytics/test/test_module.py
|
|
23
|
+
rudderstack/analytics/test/test_request.py
|
|
24
|
+
rudderstack/analytics/test/test_utils.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rudderstack
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
|
|
2
|
+
import warnings
|
|
3
|
+
from rudderstack.analytics.version import VERSION
|
|
4
|
+
from rudderstack.analytics.client import Client
|
|
5
|
+
import deprecation
|
|
6
|
+
__version__ = VERSION
|
|
7
|
+
|
|
8
|
+
"""Settings."""
|
|
9
|
+
write_key = Client.DefaultConfig.write_key
|
|
10
|
+
|
|
11
|
+
@property
|
|
12
|
+
@deprecation.deprecated(deprecated_in="2.0",
|
|
13
|
+
current_version=__version__,
|
|
14
|
+
details="Use the dataPlaneUrl property instead")
|
|
15
|
+
def host(self):
|
|
16
|
+
warnings.warn('The use of host is deprecated. Use dataPlaneUrl instead', DeprecationWarning)
|
|
17
|
+
return host
|
|
18
|
+
|
|
19
|
+
@host.setter
|
|
20
|
+
@deprecation.deprecated(deprecated_in="2.0",
|
|
21
|
+
current_version=__version__,
|
|
22
|
+
details="Use the dataPlaneUrl property instead")
|
|
23
|
+
def host(self, value: str):
|
|
24
|
+
warnings.warn('The use of host is deprecated. Use dataPlaneUrl instead', DeprecationWarning)
|
|
25
|
+
self.host = value
|
|
26
|
+
|
|
27
|
+
host = Client.DefaultConfig.host
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def dataPlaneUrl(self):
|
|
31
|
+
return dataPlaneUrl
|
|
32
|
+
|
|
33
|
+
@dataPlaneUrl.setter
|
|
34
|
+
def dataPlaneUrl(self, value: str):
|
|
35
|
+
self.host = value
|
|
36
|
+
|
|
37
|
+
on_error = Client.DefaultConfig.on_error
|
|
38
|
+
debug = Client.DefaultConfig.debug
|
|
39
|
+
send = Client.DefaultConfig.send
|
|
40
|
+
sync_mode = Client.DefaultConfig.sync_mode
|
|
41
|
+
max_queue_size = Client.DefaultConfig.max_queue_size
|
|
42
|
+
gzip = Client.DefaultConfig.gzip
|
|
43
|
+
timeout = Client.DefaultConfig.timeout
|
|
44
|
+
upload_interval = Client.DefaultConfig.upload_interval
|
|
45
|
+
upload_size = Client.DefaultConfig.upload_size
|
|
46
|
+
max_retries = Client.DefaultConfig.max_retries
|
|
47
|
+
|
|
48
|
+
default_client = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def track(*args, **kwargs):
|
|
52
|
+
"""Send a track call."""
|
|
53
|
+
_proxy('track', *args, **kwargs)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def identify(*args, **kwargs):
|
|
57
|
+
"""Send a identify call."""
|
|
58
|
+
_proxy('identify', *args, **kwargs)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def group(*args, **kwargs):
|
|
62
|
+
"""Send a group call."""
|
|
63
|
+
_proxy('group', *args, **kwargs)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def alias(*args, **kwargs):
|
|
67
|
+
"""Send a alias call."""
|
|
68
|
+
_proxy('alias', *args, **kwargs)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def page(*args, **kwargs):
|
|
72
|
+
"""Send a page call."""
|
|
73
|
+
_proxy('page', *args, **kwargs)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def screen(*args, **kwargs):
|
|
77
|
+
"""Send a screen call."""
|
|
78
|
+
_proxy('screen', *args, **kwargs)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def flush():
|
|
82
|
+
"""Tell the client to flush."""
|
|
83
|
+
_proxy('flush')
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def join():
|
|
87
|
+
"""Block program until the client clears the queue"""
|
|
88
|
+
_proxy('join')
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def shutdown():
|
|
92
|
+
"""Flush all messages and cleanly shutdown the client"""
|
|
93
|
+
_proxy('flush')
|
|
94
|
+
_proxy('join')
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _proxy(method, *args, **kwargs):
|
|
98
|
+
"""Create an analytics client if one doesn't exist and send to it."""
|
|
99
|
+
global default_client
|
|
100
|
+
if not default_client:
|
|
101
|
+
if isinstance(dataPlaneUrl,str) and dataPlaneUrl != "":
|
|
102
|
+
finalDataplaneUrl = dataPlaneUrl
|
|
103
|
+
else:
|
|
104
|
+
finalDataplaneUrl = host
|
|
105
|
+
|
|
106
|
+
default_client = Client(write_key, host=finalDataplaneUrl, debug=debug,
|
|
107
|
+
max_queue_size=max_queue_size,
|
|
108
|
+
send=send, on_error=on_error,
|
|
109
|
+
gzip=gzip, max_retries=max_retries,
|
|
110
|
+
sync_mode=sync_mode, timeout=timeout)
|
|
111
|
+
|
|
112
|
+
fn = getattr(default_client, method)
|
|
113
|
+
fn(*args, **kwargs)
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from uuid import uuid4
|
|
3
|
+
import logging
|
|
4
|
+
import numbers
|
|
5
|
+
import atexit
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
from dateutil.tz import tzutc
|
|
9
|
+
from rudderstack.analytics.get_env import TEST_DATA_PLANE_URL
|
|
10
|
+
|
|
11
|
+
from rudderstack.analytics.utils import guess_timezone, clean
|
|
12
|
+
from rudderstack.analytics.consumer import Consumer, MAX_MSG_SIZE
|
|
13
|
+
from rudderstack.analytics.request import post, DatetimeSerializer
|
|
14
|
+
from rudderstack.analytics.version import VERSION
|
|
15
|
+
|
|
16
|
+
import queue
|
|
17
|
+
|
|
18
|
+
ID_TYPES = (numbers.Number, str)
|
|
19
|
+
CHANNEL = 'server'
|
|
20
|
+
|
|
21
|
+
class Client(object):
|
|
22
|
+
class DefaultConfig(object):
|
|
23
|
+
write_key = None
|
|
24
|
+
host = TEST_DATA_PLANE_URL
|
|
25
|
+
on_error = None
|
|
26
|
+
debug = False
|
|
27
|
+
send = True
|
|
28
|
+
sync_mode = False
|
|
29
|
+
max_queue_size = 10000
|
|
30
|
+
gzip = True
|
|
31
|
+
timeout = 15
|
|
32
|
+
max_retries = 10
|
|
33
|
+
proxies = None
|
|
34
|
+
thread = 1
|
|
35
|
+
upload_interval = 0.5
|
|
36
|
+
upload_size = 100
|
|
37
|
+
|
|
38
|
+
"""Create a new rudderstack client."""
|
|
39
|
+
log = logging.getLogger('rudderstack')
|
|
40
|
+
def __init__(self,
|
|
41
|
+
write_key=DefaultConfig.write_key,
|
|
42
|
+
host=DefaultConfig.host,
|
|
43
|
+
debug=DefaultConfig.debug,
|
|
44
|
+
max_queue_size=DefaultConfig.max_queue_size,
|
|
45
|
+
send=DefaultConfig.send,
|
|
46
|
+
on_error=DefaultConfig.on_error,
|
|
47
|
+
gzip=DefaultConfig.gzip,
|
|
48
|
+
max_retries=DefaultConfig.max_retries,
|
|
49
|
+
sync_mode=DefaultConfig.sync_mode,
|
|
50
|
+
timeout=DefaultConfig.timeout,
|
|
51
|
+
proxies=DefaultConfig.proxies,
|
|
52
|
+
thread=DefaultConfig.thread,
|
|
53
|
+
upload_size=DefaultConfig.upload_size,
|
|
54
|
+
upload_interval=DefaultConfig.upload_interval,):
|
|
55
|
+
require('write_key', write_key, str)
|
|
56
|
+
|
|
57
|
+
self.queue = queue.Queue(max_queue_size)
|
|
58
|
+
self.write_key = write_key
|
|
59
|
+
self.on_error = on_error
|
|
60
|
+
self.debug = debug
|
|
61
|
+
self.send = send
|
|
62
|
+
self.sync_mode = sync_mode
|
|
63
|
+
self.host = host
|
|
64
|
+
self.gzip = gzip
|
|
65
|
+
self.timeout = timeout
|
|
66
|
+
self.proxies = proxies
|
|
67
|
+
|
|
68
|
+
if debug:
|
|
69
|
+
self.log.setLevel(logging.DEBUG)
|
|
70
|
+
|
|
71
|
+
if sync_mode:
|
|
72
|
+
self.consumers = None
|
|
73
|
+
else:
|
|
74
|
+
# On program exit, allow the consumer thread to exit cleanly.
|
|
75
|
+
# This prevents exceptions and a messy shutdown when the
|
|
76
|
+
# interpreter is destroyed before the daemon thread finishes
|
|
77
|
+
# execution. However, it is *not* the same as flushing the queue!
|
|
78
|
+
# To guarantee all messages have been delivered, you'll still need
|
|
79
|
+
# to call flush().
|
|
80
|
+
if send:
|
|
81
|
+
atexit.register(self.join)
|
|
82
|
+
for _ in range(thread):
|
|
83
|
+
self.consumers = []
|
|
84
|
+
consumer = Consumer(
|
|
85
|
+
self.queue, write_key, host=host, on_error=on_error,
|
|
86
|
+
upload_size=upload_size, upload_interval=upload_interval,
|
|
87
|
+
gzip=gzip, retries=max_retries, timeout=timeout,
|
|
88
|
+
proxies=proxies,
|
|
89
|
+
)
|
|
90
|
+
self.consumers.append(consumer)
|
|
91
|
+
|
|
92
|
+
# if we've disabled sending, just don't start the consumer
|
|
93
|
+
if send:
|
|
94
|
+
consumer.start()
|
|
95
|
+
|
|
96
|
+
def identify(self, user_id=None, traits=None, context=None, timestamp=None,
|
|
97
|
+
anonymous_id=None, integrations=None, message_id=None):
|
|
98
|
+
traits = traits or {}
|
|
99
|
+
context = context or {}
|
|
100
|
+
# putting traits inside context
|
|
101
|
+
context['traits'] = traits
|
|
102
|
+
integrations = integrations or {}
|
|
103
|
+
require('user_id or anonymous_id', user_id or anonymous_id, ID_TYPES)
|
|
104
|
+
require('traits', traits, dict)
|
|
105
|
+
|
|
106
|
+
msg = {
|
|
107
|
+
'integrations': integrations,
|
|
108
|
+
'anonymousId': anonymous_id,
|
|
109
|
+
'timestamp': timestamp,
|
|
110
|
+
'context': context,
|
|
111
|
+
'type': 'identify',
|
|
112
|
+
'userId': user_id,
|
|
113
|
+
# 'traits': traits, #traits not needed at root level
|
|
114
|
+
'messageId': message_id,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return self._enqueue(msg)
|
|
118
|
+
|
|
119
|
+
def track(self, user_id=None, event=None, properties=None, context=None,
|
|
120
|
+
timestamp=None, anonymous_id=None, integrations=None,
|
|
121
|
+
message_id=None):
|
|
122
|
+
properties = properties or {}
|
|
123
|
+
context = context or {}
|
|
124
|
+
integrations = integrations or {}
|
|
125
|
+
require('user_id or anonymous_id', user_id or anonymous_id, ID_TYPES)
|
|
126
|
+
require('properties', properties, dict)
|
|
127
|
+
require('event', event, str)
|
|
128
|
+
|
|
129
|
+
msg = {
|
|
130
|
+
'integrations': integrations,
|
|
131
|
+
'anonymousId': anonymous_id,
|
|
132
|
+
'properties': properties,
|
|
133
|
+
'timestamp': timestamp,
|
|
134
|
+
'context': context,
|
|
135
|
+
'userId': user_id,
|
|
136
|
+
'type': 'track',
|
|
137
|
+
'event': event,
|
|
138
|
+
'messageId': message_id,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return self._enqueue(msg)
|
|
142
|
+
|
|
143
|
+
def alias(self, previous_id=None, user_id=None, context=None,
|
|
144
|
+
timestamp=None, integrations=None, message_id=None):
|
|
145
|
+
context = context or {}
|
|
146
|
+
integrations = integrations or {}
|
|
147
|
+
require('previous_id', previous_id, ID_TYPES)
|
|
148
|
+
require('user_id', user_id, ID_TYPES)
|
|
149
|
+
|
|
150
|
+
msg = {
|
|
151
|
+
'integrations': integrations,
|
|
152
|
+
'previousId': previous_id,
|
|
153
|
+
'timestamp': timestamp,
|
|
154
|
+
'context': context,
|
|
155
|
+
'userId': user_id,
|
|
156
|
+
'type': 'alias',
|
|
157
|
+
'messageId': message_id,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return self._enqueue(msg)
|
|
161
|
+
|
|
162
|
+
def group(self, user_id=None, group_id=None, traits=None, context=None,
|
|
163
|
+
timestamp=None, anonymous_id=None, integrations=None,
|
|
164
|
+
message_id=None):
|
|
165
|
+
traits = traits or {}
|
|
166
|
+
context = context or {}
|
|
167
|
+
integrations = integrations or {}
|
|
168
|
+
require('user_id or anonymous_id', user_id or anonymous_id, ID_TYPES)
|
|
169
|
+
require('group_id', group_id, ID_TYPES)
|
|
170
|
+
require('traits', traits, dict)
|
|
171
|
+
|
|
172
|
+
msg = {
|
|
173
|
+
'integrations': integrations,
|
|
174
|
+
'anonymousId': anonymous_id,
|
|
175
|
+
'timestamp': timestamp,
|
|
176
|
+
'groupId': group_id,
|
|
177
|
+
'context': context,
|
|
178
|
+
'userId': user_id,
|
|
179
|
+
'traits': traits,
|
|
180
|
+
'type': 'group',
|
|
181
|
+
'messageId': message_id,
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return self._enqueue(msg)
|
|
185
|
+
|
|
186
|
+
def page(self, user_id=None, category=None, name=None, properties=None,
|
|
187
|
+
context=None, timestamp=None, anonymous_id=None,
|
|
188
|
+
integrations=None, message_id=None):
|
|
189
|
+
properties = properties or {}
|
|
190
|
+
context = context or {}
|
|
191
|
+
integrations = integrations or {}
|
|
192
|
+
require('user_id or anonymous_id', user_id or anonymous_id, ID_TYPES)
|
|
193
|
+
require('properties', properties, dict)
|
|
194
|
+
|
|
195
|
+
if name:
|
|
196
|
+
require('name', name, str)
|
|
197
|
+
if category:
|
|
198
|
+
require('category', category, str)
|
|
199
|
+
|
|
200
|
+
msg = {
|
|
201
|
+
'integrations': integrations,
|
|
202
|
+
'anonymousId': anonymous_id,
|
|
203
|
+
'properties': properties,
|
|
204
|
+
'timestamp': timestamp,
|
|
205
|
+
'category': category,
|
|
206
|
+
'context': context,
|
|
207
|
+
'userId': user_id,
|
|
208
|
+
'type': 'page',
|
|
209
|
+
'name': name,
|
|
210
|
+
'messageId': message_id,
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return self._enqueue(msg)
|
|
214
|
+
|
|
215
|
+
def screen(self, user_id=None, category=None, name=None, properties=None,
|
|
216
|
+
context=None, timestamp=None, anonymous_id=None,
|
|
217
|
+
integrations=None, message_id=None):
|
|
218
|
+
properties = properties or {}
|
|
219
|
+
context = context or {}
|
|
220
|
+
integrations = integrations or {}
|
|
221
|
+
require('user_id or anonymous_id', user_id or anonymous_id, ID_TYPES)
|
|
222
|
+
require('properties', properties, dict)
|
|
223
|
+
|
|
224
|
+
if name:
|
|
225
|
+
require('name', name, str)
|
|
226
|
+
if category:
|
|
227
|
+
require('category', category, str)
|
|
228
|
+
|
|
229
|
+
msg = {
|
|
230
|
+
'integrations': integrations,
|
|
231
|
+
'anonymousId': anonymous_id,
|
|
232
|
+
'properties': properties,
|
|
233
|
+
'timestamp': timestamp,
|
|
234
|
+
'category': category,
|
|
235
|
+
'context': context,
|
|
236
|
+
'userId': user_id,
|
|
237
|
+
'type': 'screen',
|
|
238
|
+
'name': name,
|
|
239
|
+
'messageId': message_id,
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return self._enqueue(msg)
|
|
243
|
+
|
|
244
|
+
def _enqueue(self, msg):
|
|
245
|
+
"""Push a new `msg` onto the queue, return `(success, msg)`"""
|
|
246
|
+
timestamp = msg['timestamp']
|
|
247
|
+
if timestamp is None:
|
|
248
|
+
timestamp = datetime.utcnow().replace(tzinfo=tzutc())
|
|
249
|
+
message_id = msg.get('messageId')
|
|
250
|
+
if message_id is None:
|
|
251
|
+
message_id = uuid4()
|
|
252
|
+
|
|
253
|
+
# default integrations should be "All": True
|
|
254
|
+
if msg['integrations'] == {} :
|
|
255
|
+
msg['integrations'] = {"All" : True}
|
|
256
|
+
|
|
257
|
+
require('integrations', msg['integrations'], dict)
|
|
258
|
+
require('type', msg['type'], str)
|
|
259
|
+
require('timestamp', timestamp, datetime)
|
|
260
|
+
require('context', msg['context'], dict)
|
|
261
|
+
|
|
262
|
+
# add common
|
|
263
|
+
timestamp = guess_timezone(timestamp)
|
|
264
|
+
msg['timestamp'] = timestamp.isoformat()
|
|
265
|
+
msg['messageId'] = stringify_id(message_id)
|
|
266
|
+
msg['context']['library'] = {
|
|
267
|
+
'name': 'analytics-python',
|
|
268
|
+
'version': VERSION
|
|
269
|
+
}
|
|
270
|
+
msg['userId'] = stringify_id(msg.get('userId', None))
|
|
271
|
+
msg['anonymousId'] = stringify_id(msg.get('anonymousId', None))
|
|
272
|
+
msg['channel'] = CHANNEL
|
|
273
|
+
msg = clean(msg)
|
|
274
|
+
self.log.debug('queueing: %s', msg)
|
|
275
|
+
|
|
276
|
+
# Check message size.
|
|
277
|
+
msg_size = len(json.dumps(msg, cls=DatetimeSerializer).encode())
|
|
278
|
+
if msg_size > MAX_MSG_SIZE:
|
|
279
|
+
raise RuntimeError(f'Message exceeds {str(int(MAX_MSG_SIZE / 1024))}kb limit. ({str(msg)})')
|
|
280
|
+
|
|
281
|
+
# if send is False, return msg as if it was successfully queued
|
|
282
|
+
if not self.send:
|
|
283
|
+
return True, msg
|
|
284
|
+
|
|
285
|
+
if self.sync_mode:
|
|
286
|
+
self.log.debug('enqueued with blocking %s.', msg['type'])
|
|
287
|
+
post(self.write_key, self.host, gzip=self.gzip,
|
|
288
|
+
timeout=self.timeout, proxies=self.proxies, batch=[msg])
|
|
289
|
+
|
|
290
|
+
return True, msg
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
self.queue.put(msg, block=False)
|
|
294
|
+
self.log.debug('enqueued %s.', msg['type'])
|
|
295
|
+
return True, msg
|
|
296
|
+
except queue.Full:
|
|
297
|
+
self.log.warning('analytics-python queue is full')
|
|
298
|
+
return False, msg
|
|
299
|
+
|
|
300
|
+
def flush(self):
|
|
301
|
+
"""Forces a flush from the internal queue to the server"""
|
|
302
|
+
queue = self.queue
|
|
303
|
+
size = queue.qsize()
|
|
304
|
+
queue.join()
|
|
305
|
+
# Note that this message may not be precise, because of threading.
|
|
306
|
+
self.log.debug('successfully flushed about %s items.', size)
|
|
307
|
+
|
|
308
|
+
def join(self):
|
|
309
|
+
"""Ends the consumer thread once the queue is empty.
|
|
310
|
+
Blocks execution until finished
|
|
311
|
+
"""
|
|
312
|
+
for consumer in self.consumers:
|
|
313
|
+
consumer.pause()
|
|
314
|
+
try:
|
|
315
|
+
consumer.join()
|
|
316
|
+
except RuntimeError:
|
|
317
|
+
# consumer thread has not started
|
|
318
|
+
pass
|
|
319
|
+
|
|
320
|
+
def shutdown(self):
|
|
321
|
+
"""Flush all messages and cleanly shutdown the client"""
|
|
322
|
+
self.flush()
|
|
323
|
+
self.join()
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def require(name, field, data_type):
|
|
327
|
+
"""Require that the named `field` has the right `data_type`"""
|
|
328
|
+
if not isinstance(field, data_type):
|
|
329
|
+
msg = '{0} must have {1}, got: {2}'.format(name, data_type, field)
|
|
330
|
+
raise AssertionError(msg)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def stringify_id(val):
|
|
334
|
+
if val is None:
|
|
335
|
+
return None
|
|
336
|
+
if isinstance(val, str):
|
|
337
|
+
return val
|
|
338
|
+
return str(val)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from threading import Thread
|
|
3
|
+
import monotonic
|
|
4
|
+
import backoff
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from rudderstack.analytics.request import post, APIError, DatetimeSerializer
|
|
8
|
+
|
|
9
|
+
from queue import Empty
|
|
10
|
+
|
|
11
|
+
MAX_MSG_SIZE = 32 << 10
|
|
12
|
+
|
|
13
|
+
# Our servers only accept batches less than 500KB. Here limit is set slightly
|
|
14
|
+
# lower to leave space for extra data that will be added later, eg. "sentAt".
|
|
15
|
+
BATCH_SIZE_LIMIT = 475000
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Consumer(Thread):
|
|
19
|
+
"""Consumes the messages from the client's queue."""
|
|
20
|
+
log = logging.getLogger('rudderstack')
|
|
21
|
+
|
|
22
|
+
def __init__(self, queue, write_key, upload_size=100, host=None,
|
|
23
|
+
on_error=None, upload_interval=0.5, gzip=True, retries=10,
|
|
24
|
+
timeout=15, proxies=None):
|
|
25
|
+
"""Create a consumer thread."""
|
|
26
|
+
Thread.__init__(self)
|
|
27
|
+
# Make consumer a daemon thread so that it doesn't block program exit
|
|
28
|
+
self.daemon = True
|
|
29
|
+
self.upload_size = upload_size
|
|
30
|
+
self.upload_interval = upload_interval
|
|
31
|
+
self.write_key = write_key
|
|
32
|
+
self.host = host
|
|
33
|
+
self.on_error = on_error
|
|
34
|
+
self.queue = queue
|
|
35
|
+
self.gzip = gzip
|
|
36
|
+
# It's important to set running in the constructor: if we are asked to
|
|
37
|
+
# pause immediately after construction, we might set running to True in
|
|
38
|
+
# run() *after* we set it to False in pause... and keep running
|
|
39
|
+
# forever.
|
|
40
|
+
self.running = True
|
|
41
|
+
self.retries = retries
|
|
42
|
+
self.timeout = timeout
|
|
43
|
+
self.proxies = proxies
|
|
44
|
+
|
|
45
|
+
def run(self):
|
|
46
|
+
"""Runs the consumer."""
|
|
47
|
+
self.log.debug('consumer is running...')
|
|
48
|
+
while self.running:
|
|
49
|
+
self.upload()
|
|
50
|
+
|
|
51
|
+
self.log.debug('consumer exited.')
|
|
52
|
+
|
|
53
|
+
def pause(self):
|
|
54
|
+
"""Pause the consumer."""
|
|
55
|
+
self.running = False
|
|
56
|
+
|
|
57
|
+
def upload(self):
|
|
58
|
+
"""Upload the next batch of items, return whether successful."""
|
|
59
|
+
success = False
|
|
60
|
+
batch = self.next()
|
|
61
|
+
if len(batch) == 0:
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
self.request(batch)
|
|
66
|
+
success = True
|
|
67
|
+
except Exception as e:
|
|
68
|
+
self.log.error('error uploading: %s', e)
|
|
69
|
+
success = False
|
|
70
|
+
if self.on_error:
|
|
71
|
+
self.on_error(e, batch)
|
|
72
|
+
finally:
|
|
73
|
+
# mark items as acknowledged from queue
|
|
74
|
+
for _ in batch:
|
|
75
|
+
self.queue.task_done()
|
|
76
|
+
return success
|
|
77
|
+
|
|
78
|
+
def next(self):
|
|
79
|
+
"""Return the next batch of items to upload."""
|
|
80
|
+
queue = self.queue
|
|
81
|
+
items = []
|
|
82
|
+
|
|
83
|
+
start_time = monotonic.monotonic()
|
|
84
|
+
total_size = 0
|
|
85
|
+
|
|
86
|
+
while len(items) < self.upload_size:
|
|
87
|
+
elapsed = monotonic.monotonic() - start_time
|
|
88
|
+
if elapsed >= self.upload_interval:
|
|
89
|
+
break
|
|
90
|
+
try:
|
|
91
|
+
item = queue.get(
|
|
92
|
+
block=True, timeout=self.upload_interval - elapsed)
|
|
93
|
+
item_size = len(json.dumps(
|
|
94
|
+
item, cls=DatetimeSerializer).encode())
|
|
95
|
+
if item_size > MAX_MSG_SIZE:
|
|
96
|
+
self.log.error(
|
|
97
|
+
'Item exceeds 32kb limit, dropping. (%s)', str(item))
|
|
98
|
+
continue
|
|
99
|
+
items.append(item)
|
|
100
|
+
total_size += item_size
|
|
101
|
+
if total_size >= BATCH_SIZE_LIMIT:
|
|
102
|
+
self.log.debug(
|
|
103
|
+
'hit batch size limit (size: %d)', total_size)
|
|
104
|
+
break
|
|
105
|
+
except Empty:
|
|
106
|
+
break
|
|
107
|
+
except Exception as e:
|
|
108
|
+
self.log.exception('Exception: %s', e)
|
|
109
|
+
|
|
110
|
+
return items
|
|
111
|
+
|
|
112
|
+
def request(self, batch):
|
|
113
|
+
"""Attempt to upload the batch and retry before raising an error """
|
|
114
|
+
|
|
115
|
+
def fatal_exception(exc):
|
|
116
|
+
if isinstance(exc, APIError):
|
|
117
|
+
# retry on server errors and client errors
|
|
118
|
+
# with 429 status code (rate limited),
|
|
119
|
+
# don't retry on other client errors
|
|
120
|
+
return (400 <= exc.status < 500) and exc.status != 429
|
|
121
|
+
else:
|
|
122
|
+
# retry on all other errors (eg. network)
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
@backoff.on_exception(
|
|
126
|
+
backoff.expo,
|
|
127
|
+
Exception,
|
|
128
|
+
max_tries=self.retries + 1,
|
|
129
|
+
giveup=fatal_exception)
|
|
130
|
+
def send_request():
|
|
131
|
+
post(self.write_key, self.host, gzip=self.gzip,
|
|
132
|
+
timeout=self.timeout, batch=batch, proxies=self.proxies)
|
|
133
|
+
|
|
134
|
+
send_request()
|