posthog-ruby 3.29.0 → 3.30.0
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.
- checksums.yaml +4 -4
- data/lib/posthog/client.rb +44 -20
- data/lib/posthog/defaults.rb +5 -0
- data/lib/posthog/email_error.rb +57 -0
- data/lib/posthog/emails.rb +518 -0
- data/lib/posthog/feature_flags.rb +18 -28
- data/lib/posthog/transport.rb +0 -13
- data/lib/posthog/utils.rb +26 -0
- data/lib/posthog/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 83e0b91accb2b3ec2fa0f56ece8a8823877290454f55a7fa3f1c810f3eee4577
|
|
4
|
+
data.tar.gz: fb8bc42c783e9fb5ce9601fc68b83024453f67aa2cb7330d95c1481f2cd23e0d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a157aa2438e7845107409133cc1825ceacde289d070ee6d13140f89d26341030fd45cfc8b8004caeb588d4dc3b982e693490027d67091dc6305cbdcafb2e0861
|
|
7
|
+
data.tar.gz: 63e42fc08a1241e5fe7c0e6de790e3bc4293fa3b36067e8f80f77f521760e0be82a975b94e9ce4121f75e5a86a07f1f44954c0abd341b759457c2f1bc492172c
|
data/lib/posthog/client.rb
CHANGED
|
@@ -15,6 +15,7 @@ require 'posthog/feature_flags'
|
|
|
15
15
|
require 'posthog/feature_flag_evaluations'
|
|
16
16
|
require 'posthog/send_feature_flags_options'
|
|
17
17
|
require 'posthog/exception_capture'
|
|
18
|
+
require 'posthog/emails'
|
|
18
19
|
require 'posthog/internal/context'
|
|
19
20
|
|
|
20
21
|
module PostHog
|
|
@@ -127,6 +128,14 @@ module PostHog
|
|
|
127
128
|
# @option opts [Boolean] :compress_request Set to +false+ to disable gzip compression for batch uploads.
|
|
128
129
|
# @option opts [Object] :flag_definition_cache_provider An object implementing the {FlagDefinitionCacheProvider}
|
|
129
130
|
# interface for distributed flag definition caching.
|
|
131
|
+
# @option opts [String, nil] :app_host Your PostHog app host, such as `https://posthog.example.com`, for {#emails}.
|
|
132
|
+
# Needed only when +:host+ is not PostHog Cloud, such as a reverse proxy, which must not receive the secret key.
|
|
133
|
+
# @option opts [Numeric] :email_request_timeout_seconds How long each {#emails} request may take, in seconds.
|
|
134
|
+
# Defaults to 15.
|
|
135
|
+
# @option opts [Integer] :email_request_max_retries How many times {#emails} retries a request after a network
|
|
136
|
+
# error, a rate limit or an unavailable service. Defaults to 2. Set to 0 when a job runner already retries.
|
|
137
|
+
# @option opts [String, nil] :migrated_from The email provider you moved from, such as `resend`. {#emails} sends
|
|
138
|
+
# it with every request.
|
|
130
139
|
# @option opts [Boolean] :is_server +true+ to stamp captured events with `$is_server => true` so PostHog
|
|
131
140
|
# attributes them as server-side. Defaults to +true+. Set to +false+ when running posthog-ruby as a
|
|
132
141
|
# client/CLI so the device OS is attributed normally and `$is_server` is omitted.
|
|
@@ -154,6 +163,19 @@ module PostHog
|
|
|
154
163
|
@lib = opts[:_lib] || 'posthog-ruby'
|
|
155
164
|
@lib_version = (opts[:_lib_version] || PostHog::VERSION).to_s
|
|
156
165
|
@headers = Defaults::Request::HEADERS.merge('User-Agent' => "#{@lib}/#{@lib_version}")
|
|
166
|
+
# Built before the worker, the instance count and the flags poller start, so a bad email option raises
|
|
167
|
+
# before anything runs or sends the secret key.
|
|
168
|
+
@emails = Emails.new(
|
|
169
|
+
secret_key: secret_key,
|
|
170
|
+
host: opts[:host],
|
|
171
|
+
app_host: opts[:app_host],
|
|
172
|
+
timeout: opts[:email_request_timeout_seconds] || Defaults::Emails::REQUEST_TIMEOUT_SECONDS,
|
|
173
|
+
max_retries: opts[:email_request_max_retries] || Defaults::Emails::REQUEST_MAX_RETRIES,
|
|
174
|
+
migrated_from: opts[:migrated_from],
|
|
175
|
+
user_agent: @headers['User-Agent'],
|
|
176
|
+
disabled: @disabled,
|
|
177
|
+
skip_ssl_verification: opts[:skip_ssl_verification] == true
|
|
178
|
+
)
|
|
157
179
|
@worker_mutex = Mutex.new
|
|
158
180
|
@shutdown_mutex = Mutex.new
|
|
159
181
|
@shutdown_condition = ConditionVariable.new
|
|
@@ -233,6 +255,13 @@ module PostHog
|
|
|
233
255
|
@deprecation_emitted_for = Concurrent::Set.new
|
|
234
256
|
end
|
|
235
257
|
|
|
258
|
+
# Sends transactional email from email templates made in PostHog or from content your code rendered, and reads
|
|
259
|
+
# the status of each email sent. Needs +:secret_key+, a project secret API key with the
|
|
260
|
+
# `transactional_email:write` scope.
|
|
261
|
+
#
|
|
262
|
+
# @return [PostHog::Emails]
|
|
263
|
+
attr_reader :emails
|
|
264
|
+
|
|
236
265
|
# When sync_mode is enabled, blocks until in-flight requests are complete
|
|
237
266
|
# and returns true. Timeout parameter has no effect.
|
|
238
267
|
#
|
|
@@ -671,7 +700,7 @@ module PostHog
|
|
|
671
700
|
)
|
|
672
701
|
end
|
|
673
702
|
|
|
674
|
-
person_properties, group_properties = add_local_person_and_group_properties(
|
|
703
|
+
groups, person_properties, group_properties = add_local_person_and_group_properties(
|
|
675
704
|
groups, person_properties, group_properties
|
|
676
705
|
)
|
|
677
706
|
|
|
@@ -806,8 +835,9 @@ module PostHog
|
|
|
806
835
|
)
|
|
807
836
|
return {} if @disabled
|
|
808
837
|
|
|
809
|
-
person_properties, group_properties = add_local_person_and_group_properties(
|
|
810
|
-
|
|
838
|
+
groups, person_properties, group_properties = add_local_person_and_group_properties(
|
|
839
|
+
groups, person_properties, group_properties
|
|
840
|
+
)
|
|
811
841
|
@feature_flags_poller.get_all_flags(distinct_id, groups, person_properties, group_properties,
|
|
812
842
|
only_evaluate_locally)
|
|
813
843
|
end
|
|
@@ -846,8 +876,9 @@ module PostHog
|
|
|
846
876
|
return nil if @disabled
|
|
847
877
|
|
|
848
878
|
key = key.to_s
|
|
849
|
-
person_properties, group_properties = add_local_person_and_group_properties(
|
|
850
|
-
|
|
879
|
+
groups, person_properties, group_properties = add_local_person_and_group_properties(
|
|
880
|
+
groups, person_properties, group_properties
|
|
881
|
+
)
|
|
851
882
|
@feature_flags_poller.get_feature_flag_payload(key, distinct_id, match_value, groups, person_properties,
|
|
852
883
|
group_properties, only_evaluate_locally)
|
|
853
884
|
end
|
|
@@ -873,7 +904,7 @@ module PostHog
|
|
|
873
904
|
)
|
|
874
905
|
return { featureFlags: {}, featureFlagPayloads: {} } if @disabled
|
|
875
906
|
|
|
876
|
-
person_properties, group_properties = add_local_person_and_group_properties(
|
|
907
|
+
groups, person_properties, group_properties = add_local_person_and_group_properties(
|
|
877
908
|
groups, person_properties, group_properties
|
|
878
909
|
)
|
|
879
910
|
response = @feature_flags_poller.get_all_flags_and_payloads(
|
|
@@ -977,7 +1008,8 @@ module PostHog
|
|
|
977
1008
|
@distinct_id_has_sent_flag_calls_mutex.synchronize do
|
|
978
1009
|
@distinct_id_has_sent_flag_calls.clear
|
|
979
1010
|
end
|
|
980
|
-
|
|
1011
|
+
emails_settled = @emails.close(timeout: deadline && [deadline - monotonic_time, 0].max)
|
|
1012
|
+
flushed &&= emails_settled
|
|
981
1013
|
ensure
|
|
982
1014
|
@shutdown_mutex.synchronize do
|
|
983
1015
|
@shutdown_result = flushed
|
|
@@ -1124,7 +1156,7 @@ module PostHog
|
|
|
1124
1156
|
return nil if @disabled
|
|
1125
1157
|
|
|
1126
1158
|
key = key.to_s
|
|
1127
|
-
person_properties, group_properties = add_local_person_and_group_properties(
|
|
1159
|
+
groups, person_properties, group_properties = add_local_person_and_group_properties(
|
|
1128
1160
|
groups, person_properties, group_properties
|
|
1129
1161
|
)
|
|
1130
1162
|
feature_flag_response, flag_was_locally_evaluated, request_id, evaluated_at, feature_flag_error, payload,
|
|
@@ -1290,17 +1322,9 @@ module PostHog
|
|
|
1290
1322
|
end
|
|
1291
1323
|
|
|
1292
1324
|
def add_local_person_and_group_properties(groups, person_properties, group_properties)
|
|
1293
|
-
groups
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
symbolize_keys! groups
|
|
1298
|
-
symbolize_keys! person_properties
|
|
1299
|
-
symbolize_keys! group_properties
|
|
1300
|
-
|
|
1301
|
-
group_properties.each_value do |value|
|
|
1302
|
-
symbolize_keys! value
|
|
1303
|
-
end
|
|
1325
|
+
groups, person_properties, group_properties = normalize_flag_evaluation_inputs(
|
|
1326
|
+
groups, person_properties, group_properties
|
|
1327
|
+
)
|
|
1304
1328
|
|
|
1305
1329
|
all_group_properties = {}
|
|
1306
1330
|
groups&.each do |group_name, group_key|
|
|
@@ -1309,7 +1333,7 @@ module PostHog
|
|
|
1309
1333
|
}.merge((group_properties && group_properties[group_name]) || {})
|
|
1310
1334
|
end
|
|
1311
1335
|
|
|
1312
|
-
[person_properties, all_group_properties]
|
|
1336
|
+
[groups, person_properties, all_group_properties]
|
|
1313
1337
|
end
|
|
1314
1338
|
end
|
|
1315
1339
|
end
|
data/lib/posthog/defaults.rb
CHANGED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PostHog
|
|
4
|
+
# PostHog did not queue the email, or the client could not tell whether it did.
|
|
5
|
+
#
|
|
6
|
+
# Branch on {#code}, PostHog's stable error code, or on {#name}, a coarse category of the failure.
|
|
7
|
+
# The message never contains the secret key or the request body.
|
|
8
|
+
class EmailError < StandardError
|
|
9
|
+
# @return [String] A coarse category of the failure, such as `validation_error`, `rate_limit_exceeded`
|
|
10
|
+
# or `service_unavailable`.
|
|
11
|
+
attr_reader :name
|
|
12
|
+
# @return [String] PostHog's error code, such as `quota_limited`, or an SDK code such as `network_error`.
|
|
13
|
+
attr_reader :code
|
|
14
|
+
# @return [Integer, nil] The HTTP status, when PostHog answered.
|
|
15
|
+
attr_reader :status_code
|
|
16
|
+
# @return [String, nil] Why PostHog refused the email.
|
|
17
|
+
attr_reader :why
|
|
18
|
+
# @return [String, nil] How to fix the request.
|
|
19
|
+
attr_reader :fix
|
|
20
|
+
# @return [String, nil] A link to the docs for this error.
|
|
21
|
+
attr_reader :docs_url
|
|
22
|
+
# @return [String, nil] The request field the error is about, when PostHog named one.
|
|
23
|
+
attr_reader :field
|
|
24
|
+
# @return [Float, nil] How long PostHog asked the client to wait before it sends again, in seconds.
|
|
25
|
+
attr_reader :retry_after
|
|
26
|
+
|
|
27
|
+
# @api private
|
|
28
|
+
def initialize(code:, message:, status_code: nil, why: nil, fix: nil, docs_url: nil, field: nil, retry_after: nil)
|
|
29
|
+
super(message)
|
|
30
|
+
@name = self.class.name_for(status_code, code)
|
|
31
|
+
@code = code
|
|
32
|
+
@status_code = status_code
|
|
33
|
+
@why = why
|
|
34
|
+
@fix = fix
|
|
35
|
+
@docs_url = docs_url
|
|
36
|
+
@field = field
|
|
37
|
+
@retry_after = retry_after
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @api private
|
|
41
|
+
def self.name_for(status_code, code)
|
|
42
|
+
return 'missing_api_key' if code == 'missing_secret_key' || status_code == 401
|
|
43
|
+
return code == 'network_error' ? 'service_unavailable' : 'application_error' if status_code.nil?
|
|
44
|
+
|
|
45
|
+
case status_code
|
|
46
|
+
when 400 then 'validation_error'
|
|
47
|
+
when 403 then 'invalid_permission'
|
|
48
|
+
when 404 then 'not_found'
|
|
49
|
+
when 409 then code == 'template_outdated' ? 'validation_error' : 'concurrent_idempotent_requests'
|
|
50
|
+
when 422 then code == 'idempotency_key_reused' ? 'invalid_idempotent_request' : 'validation_error'
|
|
51
|
+
when 429 then code == 'quota_limited' ? 'monthly_quota_exceeded' : 'rate_limit_exceeded'
|
|
52
|
+
when 503 then 'service_unavailable'
|
|
53
|
+
else 'application_error'
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'openssl'
|
|
6
|
+
require 'securerandom'
|
|
7
|
+
require 'time'
|
|
8
|
+
require 'uri'
|
|
9
|
+
require 'zlib'
|
|
10
|
+
|
|
11
|
+
require 'posthog/email_error'
|
|
12
|
+
require 'posthog/logging'
|
|
13
|
+
require 'posthog/utils'
|
|
14
|
+
|
|
15
|
+
module PostHog
|
|
16
|
+
# Sends transactional email, from an email template made in PostHog or from content your code rendered,
|
|
17
|
+
# and reads the status of each email sent. Reach it through {Client#emails}.
|
|
18
|
+
#
|
|
19
|
+
# A send needs the client's `secret_key`, a project secret API key (`phs_...`) with the
|
|
20
|
+
# `transactional_email:write` scope: the "Send transactional email" preset in your project settings. A key with
|
|
21
|
+
# only `transactional_email:read` can read statuses but not send. Keys made with `hog_flow:write` still work.
|
|
22
|
+
# Sends go to the PostHog app host: a client on PostHog Cloud uses its region's app host, and any other `host`
|
|
23
|
+
# needs `app_host`, because a reverse proxy for ingestion must not receive the secret key.
|
|
24
|
+
#
|
|
25
|
+
# Each send retries network errors, rate limits and an unavailable service, waiting about 1 and then 2 seconds,
|
|
26
|
+
# or the server's `Retry-After` when that is longer. Every attempt sends the same idempotency key, so a retry
|
|
27
|
+
# never sends twice.
|
|
28
|
+
#
|
|
29
|
+
# @example A template made in PostHog
|
|
30
|
+
# posthog.emails.send(
|
|
31
|
+
# to: 'user@example.com',
|
|
32
|
+
# template_id: '0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b',
|
|
33
|
+
# template_data: { name: 'Ada' },
|
|
34
|
+
# distinct_id: 'user_123',
|
|
35
|
+
# idempotency_key: 'welcome:user_123'
|
|
36
|
+
# )
|
|
37
|
+
#
|
|
38
|
+
# @example Content your code rendered, such as React Email output
|
|
39
|
+
# posthog.emails.send(
|
|
40
|
+
# to: 'user@example.com',
|
|
41
|
+
# template_key: 'password-reset',
|
|
42
|
+
# subject: 'Reset your password',
|
|
43
|
+
# html: rendered_html,
|
|
44
|
+
# distinct_id: 'user_123'
|
|
45
|
+
# )
|
|
46
|
+
class Emails
|
|
47
|
+
include PostHog::Logging
|
|
48
|
+
include PostHog::Utils
|
|
49
|
+
|
|
50
|
+
# The queued email. A result means PostHog queued the email, not that it was delivered.
|
|
51
|
+
#
|
|
52
|
+
# @!attribute [r] id
|
|
53
|
+
# @return [String] The queued email's message ID.
|
|
54
|
+
# @!attribute [r] replayed
|
|
55
|
+
# @return [Boolean] Whether PostHog had already accepted this idempotency key and returned the first result.
|
|
56
|
+
# @!attribute [r] warnings
|
|
57
|
+
# @return [Array<Warning>]
|
|
58
|
+
SendResult = Struct.new(:id, :replayed, :warnings, keyword_init: true)
|
|
59
|
+
|
|
60
|
+
# A warning PostHog returned with a queued email.
|
|
61
|
+
Warning = Struct.new(:code, :message, :fix, keyword_init: true)
|
|
62
|
+
|
|
63
|
+
# What happened to an email that {Emails#send} queued.
|
|
64
|
+
#
|
|
65
|
+
# @!attribute [r] status
|
|
66
|
+
# @return [String] `queued`, `sent`, `failed`, `delivered`, `bounced` or `complained`. Later SDK versions
|
|
67
|
+
# can see values this list does not have yet.
|
|
68
|
+
# @!attribute [r] reason
|
|
69
|
+
# @return [String, nil] Why a failed email was not sent, such as `recipient_suppressed`, or the bounce type
|
|
70
|
+
# of a bounced email: `hard`, `soft` or `unknown`.
|
|
71
|
+
Status = Struct.new(:id, :status, :created_at, :status_at, :reason, keyword_init: true)
|
|
72
|
+
|
|
73
|
+
SEND_PATH = '/api/projects/@current/messaging/send/email/'
|
|
74
|
+
STATUS_PATH = '/api/projects/@current/messaging/emails/%<message_id>s/'
|
|
75
|
+
US_APP_HOST = 'https://us.posthog.com'
|
|
76
|
+
EU_APP_HOST = 'https://eu.posthog.com'
|
|
77
|
+
CLOUD_APP_HOSTS = {
|
|
78
|
+
'https://us.i.posthog.com' => US_APP_HOST,
|
|
79
|
+
'https://us.posthog.com' => US_APP_HOST,
|
|
80
|
+
'https://app.posthog.com' => US_APP_HOST,
|
|
81
|
+
'https://eu.i.posthog.com' => EU_APP_HOST,
|
|
82
|
+
'https://eu.posthog.com' => EU_APP_HOST
|
|
83
|
+
}.freeze
|
|
84
|
+
MAX_RETRY_DELAY_SECONDS = 30.0
|
|
85
|
+
UUID_PATTERN = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
|
|
86
|
+
IDEMPOTENCY_KEY_PATTERN = /\A[\x21-\x7e]{1,255}\z/
|
|
87
|
+
TEMPLATE_KEY_PATTERN = /\A[a-zA-Z0-9_-]{1,400}\z/
|
|
88
|
+
HEADER_TOKEN_PATTERN = /\A[\x21-\x7e]+\z/
|
|
89
|
+
SEND_KEY_NEEDED = 'Sending email needs secret_key, a project secret API key with the ' \
|
|
90
|
+
'transactional_email:write scope.'
|
|
91
|
+
READ_KEY_NEEDED = 'Reading an email status needs secret_key, a project secret API key with the ' \
|
|
92
|
+
'transactional_email:read or transactional_email:write scope.'
|
|
93
|
+
TEMPLATE_FORM_WHY = 'A template made in PostHog sets its own sender, subject and content, and follows the ' \
|
|
94
|
+
"project's engagement setting."
|
|
95
|
+
CODE_FORM_WHY = 'A send with template_key takes its content from your code.'
|
|
96
|
+
TEMPLATE_FIELDS = %i[template_data].freeze
|
|
97
|
+
CODE_FIELDS = %i[subject html text from reply_to capture_engagement].freeze
|
|
98
|
+
NETWORK_ERRORS = [
|
|
99
|
+
Timeout::Error, SocketError, SystemCallError, IOError, EOFError, OpenSSL::SSL::SSLError,
|
|
100
|
+
Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError, Zlib::Error
|
|
101
|
+
].freeze
|
|
102
|
+
private_constant :SEND_PATH, :STATUS_PATH, :US_APP_HOST, :EU_APP_HOST, :CLOUD_APP_HOSTS,
|
|
103
|
+
:MAX_RETRY_DELAY_SECONDS, :UUID_PATTERN, :IDEMPOTENCY_KEY_PATTERN, :TEMPLATE_KEY_PATTERN,
|
|
104
|
+
:HEADER_TOKEN_PATTERN, :SEND_KEY_NEEDED, :READ_KEY_NEEDED, :TEMPLATE_FORM_WHY,
|
|
105
|
+
:CODE_FORM_WHY, :TEMPLATE_FIELDS, :CODE_FIELDS, :NETWORK_ERRORS
|
|
106
|
+
|
|
107
|
+
# @api private
|
|
108
|
+
def initialize(secret_key:, host:, app_host:, timeout:, max_retries:, migrated_from:, user_agent:, disabled:,
|
|
109
|
+
skip_ssl_verification: false)
|
|
110
|
+
check_options(timeout, max_retries, migrated_from)
|
|
111
|
+
@secret_key = secret_key.is_a?(String) ? secret_key.strip : nil
|
|
112
|
+
@app_host = resolve_app_host(host, app_host)
|
|
113
|
+
@timeout = timeout
|
|
114
|
+
@max_retries = max_retries
|
|
115
|
+
@migrated_from = migrated_from
|
|
116
|
+
@user_agent = user_agent
|
|
117
|
+
@disabled = disabled
|
|
118
|
+
@skip_ssl_verification = skip_ssl_verification
|
|
119
|
+
@closed = false
|
|
120
|
+
@in_flight = 0
|
|
121
|
+
@pid = Process.pid
|
|
122
|
+
@mutex = Mutex.new
|
|
123
|
+
@idle = ConditionVariable.new
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Queues one email, from a template made in PostHog or from content your code rendered.
|
|
127
|
+
#
|
|
128
|
+
# Pass exactly one form. The template form names a template made in PostHog with `template_id`. The code form
|
|
129
|
+
# names the email with `template_key` and passes `subject` and `html`, `text` or both. To send a React email,
|
|
130
|
+
# render it to HTML in your app first.
|
|
131
|
+
#
|
|
132
|
+
# @param to [String] One recipient email address.
|
|
133
|
+
# @param template_id [String] Template form: the UUID of a template made transactional in PostHog.
|
|
134
|
+
# @param template_data [Hash] Template form: values for the template's `{{ variables.* }}` placeholders.
|
|
135
|
+
# @param template_key [String] Code form: names the email, such as `password-reset`, so its metrics, logs and
|
|
136
|
+
# status group under it in PostHog. Letters, digits, hyphens and underscores, at most 400.
|
|
137
|
+
# @param subject [String] Code form: the subject line.
|
|
138
|
+
# @param html [String] Code form: the HTML body.
|
|
139
|
+
# @param text [String] Code form: the plain-text body. Without it, PostHog derives it from `html`.
|
|
140
|
+
# @param from [String] Code form: a verified sender, such as `Acme <hello@example.com>`. Without it, the
|
|
141
|
+
# project's default sender.
|
|
142
|
+
# @param reply_to [String, Array<String>] Code form: one address or a list of addresses.
|
|
143
|
+
# @param capture_engagement [Boolean] Code form: whether PostHog captures the email's opens and clicks as events
|
|
144
|
+
# of the recipient. Defaults to +true+. A project whose admin turned engagement capture off captures nothing.
|
|
145
|
+
# @param distinct_id [String] The recipient's distinct ID in your product. The email's events go to this
|
|
146
|
+
# person. Without it, they go to a person whose distinct ID is the email address.
|
|
147
|
+
# @param idempotency_key [String] 1 to 255 visible ASCII characters. A send with a key PostHog has already
|
|
148
|
+
# accepted queues nothing and returns the first send's result. Without it, the client makes a UUID that covers
|
|
149
|
+
# its own retries. Pass a key derived from what the email is for, such as `welcome:user_123`, when a job runner
|
|
150
|
+
# retries the send.
|
|
151
|
+
# @param max_retries [Integer] Overrides the client's `email_request_max_retries` for this send. +0+ turns
|
|
152
|
+
# retries off.
|
|
153
|
+
# @return [SendResult] The queued email.
|
|
154
|
+
# @raise [EmailError] PostHog did not queue the email, or the client could not tell whether it did.
|
|
155
|
+
# @raise [ArgumentError] An argument has the wrong type or format, or the call mixes the two forms.
|
|
156
|
+
def send(
|
|
157
|
+
to:, template_id: nil, template_data: nil, template_key: nil, subject: nil, html: nil, text: nil, from: nil,
|
|
158
|
+
reply_to: nil, capture_engagement: nil, distinct_id: nil, idempotency_key: nil, max_retries: nil
|
|
159
|
+
)
|
|
160
|
+
fields = {
|
|
161
|
+
template_data: template_data, subject: subject, html: html, text: text, from: from, reply_to: reply_to,
|
|
162
|
+
capture_engagement: capture_engagement
|
|
163
|
+
}.compact
|
|
164
|
+
check_recipient(to, distinct_id, idempotency_key, max_retries)
|
|
165
|
+
payload = content(template_id, template_key, fields).merge(to: to)
|
|
166
|
+
payload[:distinct_id] = distinct_id unless distinct_id.nil?
|
|
167
|
+
body = JSON.generate(payload)
|
|
168
|
+
headers = { 'Content-Type' => 'application/json', 'Idempotency-Key' => idempotency_key || SecureRandom.uuid }
|
|
169
|
+
|
|
170
|
+
result = call('no email was sent', SEND_KEY_NEEDED) do |secret_key|
|
|
171
|
+
uri = URI(@app_host + SEND_PATH)
|
|
172
|
+
with_retries(max_retries || @max_retries) do
|
|
173
|
+
parse_send_reply(request(Net::HTTP::Post.new(uri), uri, secret_key, headers, body))
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
result.warnings.each { |warning| logger.warn("PostHog email warning #{warning.code}: #{warning.message}") }
|
|
177
|
+
result
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Reads what happened to an email that {#send} queued, for 30 days after the send.
|
|
181
|
+
#
|
|
182
|
+
# @param message_id [String] The {SendResult#id} that {#send} returned.
|
|
183
|
+
# @return [Status] The email's latest status.
|
|
184
|
+
# @raise [EmailError] PostHog did not answer with a status. {EmailError#code} is `not_found` for an unknown ID,
|
|
185
|
+
# an email older than 30 days, or for a few seconds after the send.
|
|
186
|
+
# @raise [ArgumentError] +message_id+ is not a message ID.
|
|
187
|
+
def get(message_id)
|
|
188
|
+
raise ArgumentError, 'message_id must be the message ID that send returned.' unless uuid?(message_id)
|
|
189
|
+
|
|
190
|
+
call('no email status was read', READ_KEY_NEEDED) do |secret_key|
|
|
191
|
+
uri = URI(@app_host + format(STATUS_PATH, message_id: message_id.downcase))
|
|
192
|
+
with_retries(@max_retries) { parse_status_reply(request(Net::HTTP::Get.new(uri), uri, secret_key, {})) }
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Refuses new sends and waits for the sends and status reads in flight, retries included, to settle.
|
|
197
|
+
#
|
|
198
|
+
# @api private
|
|
199
|
+
# @param timeout [Numeric, nil] Maximum seconds to wait, or +nil+ to wait until they settle.
|
|
200
|
+
# @return [Boolean] +true+ if nothing is in flight any more.
|
|
201
|
+
def close(timeout: nil)
|
|
202
|
+
deadline = timeout && (monotonic_time + timeout)
|
|
203
|
+
@mutex.synchronize do
|
|
204
|
+
forget_parent_requests
|
|
205
|
+
@closed = true
|
|
206
|
+
until @in_flight.zero?
|
|
207
|
+
remaining = deadline && (deadline - monotonic_time)
|
|
208
|
+
break if remaining && remaining <= 0
|
|
209
|
+
|
|
210
|
+
@idle.wait(@mutex, remaining)
|
|
211
|
+
end
|
|
212
|
+
@in_flight.zero?
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
private
|
|
217
|
+
|
|
218
|
+
def check_options(timeout, max_retries, migrated_from)
|
|
219
|
+
raise ArgumentError, 'email_request_timeout_seconds must be a positive number.' unless positive?(timeout)
|
|
220
|
+
raise ArgumentError, 'email_request_max_retries must be a nonnegative integer.' unless retry_count?(max_retries)
|
|
221
|
+
return if migrated_from.nil? || (migrated_from.is_a?(String) && HEADER_TOKEN_PATTERN.match?(migrated_from))
|
|
222
|
+
|
|
223
|
+
raise ArgumentError, "migrated_from must be visible ASCII characters, such as 'resend'."
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def positive?(value)
|
|
227
|
+
value.is_a?(Numeric) && value.real? && value.positive? && value.finite?
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def retry_count?(value)
|
|
231
|
+
value.is_a?(Integer) && value >= 0
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def uuid?(value)
|
|
235
|
+
value.is_a?(String) && UUID_PATTERN.match?(value)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def resolve_app_host(host, app_host)
|
|
239
|
+
explicit = app_host.is_a?(String) ? app_host.strip.chomp('/') : ''
|
|
240
|
+
return CLOUD_APP_HOSTS[host.to_s.strip.chomp('/')] if explicit.empty?
|
|
241
|
+
# A host without a scheme would send the secret key to the wrong place, such as localhost.
|
|
242
|
+
unless web_url?(explicit)
|
|
243
|
+
raise ArgumentError, 'app_host must be an http or https URL, such as https://us.posthog.com.'
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
CLOUD_APP_HOSTS.fetch(explicit, explicit)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def web_url?(value)
|
|
250
|
+
uri = URI(value)
|
|
251
|
+
%w[http https].include?(uri.scheme) && !uri.host.to_s.empty?
|
|
252
|
+
rescue URI::InvalidURIError
|
|
253
|
+
false
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def check_recipient(to, distinct_id, idempotency_key, max_retries)
|
|
257
|
+
raise ArgumentError, 'to must be one email address as a nonempty string.' unless nonempty_string?(to)
|
|
258
|
+
unless distinct_id.nil? || nonempty_string?(distinct_id)
|
|
259
|
+
raise ArgumentError, 'distinct_id must be a nonempty string.'
|
|
260
|
+
end
|
|
261
|
+
unless idempotency_key.nil? || (idempotency_key.is_a?(String) && IDEMPOTENCY_KEY_PATTERN.match?(idempotency_key))
|
|
262
|
+
raise ArgumentError, 'idempotency_key must be 1 to 255 visible ASCII characters without spaces.'
|
|
263
|
+
end
|
|
264
|
+
return if max_retries.nil? || retry_count?(max_retries)
|
|
265
|
+
|
|
266
|
+
raise ArgumentError, 'max_retries must be a nonnegative integer.'
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def nonempty_string?(value)
|
|
270
|
+
value.is_a?(String) && !value.strip.empty?
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def content(template_id, template_key, fields)
|
|
274
|
+
if template_id.nil? == template_key.nil?
|
|
275
|
+
raise ArgumentError, 'Send either template_id, or template_key with subject and html or text.'
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
template_key.nil? ? template_content(template_id, fields) : code_content(template_key, fields)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def template_content(template_id, fields)
|
|
282
|
+
refuse_fields(fields, allowed: TEMPLATE_FIELDS, form: :template_id, other_form: :template_key,
|
|
283
|
+
why: TEMPLATE_FORM_WHY)
|
|
284
|
+
raise ArgumentError, "template_id must be the template's UUID." unless uuid?(template_id)
|
|
285
|
+
|
|
286
|
+
template_data = fields.fetch(:template_data, {})
|
|
287
|
+
raise ArgumentError, 'template_data must be a Hash.' unless template_data.is_a?(Hash)
|
|
288
|
+
|
|
289
|
+
{ template_id: template_id.downcase, message_data: isoify_dates(template_data) }
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def code_content(template_key, fields)
|
|
293
|
+
refuse_fields(fields, allowed: CODE_FIELDS, form: :template_key, other_form: :template_id,
|
|
294
|
+
why: CODE_FORM_WHY)
|
|
295
|
+
unless template_key.is_a?(String) && TEMPLATE_KEY_PATTERN.match?(template_key)
|
|
296
|
+
raise ArgumentError,
|
|
297
|
+
'template_key can only have letters, digits, hyphens (-) and underscores (_), at most 400.'
|
|
298
|
+
end
|
|
299
|
+
raise ArgumentError, 'A send with template_key needs a subject.' unless fields[:subject].is_a?(String)
|
|
300
|
+
unless fields.key?(:html) || fields.key?(:text)
|
|
301
|
+
raise ArgumentError,
|
|
302
|
+
'A send with template_key needs html, text or both.'
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
%i[html text from].each do |name|
|
|
306
|
+
raise ArgumentError, "#{name} must be a string." if fields.key?(name) && !fields[name].is_a?(String)
|
|
307
|
+
end
|
|
308
|
+
unless [nil, true, false].include?(fields[:capture_engagement])
|
|
309
|
+
raise ArgumentError, 'capture_engagement must be true or false.'
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
code = fields.slice(:subject, :html, :text, :from).merge(template_key: template_key)
|
|
313
|
+
code[:reply_to] = addresses(fields[:reply_to]) if fields.key?(:reply_to)
|
|
314
|
+
code[:capture_engagement] = fields.fetch(:capture_engagement, true)
|
|
315
|
+
code
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def refuse_fields(fields, allowed:, form:, other_form:, why:)
|
|
319
|
+
field = fields.keys.find { |name| !allowed.include?(name) }
|
|
320
|
+
return if field.nil?
|
|
321
|
+
|
|
322
|
+
message = "A send with #{form} doesn't take #{field}, so no email was sent."
|
|
323
|
+
raise ArgumentError, "#{message} #{why} Remove #{field}, or send with #{other_form}."
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def addresses(reply_to)
|
|
327
|
+
list = reply_to.is_a?(String) ? [reply_to] : reply_to
|
|
328
|
+
return list if list.is_a?(Array) && list.all? { |address| nonempty_string?(address) }
|
|
329
|
+
|
|
330
|
+
raise ArgumentError, 'reply_to must be one address or a list of addresses.'
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def call(nothing_done, key_needed)
|
|
334
|
+
admit(nothing_done)
|
|
335
|
+
begin
|
|
336
|
+
unless @app_host
|
|
337
|
+
# A custom host is often an ingestion proxy, which must not receive the secret key.
|
|
338
|
+
raise EmailError.new(
|
|
339
|
+
code: 'missing_app_host',
|
|
340
|
+
message: "The client's host is not PostHog Cloud, so the client can't tell where to send email. " \
|
|
341
|
+
'Set app_host to your PostHog app host on the client.'
|
|
342
|
+
)
|
|
343
|
+
end
|
|
344
|
+
raise EmailError.new(code: 'missing_secret_key', message: key_needed) if @secret_key.nil? || @secret_key.empty?
|
|
345
|
+
|
|
346
|
+
yield @secret_key
|
|
347
|
+
ensure
|
|
348
|
+
finish
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def admit(nothing_done)
|
|
353
|
+
if @disabled
|
|
354
|
+
raise EmailError.new(code: 'client_disabled',
|
|
355
|
+
message: "The PostHog client is disabled, so #{nothing_done}.")
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
@mutex.synchronize do
|
|
359
|
+
forget_parent_requests
|
|
360
|
+
if @closed
|
|
361
|
+
raise EmailError.new(code: 'client_closed',
|
|
362
|
+
message: "The PostHog client is shut down, so #{nothing_done}.")
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
@in_flight += 1
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# A forked child inherits the parent's count, but not the threads that would settle those requests.
|
|
370
|
+
def forget_parent_requests
|
|
371
|
+
return if @pid == Process.pid
|
|
372
|
+
|
|
373
|
+
@pid = Process.pid
|
|
374
|
+
@in_flight = 0
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def finish
|
|
378
|
+
@mutex.synchronize do
|
|
379
|
+
@in_flight -= 1
|
|
380
|
+
@idle.broadcast if @in_flight.zero?
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def with_retries(max_retries)
|
|
385
|
+
attempt = 0
|
|
386
|
+
begin
|
|
387
|
+
yield
|
|
388
|
+
rescue EmailError => e
|
|
389
|
+
delay = retry_delay(e, attempt, max_retries)
|
|
390
|
+
raise if delay.nil?
|
|
391
|
+
|
|
392
|
+
sleep(delay)
|
|
393
|
+
attempt += 1
|
|
394
|
+
retry
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def retry_delay(error, attempt, max_retries)
|
|
399
|
+
return nil if attempt >= max_retries || !retryable?(error)
|
|
400
|
+
# Retrying before a longer Retry-After would break the server's rate limit.
|
|
401
|
+
return nil if error.retry_after && error.retry_after > MAX_RETRY_DELAY_SECONDS
|
|
402
|
+
|
|
403
|
+
backoff = [MAX_RETRY_DELAY_SECONDS, (2**attempt) * rand(0.8..1.2)].min
|
|
404
|
+
[backoff, error.retry_after || 0].max
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def retryable?(error)
|
|
408
|
+
# A send carries an idempotency key, so a retry can't queue a second email. A status read changes nothing.
|
|
409
|
+
case error.status_code
|
|
410
|
+
when nil then error.code == 'network_error'
|
|
411
|
+
when 409 then error.code == 'idempotency_key_in_flight'
|
|
412
|
+
when 429 then error.code != 'quota_limited'
|
|
413
|
+
else error.status_code == 503
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def request(request, uri, secret_key, headers, body = nil)
|
|
418
|
+
request['Authorization'] = "Bearer #{secret_key}"
|
|
419
|
+
request['Accept'] = 'application/json'
|
|
420
|
+
request['User-Agent'] = @user_agent if @user_agent
|
|
421
|
+
request['X-PostHog-Migrated-From'] = @migrated_from if @migrated_from
|
|
422
|
+
headers.each { |name, value| request[name] = value }
|
|
423
|
+
request.body = body if body
|
|
424
|
+
|
|
425
|
+
head = nil
|
|
426
|
+
response = Net::HTTP.start(uri.hostname, uri.port, connection_options(uri)) do |http|
|
|
427
|
+
http.request(request) { |started| head = started }
|
|
428
|
+
end
|
|
429
|
+
[response.code.to_i, parse_json(response.body), parse_retry_after(response['Retry-After'])]
|
|
430
|
+
rescue *NETWORK_ERRORS
|
|
431
|
+
# A reply whose body broke off retries with the same idempotency key, after its Retry-After when it sent one.
|
|
432
|
+
raise EmailError.new(code: 'network_error', message: 'The request to PostHog failed.',
|
|
433
|
+
retry_after: head && parse_retry_after(head['Retry-After']))
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def connection_options(uri)
|
|
437
|
+
options = {
|
|
438
|
+
use_ssl: uri.scheme == 'https', open_timeout: @timeout, read_timeout: @timeout, write_timeout: @timeout,
|
|
439
|
+
# Net::HTTP returns a body cut off by a closed connection as if it were whole. Raise instead.
|
|
440
|
+
max_retries: 0, ignore_eof: false
|
|
441
|
+
}
|
|
442
|
+
options[:verify_mode] = OpenSSL::SSL::VERIFY_NONE if @skip_ssl_verification
|
|
443
|
+
options
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def parse_json(body)
|
|
447
|
+
JSON.parse(body.to_s)
|
|
448
|
+
rescue JSON::ParserError
|
|
449
|
+
nil
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def parse_send_reply((status, payload, retry_after))
|
|
453
|
+
fields = payload.is_a?(Hash) ? payload : {}
|
|
454
|
+
raise reply_error(fields, status, retry_after) unless status.between?(200, 299)
|
|
455
|
+
|
|
456
|
+
id = text(fields['message_id'])
|
|
457
|
+
unless id
|
|
458
|
+
raise EmailError.new(code: 'api_error', message: 'PostHog answered without a message ID.',
|
|
459
|
+
status_code: status)
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
SendResult.new(id: id, replayed: fields['replayed'] == true, warnings: warnings(fields['warnings']))
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def warnings(entries)
|
|
466
|
+
return [] unless entries.is_a?(Array)
|
|
467
|
+
|
|
468
|
+
entries.grep(Hash).map do |entry|
|
|
469
|
+
Warning.new(
|
|
470
|
+
code: text(entry['code']) || 'warning',
|
|
471
|
+
message: text(entry['detail']) || text(entry['message']) || '',
|
|
472
|
+
fix: text(entry['fix'])
|
|
473
|
+
)
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def parse_status_reply((status, payload, retry_after))
|
|
478
|
+
fields = payload.is_a?(Hash) ? payload : {}
|
|
479
|
+
raise reply_error(fields, status, retry_after) unless status.between?(200, 299)
|
|
480
|
+
|
|
481
|
+
record = {
|
|
482
|
+
id: text(fields['id']),
|
|
483
|
+
status: text(fields['status']),
|
|
484
|
+
created_at: timestamp(fields['created_at']),
|
|
485
|
+
status_at: timestamp(fields['status_at'])
|
|
486
|
+
}
|
|
487
|
+
if record.value?(nil)
|
|
488
|
+
raise EmailError.new(code: 'api_error', message: 'PostHog answered without an email status.',
|
|
489
|
+
status_code: status)
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
Status.new(**record, reason: text(fields['reason']))
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def timestamp(value)
|
|
496
|
+
text(value) && Time.iso8601(value)
|
|
497
|
+
rescue ArgumentError
|
|
498
|
+
nil
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def reply_error(fields, status, retry_after)
|
|
502
|
+
EmailError.new(
|
|
503
|
+
code: text(fields['code']) || 'api_error',
|
|
504
|
+
message: text(fields['detail']) || "PostHog answered HTTP #{status}.",
|
|
505
|
+
status_code: status,
|
|
506
|
+
why: text(fields['why']),
|
|
507
|
+
fix: text(fields['fix']),
|
|
508
|
+
docs_url: text(fields['docs_url']),
|
|
509
|
+
field: text(fields['attr']),
|
|
510
|
+
retry_after: retry_after
|
|
511
|
+
)
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def text(value)
|
|
515
|
+
value if value.is_a?(String) && !value.empty?
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
end
|
|
@@ -150,6 +150,9 @@ module PostHog
|
|
|
150
150
|
only_evaluate_locally = false,
|
|
151
151
|
raise_on_error = false
|
|
152
152
|
)
|
|
153
|
+
groups, person_properties, group_properties = normalize_flag_evaluation_inputs(
|
|
154
|
+
groups, person_properties, group_properties
|
|
155
|
+
)
|
|
153
156
|
# TODO: Convert to options hash for easier argument passing
|
|
154
157
|
flags_data = get_all_flags_and_payloads(
|
|
155
158
|
distinct_id,
|
|
@@ -246,14 +249,6 @@ module PostHog
|
|
|
246
249
|
load_feature_flags
|
|
247
250
|
snapshot ||= _evaluation_snapshot
|
|
248
251
|
|
|
249
|
-
symbolize_keys! groups
|
|
250
|
-
symbolize_keys! person_properties
|
|
251
|
-
symbolize_keys! group_properties
|
|
252
|
-
|
|
253
|
-
group_properties.each_value do |value|
|
|
254
|
-
symbolize_keys!(value)
|
|
255
|
-
end
|
|
256
|
-
|
|
257
252
|
response = nil
|
|
258
253
|
payload = nil
|
|
259
254
|
feature_flag = snapshot[:flags_by_key]&.[](key)
|
|
@@ -734,9 +729,6 @@ module PostHog
|
|
|
734
729
|
def self.match_property(property, property_values, cohort_properties = {}, property_matching_version: 1)
|
|
735
730
|
# only looks for matches where key exists in property_values
|
|
736
731
|
|
|
737
|
-
PostHog::Utils.symbolize_keys! property
|
|
738
|
-
PostHog::Utils.symbolize_keys! property_values
|
|
739
|
-
|
|
740
732
|
# Handle cohort properties
|
|
741
733
|
if extract_value(property, :type) == 'cohort'
|
|
742
734
|
return match_cohort(property, property_values, cohort_properties,
|
|
@@ -753,25 +745,23 @@ module PostHog
|
|
|
753
745
|
end
|
|
754
746
|
|
|
755
747
|
def self.match_property_value(property, property_values, property_matching_version)
|
|
756
|
-
key = property[:key].to_sym
|
|
757
|
-
value = property[:value]
|
|
758
|
-
operator = property[:operator] || 'exact'
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
return false
|
|
748
|
+
key = (property[:key] || property['key']).to_sym
|
|
749
|
+
value = property.key?(:value) ? property[:value] : property['value']
|
|
750
|
+
operator = property[:operator] || property['operator'] || 'exact'
|
|
751
|
+
override_value = property_values.fetch(key) do
|
|
752
|
+
property_values.fetch(key.to_s) do
|
|
753
|
+
raise InconclusiveMatchError.new("Property #{key} not found in property_values",
|
|
754
|
+
reason: UnresolvedFlagReason::MISSING_CONTEXT)
|
|
755
|
+
end
|
|
765
756
|
end
|
|
766
|
-
|
|
767
|
-
override_value = property_values[key]
|
|
757
|
+
return false if operator == 'is_not_set'
|
|
768
758
|
|
|
769
759
|
case operator
|
|
770
760
|
when 'exact', 'is_not'
|
|
771
761
|
matches = exact_property_match?(value, override_value, property_matching_version)
|
|
772
762
|
operator == 'exact' ? matches : !matches
|
|
773
763
|
when 'is_set'
|
|
774
|
-
|
|
764
|
+
true
|
|
775
765
|
when 'icontains'
|
|
776
766
|
string_operand(override_value).downcase(:ascii).include?(value.to_s.downcase(:ascii))
|
|
777
767
|
when 'not_icontains'
|
|
@@ -847,8 +837,9 @@ module PostHog
|
|
|
847
837
|
# Raises an unsupported_definition error when local evaluation cannot use the filter, whatever
|
|
848
838
|
# the call passes: an unknown operator, or a filter value that the operator cannot parse.
|
|
849
839
|
def self.assert_filter_supported(property)
|
|
850
|
-
value = property[:value]
|
|
851
|
-
|
|
840
|
+
value = property.key?(:value) ? property[:value] : property['value']
|
|
841
|
+
operator = property[:operator] || property['operator'] || 'exact'
|
|
842
|
+
case operator
|
|
852
843
|
when 'exact', 'is_not', 'is_set', 'is_not_set', 'icontains', 'not_icontains', 'starts_with',
|
|
853
844
|
'not_starts_with', 'ends_with', 'not_ends_with'
|
|
854
845
|
nil
|
|
@@ -871,7 +862,7 @@ module PostHog
|
|
|
871
862
|
when 'semver_wildcard'
|
|
872
863
|
semver_wildcard_bounds(value)
|
|
873
864
|
else
|
|
874
|
-
raise InconclusiveMatchError.new("Unknown operator: #{
|
|
865
|
+
raise InconclusiveMatchError.new("Unknown operator: #{operator}",
|
|
875
866
|
reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
|
|
876
867
|
end
|
|
877
868
|
end
|
|
@@ -1086,10 +1077,9 @@ module PostHog
|
|
|
1086
1077
|
end
|
|
1087
1078
|
else
|
|
1088
1079
|
property_group_items_match?(properties, group_type) do |prop|
|
|
1089
|
-
PostHog::Utils.symbolize_keys!(prop)
|
|
1090
1080
|
matches = match_property(prop, property_values, cohort_properties,
|
|
1091
1081
|
property_matching_version: property_matching_version)
|
|
1092
|
-
prop
|
|
1082
|
+
extract_value(prop, :negation) ? !matches : matches
|
|
1093
1083
|
end
|
|
1094
1084
|
end
|
|
1095
1085
|
end
|
data/lib/posthog/transport.rb
CHANGED
|
@@ -151,19 +151,6 @@ module PostHog
|
|
|
151
151
|
@backoff_policy.next_interval.to_f / 1000
|
|
152
152
|
end
|
|
153
153
|
|
|
154
|
-
def parse_retry_after(value)
|
|
155
|
-
return nil if value.nil? || value.empty?
|
|
156
|
-
|
|
157
|
-
seconds = Float(value, exception: false)
|
|
158
|
-
return seconds if seconds && seconds >= 0
|
|
159
|
-
|
|
160
|
-
parsed_time = Time.httpdate(value)
|
|
161
|
-
delay = parsed_time - Time.now
|
|
162
|
-
delay.positive? ? delay : nil
|
|
163
|
-
rescue ArgumentError
|
|
164
|
-
nil
|
|
165
|
-
end
|
|
166
|
-
|
|
167
154
|
# Sends a request for the batch, returns [status_code, body]
|
|
168
155
|
def send_request(api_key, batch)
|
|
169
156
|
@last_retry_after = nil
|
data/lib/posthog/utils.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
3
5
|
require 'posthog/flag_evaluation_errors'
|
|
4
6
|
|
|
5
7
|
module PostHog
|
|
@@ -21,6 +23,14 @@ module PostHog
|
|
|
21
23
|
hash.replace symbolize_keys hash
|
|
22
24
|
end
|
|
23
25
|
|
|
26
|
+
def normalize_flag_evaluation_inputs(groups, person_properties, group_properties)
|
|
27
|
+
[
|
|
28
|
+
symbolize_keys(groups || {}),
|
|
29
|
+
symbolize_keys(person_properties || {}),
|
|
30
|
+
symbolize_keys(group_properties || {}).transform_values { |properties| symbolize_keys(properties) }
|
|
31
|
+
]
|
|
32
|
+
end
|
|
33
|
+
|
|
24
34
|
# public: Return a new hash with keys as strings
|
|
25
35
|
#
|
|
26
36
|
def stringify_keys(hash)
|
|
@@ -135,6 +145,22 @@ module PostHog
|
|
|
135
145
|
end
|
|
136
146
|
end
|
|
137
147
|
|
|
148
|
+
# public: Seconds to wait from a Retry-After header, given as seconds or as an HTTP date, or nil when the
|
|
149
|
+
# header is absent, malformed or in the past
|
|
150
|
+
#
|
|
151
|
+
def parse_retry_after(value)
|
|
152
|
+
return nil if value.nil? || value.empty?
|
|
153
|
+
|
|
154
|
+
seconds = Float(value, exception: false)
|
|
155
|
+
return seconds if seconds&.finite? && seconds >= 0
|
|
156
|
+
|
|
157
|
+
parsed_time = Time.httpdate(value)
|
|
158
|
+
delay = parsed_time - Time.now
|
|
159
|
+
delay.positive? ? delay : nil
|
|
160
|
+
rescue ArgumentError
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
|
|
138
164
|
# public: Current monotonic clock time in seconds, for measuring durations
|
|
139
165
|
#
|
|
140
166
|
def monotonic_time
|
data/lib/posthog/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: posthog-ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.
|
|
4
|
+
version: 3.30.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- ''
|
|
@@ -38,6 +38,8 @@ files:
|
|
|
38
38
|
- lib/posthog/backoff_policy.rb
|
|
39
39
|
- lib/posthog/client.rb
|
|
40
40
|
- lib/posthog/defaults.rb
|
|
41
|
+
- lib/posthog/email_error.rb
|
|
42
|
+
- lib/posthog/emails.rb
|
|
41
43
|
- lib/posthog/exception_capture.rb
|
|
42
44
|
- lib/posthog/feature_flag.rb
|
|
43
45
|
- lib/posthog/feature_flag_error.rb
|