sensors_analytics_sdk 1.5.0 → 1.6.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/.gitignore +38 -0
- data/.ruby-version +1 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +30 -0
- data/LICENSE +201 -0
- data/README.md +59 -0
- data/Rakefile +10 -0
- data/changeslog.md +6 -0
- data/examples/demo.rb +129 -0
- data/lib/sensors_analytics/client.rb +319 -0
- data/lib/sensors_analytics/consumers.rb +129 -0
- data/lib/sensors_analytics/errors.rb +22 -0
- data/lib/sensors_analytics/http.rb +65 -0
- data/lib/sensors_analytics/version.rb +4 -0
- data/lib/sensors_analytics_sdk.rb +6 -439
- data/sensors_analytics_sdk.gemspec +24 -0
- metadata +79 -8
@@ -0,0 +1,319 @@
|
|
1
|
+
module SensorsAnalytics
|
2
|
+
KEY_PATTERN = /^((?!^distinct_id$|^original_id$|^time$|^properties$|^id$|^first_id$|^second_id$|^users$|^events$|^event$|^user_id$|^date$|^datetime$)[a-zA-Z_$][a-zA-Z\d_$]{0,99})$/
|
3
|
+
|
4
|
+
# Sensors Analytics SDK
|
5
|
+
#
|
6
|
+
# 通过 Sensors Analytics SDK 的构造方法及参数 Consumer 初始化对象,并通过 track, trackSignUp, profileSet 等方法向 Sensors Analytics 发送数据,例如
|
7
|
+
#
|
8
|
+
# consumer = SensorsAnalytics::DefaultConsumer.new(SENSORS_ANALYTICS_URL)
|
9
|
+
# sa = SensorsAnalytics::SensorsAnalytics.new(consumer)
|
10
|
+
# sa.track("abcdefg", "ServerStart", {"sex" => "female"})
|
11
|
+
#
|
12
|
+
# SENSORS_ANALYTICS_URL 是 Sensors Analytics 收集数据的 URI,可以从配置界面中获得。
|
13
|
+
#
|
14
|
+
# Sensors Analytics SDK 通过 Consumer 向 Sensors Analytics 发送数据,提供三种 Consumer:
|
15
|
+
#
|
16
|
+
# DefaultConsumer - 逐条同步发送数据
|
17
|
+
# BatchConsumer - 批量同步发送数据
|
18
|
+
# DebugModeConsumer - Debug 模式,用于校验数据导入是否正确
|
19
|
+
#
|
20
|
+
# Consumer 的具体信息请参看对应注释
|
21
|
+
class SensorsAnalytics
|
22
|
+
|
23
|
+
# Sensors Analytics SDK 构造函数,传入 Consumer 对象
|
24
|
+
def initialize(consumer)
|
25
|
+
@consumer = consumer
|
26
|
+
# 初始化事件公共属性
|
27
|
+
clear_super_properties
|
28
|
+
end
|
29
|
+
|
30
|
+
# 设置每个事件都带有的一些公共属性
|
31
|
+
#
|
32
|
+
# 当 track 的 Properties 和 Super Properties 有相同的 key 时,将采用 track 的
|
33
|
+
def register_super_properties(properties)
|
34
|
+
properties.each do |key, value|
|
35
|
+
@super_properties[key] = value
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# 删除所有已设置的事件公共属性
|
40
|
+
def clear_super_properties
|
41
|
+
@super_properties = {
|
42
|
+
'$lib' => 'Ruby',
|
43
|
+
'$lib_version' => VERSION,
|
44
|
+
}
|
45
|
+
end
|
46
|
+
|
47
|
+
# 记录一个的事件,其中 distinct_id 为触发事件的用户ID,event_name 标示事件名称,properties 是一个哈希表,其中每对元素描述事件的一个属性,哈希表的 Key 必须为 String 类型,哈希表的 Value 可以为 Integer、Float、String、TrueClass 和 FalseClass 类型
|
48
|
+
def track(distinct_id, event_name, properties={})
|
49
|
+
_track_event(:track, distinct_id, distinct_id, event_name, properties)
|
50
|
+
end
|
51
|
+
|
52
|
+
# 记录注册行为,其中 distinct_id 为注册后的用户ID,origin_distinct_id 为注册前的临时ID,properties 是一个哈希表,其中每对元素描述事件的一个属性,哈希表的 Key 必须为 String 类型,哈希表的 Value 可以为 Integer、Float、String、TrueClass 和 FalseClass 类型
|
53
|
+
#
|
54
|
+
# 这个接口是一个较为复杂的功能,请在使用前先阅读相关说明:
|
55
|
+
#
|
56
|
+
# http://www.sensorsdata.cn/manual/track_signup.html
|
57
|
+
#
|
58
|
+
# 并在必要时联系我们的技术支持人员。
|
59
|
+
def track_signup(distinct_id, origin_distinct_id, properties={})
|
60
|
+
_track_event(:track_signup, distinct_id, origin_distinct_id, :$SignUp, properties)
|
61
|
+
end
|
62
|
+
|
63
|
+
# 设置用户的一个或多个属性,properties 是一个哈希表,其中每对元素描述用户的一个属性,哈希表的 Key 必须为 String 类型,哈希表的 Value 可以为 Integer、Float、String、Time、TrueClass 和 FalseClass 类型
|
64
|
+
#
|
65
|
+
# 无论用户该属性值是否存在,都将用 properties 中的属性覆盖原有设置
|
66
|
+
def profile_set(distinct_id, properties)
|
67
|
+
_track_event(:profile_set, distinct_id, distinct_id, nil, properties)
|
68
|
+
end
|
69
|
+
|
70
|
+
# 尝试设置用户的一个或多个属性,properties 是一个哈希表,其中每对元素描述用户的一个属性,哈希表的 Key 必须为 String 类型,哈希表的 Value 可以为 Integer、Float、String、Time、TrueClass 和 FalseClass 类型
|
71
|
+
#
|
72
|
+
# 若用户不存在该属性,则设置用户的属性,否则放弃
|
73
|
+
def profile_set_once(distinct_id, properties)
|
74
|
+
_track_event(:profile_set_once, distinct_id, distinct_id, nil, properties)
|
75
|
+
end
|
76
|
+
|
77
|
+
# 为用户的一个或多个属性累加一个数值,properties 是一个哈希表,其中每对元素描述用户的一个属性,哈希表的 Key 必须为 String 类型,Value 必须为 Integer 类型
|
78
|
+
#
|
79
|
+
# 若该属性不存在,则创建它并设置默认值为0
|
80
|
+
def profile_increment(distinct_id, properties)
|
81
|
+
_track_event(:profile_increment, distinct_id, distinct_id, nil, properties)
|
82
|
+
end
|
83
|
+
|
84
|
+
# 追加数据至用户的一个或多个列表类型的属性,properties 是一个哈希表,其中每对元素描述用户的一个属性,哈希表的 Key 必须为 String 类型,Value 必须为元素是 String 类型的数组
|
85
|
+
#
|
86
|
+
# 若该属性不存在,则创建一个空数组,并插入 properties 中的属性值
|
87
|
+
def profile_append(distinct_id, properties)
|
88
|
+
_track_event(:profile_append, distinct_id, distinct_id, nil, properties)
|
89
|
+
end
|
90
|
+
|
91
|
+
# 删除用户一个或多个属性,properties 是一个数组,其中每个元素描述一个需要删除的属性的 Key
|
92
|
+
def profile_unset(distinct_id, properties)
|
93
|
+
unless properties.is_a?(Array)
|
94
|
+
IllegalDataError.new("Properties of PROFILE UNSET must be an instance of Array<String>.")
|
95
|
+
end
|
96
|
+
property_hash = {}
|
97
|
+
properties.each do |key|
|
98
|
+
property_hash[key] = true
|
99
|
+
end
|
100
|
+
_track_event(:profile_unset, distinct_id, distinct_id, nil, property_hash)
|
101
|
+
end
|
102
|
+
|
103
|
+
# 上报 item 数据
|
104
|
+
def item_set(item_type, item_id, properties)
|
105
|
+
_track_item(:item_set, item_type, item_id, properties)
|
106
|
+
end
|
107
|
+
|
108
|
+
# 删除 item 数据
|
109
|
+
def item_delete(item_type, item_id)
|
110
|
+
_track_item(:item_delete, item_type, item_id, {})
|
111
|
+
end
|
112
|
+
|
113
|
+
private
|
114
|
+
|
115
|
+
def _track_event(event_type, distinct_id, origin_distinct_id, event_name, properties)
|
116
|
+
_assert_key(:DistinctId, distinct_id)
|
117
|
+
_assert_key(:OriginalDistinctId, origin_distinct_id)
|
118
|
+
if event_type == :track
|
119
|
+
_assert_key_with_regex(:EventName, event_name)
|
120
|
+
end
|
121
|
+
_assert_properties(event_type, properties)
|
122
|
+
|
123
|
+
# 从事件属性中获取时间配置
|
124
|
+
event_time = _extract_time_from_properties(properties)
|
125
|
+
properties.delete(:$time)
|
126
|
+
properties.delete("$time")
|
127
|
+
|
128
|
+
event_properties = {}
|
129
|
+
if event_type == :track || event_type == :track_signup
|
130
|
+
event_properties = @super_properties.dup
|
131
|
+
end
|
132
|
+
|
133
|
+
properties.each do |key, value|
|
134
|
+
if value.is_a?(Time)
|
135
|
+
event_properties[key] = value.strftime("%Y-%m-%d %H:%M:%S.#{(value.to_f * 1000.0).to_i % 1000}")
|
136
|
+
else
|
137
|
+
event_properties[key] = value
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
lib_properties = _get_lib_properties
|
142
|
+
|
143
|
+
# Track / TrackSignup / ProfileSet / ProfileSetOne / ProfileIncrement / ProfileAppend / ProfileUnset
|
144
|
+
event = {
|
145
|
+
type: event_type,
|
146
|
+
time: event_time,
|
147
|
+
distinct_id: distinct_id,
|
148
|
+
properties: event_properties,
|
149
|
+
lib: lib_properties,
|
150
|
+
}
|
151
|
+
|
152
|
+
if event_type == :track
|
153
|
+
# Track
|
154
|
+
event[:event] = event_name
|
155
|
+
elsif event_type == :track_signup
|
156
|
+
# TrackSignUp
|
157
|
+
event[:event] = event_name
|
158
|
+
event[:original_id] = origin_distinct_id
|
159
|
+
end
|
160
|
+
|
161
|
+
@consumer.send(event)
|
162
|
+
end
|
163
|
+
|
164
|
+
def _track_item(event_type, item_type, item_id, properties)
|
165
|
+
_assert_key_with_regex(:item_type, item_type)
|
166
|
+
_assert_key(:item_id, item_id)
|
167
|
+
|
168
|
+
if event_type == :item_set
|
169
|
+
_assert_properties(event_type, properties)
|
170
|
+
end
|
171
|
+
|
172
|
+
# 从事件属性中获取时间配置
|
173
|
+
event_time = _extract_time_from_properties(properties)
|
174
|
+
properties.delete(:$time)
|
175
|
+
properties.delete("$time")
|
176
|
+
|
177
|
+
lib_properties = _get_lib_properties
|
178
|
+
|
179
|
+
# event_type 有 item_set 和 item_delete 两种
|
180
|
+
event = {
|
181
|
+
type: event_type,
|
182
|
+
time: event_time,
|
183
|
+
item_id: item_id,
|
184
|
+
item_type: item_type,
|
185
|
+
lib: lib_properties,
|
186
|
+
}
|
187
|
+
|
188
|
+
if event_type == :item_set
|
189
|
+
# item_set 有 properties 属性
|
190
|
+
item_properties = {}
|
191
|
+
|
192
|
+
properties.each do |key, value|
|
193
|
+
if value.is_a?(Time)
|
194
|
+
item_properties[key] = value.strftime("%Y-%m-%d %H:%M:%S.#{(value.to_f * 1000.0).to_i % 1000}")
|
195
|
+
else
|
196
|
+
item_properties[key] = value
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
event[:properties] = item_properties
|
201
|
+
end
|
202
|
+
|
203
|
+
@consumer.send(event)
|
204
|
+
end
|
205
|
+
|
206
|
+
def _extract_time_from_properties(properties)
|
207
|
+
properties.each do |key, value|
|
208
|
+
if (key == :$time || key == "$time") && value.is_a?(Time)
|
209
|
+
return (value.to_f * 1000).to_i
|
210
|
+
end
|
211
|
+
end
|
212
|
+
return (Time.now.to_f * 1000).to_i
|
213
|
+
end
|
214
|
+
|
215
|
+
def _get_lib_properties
|
216
|
+
@lib_properties ||= begin
|
217
|
+
lib_properties = {
|
218
|
+
'$lib' => 'Ruby',
|
219
|
+
'$lib_version' => VERSION,
|
220
|
+
'$lib_method' => 'code',
|
221
|
+
}
|
222
|
+
|
223
|
+
@super_properties.each do |key, value|
|
224
|
+
if key == :$app_version || key == "$app_version"
|
225
|
+
lib_properties[:$app_version] = value
|
226
|
+
end
|
227
|
+
end
|
228
|
+
|
229
|
+
#lib_properties[:$lib_detail] = _get_lib_detail
|
230
|
+
|
231
|
+
lib_properties
|
232
|
+
end
|
233
|
+
end
|
234
|
+
|
235
|
+
def _get_lib_detail
|
236
|
+
begin
|
237
|
+
raise Exception
|
238
|
+
rescue Exception => e
|
239
|
+
trace = e.backtrace[3].split(':')
|
240
|
+
file = trace[0]
|
241
|
+
line = trace[1]
|
242
|
+
function = trace[2].split('`')[1][0..-2]
|
243
|
+
return "###{function}###{file}###{line}"
|
244
|
+
end
|
245
|
+
end
|
246
|
+
|
247
|
+
def _assert_key(type, key)
|
248
|
+
unless key.instance_of?(String) || key.instance_of?(Symbol)
|
249
|
+
raise IllegalDataError.new("#{type} must be an instance of String / Symbol.")
|
250
|
+
end
|
251
|
+
unless key.length >= 1
|
252
|
+
raise IllegalDataError.new("#{type} is empty.")
|
253
|
+
end
|
254
|
+
unless key.length <= 255
|
255
|
+
raise IllegalDataError.new("#{type} is too long, max length is 255.")
|
256
|
+
end
|
257
|
+
end
|
258
|
+
|
259
|
+
def _assert_key_with_regex(type, key)
|
260
|
+
_assert_key(type, key)
|
261
|
+
unless key =~ KEY_PATTERN
|
262
|
+
raise IllegalDataError.new("#{type} '#{key}' is invalid.")
|
263
|
+
end
|
264
|
+
end
|
265
|
+
|
266
|
+
def _assert_properties(event_type, properties)
|
267
|
+
unless properties.instance_of?(Hash)
|
268
|
+
raise IllegalDataError.new("Properties must be an instance of Hash.")
|
269
|
+
end
|
270
|
+
properties.each do |key, value|
|
271
|
+
_assert_key_with_regex(:PropertyKey, key)
|
272
|
+
|
273
|
+
unless value.is_a?(Integer) || value.is_a?(Float) || value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(Array) || value.is_a?(TrueClass) || value.is_a?(FalseClass) || value.is_a?(Time)
|
274
|
+
raise IllegalDataError.new("The properties value must be an instance of Integer/Float/String/Array.")
|
275
|
+
end
|
276
|
+
|
277
|
+
# 属性为 Array 时,元素必须为 String 或 Symbol 类型
|
278
|
+
if value.is_a?(Array)
|
279
|
+
value.each do |element|
|
280
|
+
unless element.is_a?(String) || element.is_a?(Symbol)
|
281
|
+
raise IllegalDataError.new("The properties value of PROFILE APPEND must be an instance of Array[String].")
|
282
|
+
end
|
283
|
+
# 元素的长度不能超过8192
|
284
|
+
unless element.length <= 8192
|
285
|
+
raise IllegalDataError.new("The properties value is too long.")
|
286
|
+
end
|
287
|
+
end
|
288
|
+
end
|
289
|
+
|
290
|
+
# 属性为 String 或 Symbol 时,长度不能超过8191
|
291
|
+
if value.is_a?(String) || value.is_a?(Symbol)
|
292
|
+
unless value.length <= 8192
|
293
|
+
raise IllegalDataError.new("The properties value is too long.")
|
294
|
+
end
|
295
|
+
end
|
296
|
+
|
297
|
+
# profile_increment 的属性必须为数值类型
|
298
|
+
if event_type == :profile_increment
|
299
|
+
unless value.is_a?(Integer)
|
300
|
+
raise IllegalDataError.new("The properties value of PROFILE INCREMENT must be an instance of Integer.")
|
301
|
+
end
|
302
|
+
end
|
303
|
+
|
304
|
+
# profile_append 的属性必须为数组类型,且数组元素必须为字符串
|
305
|
+
if event_type == :profile_append
|
306
|
+
unless value.is_a?(Array)
|
307
|
+
raise IllegalDataError.new("The properties value of PROFILE APPEND must be an instance of Array[String].")
|
308
|
+
end
|
309
|
+
value.each do |element|
|
310
|
+
unless element.is_a?(String) || element.is_a?(Symbol)
|
311
|
+
raise IllegalDataError.new("The properties value of PROFILE APPEND must be an instance of Array[String].")
|
312
|
+
end
|
313
|
+
end
|
314
|
+
end
|
315
|
+
end
|
316
|
+
end
|
317
|
+
|
318
|
+
end
|
319
|
+
end
|
@@ -0,0 +1,129 @@
|
|
1
|
+
require 'base64'
|
2
|
+
require 'json'
|
3
|
+
require 'zlib'
|
4
|
+
|
5
|
+
module SensorsAnalytics
|
6
|
+
class SensorsAnalyticsConsumer
|
7
|
+
def initialize(server_url)
|
8
|
+
@http_client = Http.new(server_url, keep_alive: true)
|
9
|
+
end
|
10
|
+
|
11
|
+
def request!(event_list, headers = {})
|
12
|
+
unless event_list.is_a?(Array) && headers.is_a?(Hash)
|
13
|
+
raise IllegalDataError.new("The argument of 'request!' should be a Array.")
|
14
|
+
end
|
15
|
+
|
16
|
+
# GZip && Base64 encode
|
17
|
+
wio = StringIO.new("w")
|
18
|
+
gzip_io = Zlib::GzipWriter.new(wio)
|
19
|
+
gzip_io.write(event_list.to_json)
|
20
|
+
gzip_io.close
|
21
|
+
data = Base64.encode64(wio.string).gsub("\n", '')
|
22
|
+
form_data = {"data_list" => data, "gzip" => 1}
|
23
|
+
|
24
|
+
@http_client.request(form_data, headers)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
# 实现逐条、同步发送的 Consumer,初始化参数为 Sensors Analytics 收集数据的 URI
|
29
|
+
class DefaultConsumer < SensorsAnalyticsConsumer
|
30
|
+
|
31
|
+
def initialize(server_url)
|
32
|
+
super(server_url)
|
33
|
+
end
|
34
|
+
|
35
|
+
def send(event)
|
36
|
+
event_list = [event]
|
37
|
+
|
38
|
+
begin
|
39
|
+
response_code, response_body = request!(event_list)
|
40
|
+
rescue => e
|
41
|
+
raise ConnectionError.new("Could not connect to Sensors Analytics, with error \"#{e.message}\".")
|
42
|
+
end
|
43
|
+
|
44
|
+
unless response_code.to_i == 200
|
45
|
+
raise ServerError.new("Could not write to Sensors Analytics, server responded with #{response_code} returning: '#{response_body}'")
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
end
|
50
|
+
|
51
|
+
# 实现批量、同步发送的 Consumer,初始化参数为 Sensors Analytics 收集数据的 URI 和批量发送的缓存大小
|
52
|
+
class BatchConsumer < SensorsAnalyticsConsumer
|
53
|
+
|
54
|
+
MAX_FLUSH_BULK = 50
|
55
|
+
|
56
|
+
def initialize(server_url, flush_bulk = MAX_FLUSH_BULK)
|
57
|
+
@event_buffer = []
|
58
|
+
@flush_bulk = [flush_bulk, MAX_FLUSH_BULK].min
|
59
|
+
super(server_url)
|
60
|
+
end
|
61
|
+
|
62
|
+
def send(event)
|
63
|
+
@event_buffer << event
|
64
|
+
flush if @event_buffer.length >= @flush_bulk
|
65
|
+
end
|
66
|
+
|
67
|
+
def flush
|
68
|
+
@event_buffer.each_slice(@flush_bulk) do |event_list|
|
69
|
+
begin
|
70
|
+
response_code, response_body = request!(event_list)
|
71
|
+
rescue => e
|
72
|
+
raise ConnectionError.new("Could not connect to Sensors Analytics, with error \"#{e.message}\".")
|
73
|
+
end
|
74
|
+
|
75
|
+
unless response_code.to_i == 200
|
76
|
+
raise ServerError.new("Could not write to Sensors Analytics, server responded with #{response_code} returning: '#{response_body}'")
|
77
|
+
end
|
78
|
+
end
|
79
|
+
@event_buffer = []
|
80
|
+
end
|
81
|
+
|
82
|
+
end
|
83
|
+
|
84
|
+
# Debug 模式的 Consumer,Debug 模式的具体信息请参考文档
|
85
|
+
#
|
86
|
+
# http://www.sensorsdata.cn/manual/debug_mode.html
|
87
|
+
#
|
88
|
+
# write_data 参数为 true,则 Debug 模式下导入的数据会导入 Sensors Analytics;否则,Debug 模式下导入的数据将只进行格式校验,不会导入 Sensors Analytics 中
|
89
|
+
class DebugConsumer < SensorsAnalyticsConsumer
|
90
|
+
|
91
|
+
def initialize(server_url, write_data)
|
92
|
+
uri = URI.parse(server_url)
|
93
|
+
# 将 URL Path 替换成 Debug 模式的 '/debug'
|
94
|
+
uri.path = '/debug'
|
95
|
+
|
96
|
+
@headers = {}
|
97
|
+
unless write_data
|
98
|
+
@headers['Dry-Run'] = 'true'
|
99
|
+
end
|
100
|
+
|
101
|
+
super(uri.to_s)
|
102
|
+
end
|
103
|
+
|
104
|
+
def send(event)
|
105
|
+
event_list = [event]
|
106
|
+
|
107
|
+
begin
|
108
|
+
response_code, response_body = request!(event_list, @headers)
|
109
|
+
rescue => e
|
110
|
+
raise DebugModeError.new("Could not connect to Sensors Analytics, with error \"#{e.message}\".")
|
111
|
+
end
|
112
|
+
|
113
|
+
puts "=========================================================================="
|
114
|
+
|
115
|
+
if response_code.to_i == 200
|
116
|
+
puts "valid message: #{event_list.to_json}"
|
117
|
+
else
|
118
|
+
puts "invalid message: #{event_list.to_json}"
|
119
|
+
puts "response code: #{response_code}"
|
120
|
+
puts "response body: #{response_body}"
|
121
|
+
end
|
122
|
+
|
123
|
+
if response_code.to_i >= 300
|
124
|
+
raise DebugModeError.new("Could not write to Sensors Analytics, server responded with #{response_code} returning: '#{response_body}'")
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
end
|
129
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module SensorsAnalytics
|
2
|
+
# Sensors Analytics SDK 的异常
|
3
|
+
# 使用 Sensors Analytics SDK 时,应该捕获 IllegalDataError、ConnectionError 和 ServerError。Debug 模式下,会抛出 DebugModeError 用于校验数据导入是否正确,线上运行时不需要捕获 DebugModeError
|
4
|
+
class SensorsAnalyticsError < StandardError
|
5
|
+
end
|
6
|
+
|
7
|
+
# 输入数据格式错误,如 Distinct Id、Event Name、Property Keys 不符合命名规范,或 Property Values 不符合数据类型要求
|
8
|
+
class IllegalDataError < SensorsAnalyticsError
|
9
|
+
end
|
10
|
+
|
11
|
+
# 网络连接错误
|
12
|
+
class ConnectionError < SensorsAnalyticsError
|
13
|
+
end
|
14
|
+
|
15
|
+
# 服务器返回导入失败
|
16
|
+
class ServerError < SensorsAnalyticsError
|
17
|
+
end
|
18
|
+
|
19
|
+
# Debug模式下各种异常
|
20
|
+
class DebugModeError < SensorsAnalyticsError
|
21
|
+
end
|
22
|
+
end
|