zeroclick-sellers 0.5.0 → 0.6.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/zeroclick/sellers/agentify.rb +11 -4
- data/lib/zeroclick/sellers/client.rb +44 -4
- data/lib/zeroclick/sellers/contracts.rb +3 -0
- data/lib/zeroclick/sellers/errors.rb +2 -0
- data/lib/zeroclick/sellers/middleware/agentify.rb +6 -0
- data/lib/zeroclick/sellers/middleware/page_views.rb +177 -0
- data/lib/zeroclick/sellers/page_views.rb +67 -0
- data/lib/zeroclick/sellers/version.rb +1 -1
- data/lib/zeroclick/sellers.rb +2 -0
- 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: 6127c1b9413ac6cbf8d9690e972e319418e2bee6ba3a025ba057c6752d04a370
|
|
4
|
+
data.tar.gz: 384bfdd1ba3393497943aa9c37138f0b9fae69098a2404b3dfd6b2e51f61493b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 446af6c8cb1563130c222f09ec8ee8eeb4308636782925acd269d7353a1ed3cbc52f7aa8ac033fb0a1eb6bb4fe268b2d5178a5b483e7e7cf9348c18ea291a99d
|
|
7
|
+
data.tar.gz: 40f965870f70c61902865c586d930c39f4c1165a943244dfc6647d05abdbc93bb2faf65f184188cc80e03e45a860b5c410c5db8d2c97f48545206a5a663657cc
|
|
@@ -54,13 +54,19 @@ module ZeroClick
|
|
|
54
54
|
# The converted document plus the response headers worth forwarding to
|
|
55
55
|
# the agent — the customer's CDN caches the markdown too.
|
|
56
56
|
class MarkdownResult
|
|
57
|
-
attr_reader :markdown, :cache_control, :content_type, :etag
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
attr_reader :markdown, :cache_control, :content_type, :etag, :zcj_id
|
|
58
|
+
|
|
59
|
+
# +zcj_id+ is the journey id the conversion minted (x-zcj-id), or nil
|
|
60
|
+
# when the chain is off for this seller. Forwarded onto the agent's
|
|
61
|
+
# response so a wrapping page-view beacon can stitch this marketing
|
|
62
|
+
# view to the same journey as the storefront visit and purchase the
|
|
63
|
+
# agent reaches through the inlined links.
|
|
64
|
+
def initialize(markdown:, cache_control: nil, content_type: nil, etag: nil, zcj_id: nil)
|
|
60
65
|
@markdown = markdown
|
|
61
66
|
@cache_control = cache_control
|
|
62
67
|
@content_type = content_type
|
|
63
68
|
@etag = etag
|
|
69
|
+
@zcj_id = zcj_id
|
|
64
70
|
freeze
|
|
65
71
|
end
|
|
66
72
|
end
|
|
@@ -164,7 +170,8 @@ module ZeroClick
|
|
|
164
170
|
markdown: body,
|
|
165
171
|
cache_control: headers["cache-control"],
|
|
166
172
|
content_type: headers["content-type"],
|
|
167
|
-
etag: headers["etag"]
|
|
173
|
+
etag: headers["etag"],
|
|
174
|
+
zcj_id: headers["x-zcj-id"]
|
|
168
175
|
)
|
|
169
176
|
end
|
|
170
177
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "agentify"
|
|
4
4
|
require_relative "contracts"
|
|
5
5
|
require_relative "errors"
|
|
6
|
+
require_relative "page_views"
|
|
6
7
|
require_relative "responses"
|
|
7
8
|
require_relative "usage"
|
|
8
9
|
require_relative "verify"
|
|
@@ -27,6 +28,7 @@ module ZeroClick
|
|
|
27
28
|
usage_read_key: nil,
|
|
28
29
|
usage_write_key: nil,
|
|
29
30
|
agentify_key: nil,
|
|
31
|
+
page_views_key: nil,
|
|
30
32
|
signing_secrets: nil,
|
|
31
33
|
resolve_signing_secret: nil,
|
|
32
34
|
api_base_url: DEFAULT_API_BASE_URL,
|
|
@@ -34,6 +36,7 @@ module ZeroClick
|
|
|
34
36
|
allowance_unavailable_policy: "allow",
|
|
35
37
|
check_timeout_seconds: DEFAULT_CHECK_TIMEOUT_SECONDS,
|
|
36
38
|
agentify_timeout_seconds: DEFAULT_AGENTIFY_TIMEOUT_SECONDS,
|
|
39
|
+
page_view_timeout_seconds: DEFAULT_PAGE_VIEW_TIMEOUT_SECONDS,
|
|
37
40
|
on_allowance_unavailable: nil,
|
|
38
41
|
clock: -> { Time.now.to_i },
|
|
39
42
|
# The transports, injectable so every decision this class
|
|
@@ -44,11 +47,12 @@ module ZeroClick
|
|
|
44
47
|
http_get: Agentify.method(:get))
|
|
45
48
|
usage_read_key ||= api_key
|
|
46
49
|
usage_write_key ||= api_key
|
|
47
|
-
# Unlike the usage keys, agentify
|
|
48
|
-
# requirement:
|
|
49
|
-
# as a typed error at call time instead of failing
|
|
50
|
-
# never
|
|
50
|
+
# Unlike the usage keys, agentify and page views have no
|
|
51
|
+
# construction-time requirement: each capability is optional, so a
|
|
52
|
+
# missing key surfaces as a typed error at call time instead of failing
|
|
53
|
+
# every create that never uses it.
|
|
51
54
|
agentify_key ||= api_key
|
|
55
|
+
page_views_key ||= api_key
|
|
52
56
|
|
|
53
57
|
if usage_read_key.nil? || usage_read_key.empty?
|
|
54
58
|
raise Error.new("malformed_input", operation: "create",
|
|
@@ -74,6 +78,7 @@ module ZeroClick
|
|
|
74
78
|
@usage_read_key = usage_read_key
|
|
75
79
|
@usage_write_key = usage_write_key
|
|
76
80
|
@agentify_key = agentify_key
|
|
81
|
+
@page_views_key = page_views_key
|
|
77
82
|
@signing_secrets = signing_secrets&.to_h&.freeze
|
|
78
83
|
@resolve_signing_secret = resolve_signing_secret
|
|
79
84
|
@api_base_url = api_base_url
|
|
@@ -81,6 +86,7 @@ module ZeroClick
|
|
|
81
86
|
@policy = allowance_unavailable_policy.to_s
|
|
82
87
|
@timeout = check_timeout_seconds
|
|
83
88
|
@agentify_timeout = agentify_timeout_seconds
|
|
89
|
+
@page_view_timeout = page_view_timeout_seconds
|
|
84
90
|
@on_allowance_unavailable = on_allowance_unavailable
|
|
85
91
|
@clock = clock
|
|
86
92
|
@http_post = http_post
|
|
@@ -173,6 +179,40 @@ module ZeroClick
|
|
|
173
179
|
Agentify.interpret_markdown(status, body, headers)
|
|
174
180
|
end
|
|
175
181
|
|
|
182
|
+
# Report one marketing-site page view to POST /v1/page-views.
|
|
183
|
+
#
|
|
184
|
+
# Requires an API key with the page-views:write scope (+page_views_key+,
|
|
185
|
+
# falling back to the both-scopes +api_key+). Fire-and-forget by design:
|
|
186
|
+
# the API answers 204 without waiting for warehouse delivery. Raises a
|
|
187
|
+
# typed Error on transport failure or a non-2xx status so a direct caller
|
|
188
|
+
# can observe problems; the Middleware::PageViews wrapper swallows those
|
|
189
|
+
# so a beacon failure never affects the page being observed.
|
|
190
|
+
#
|
|
191
|
+
# +seller+ is the seller's public id; +path+ must be an absolute path.
|
|
192
|
+
def report_page_view(seller:, path:, status: nil, duration_ms: nil, representation: nil,
|
|
193
|
+
journey_id: nil, captured_at: nil, user_agent: nil, accept: nil,
|
|
194
|
+
client_ip: nil, country: nil, referrer_host: nil)
|
|
195
|
+
if @page_views_key.nil? || @page_views_key.empty?
|
|
196
|
+
raise Error.new("page_views_not_configured", operation: "report_page_view")
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
payload = PageViews.build_payload(
|
|
200
|
+
seller: seller, path: path, status: status, duration_ms: duration_ms,
|
|
201
|
+
representation: representation, journey_id: journey_id, captured_at: captured_at,
|
|
202
|
+
user_agent: user_agent, accept: accept, client_ip: client_ip,
|
|
203
|
+
country: country, referrer_host: referrer_host
|
|
204
|
+
)
|
|
205
|
+
status_code, body = @http_post.call(
|
|
206
|
+
base_url: @api_base_url,
|
|
207
|
+
path: PageViews::REPORT_PATH,
|
|
208
|
+
api_key: @page_views_key,
|
|
209
|
+
payload: payload,
|
|
210
|
+
timeout: @page_view_timeout,
|
|
211
|
+
operation: "report_page_view"
|
|
212
|
+
)
|
|
213
|
+
PageViews.interpret_report(status_code, body)
|
|
214
|
+
end
|
|
215
|
+
|
|
176
216
|
def check_allowance(zc_request_id:, service_slug:, usage:)
|
|
177
217
|
status, body = @http_post.call(
|
|
178
218
|
base_url: @api_base_url,
|
|
@@ -13,6 +13,9 @@ module ZeroClick
|
|
|
13
13
|
# optional browser render, LLM cleanup), so agentify calls get a larger
|
|
14
14
|
# timeout default than the allowance check.
|
|
15
15
|
DEFAULT_AGENTIFY_TIMEOUT_SECONDS = 10.0
|
|
16
|
+
# The page-view beacon is a cheap fire-and-forget 204; its default timeout
|
|
17
|
+
# is short so a slow ingress never delays the page it is observing.
|
|
18
|
+
DEFAULT_PAGE_VIEW_TIMEOUT_SECONDS = 2.0
|
|
16
19
|
|
|
17
20
|
# Reasons the allowance API gives for refusing. Any other reason on the wire
|
|
18
21
|
# is treated as a malformed response rather than silently passed through.
|
|
@@ -14,6 +14,8 @@ module ZeroClick
|
|
|
14
14
|
"api_status_error" => "The ZeroClick API returned an unsuccessful status",
|
|
15
15
|
"api_transport_error" => "The ZeroClick API request failed",
|
|
16
16
|
"malformed_input" => "The SDK input is malformed",
|
|
17
|
+
"page_views_not_configured" => "No API key covers page-view calls (set page_views_key, or an api_key " \
|
|
18
|
+
"with the page-views:write scope)",
|
|
17
19
|
"signing_secret_resolution_failed" => "The signing secret could not be resolved"
|
|
18
20
|
}.freeze
|
|
19
21
|
|
|
@@ -72,6 +72,12 @@ module ZeroClick
|
|
|
72
72
|
}
|
|
73
73
|
headers["cache-control"] = result.cache_control unless result.cache_control.nil?
|
|
74
74
|
headers["etag"] = result.etag unless result.etag.nil?
|
|
75
|
+
unless result.zcj_id.nil?
|
|
76
|
+
# Re-expose the journey id on the agent's response so a wrapping
|
|
77
|
+
# page-view beacon can stamp this view onto the same journey.
|
|
78
|
+
headers["x-zcj-id"] = result.zcj_id
|
|
79
|
+
headers["access-control-expose-headers"] = "x-zcj-id"
|
|
80
|
+
end
|
|
75
81
|
[200, headers, [result.markdown]]
|
|
76
82
|
end
|
|
77
83
|
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
require_relative "../middleware"
|
|
7
|
+
require_relative "agentify"
|
|
8
|
+
|
|
9
|
+
module ZeroClick
|
|
10
|
+
module Sellers
|
|
11
|
+
module Middleware
|
|
12
|
+
# Rack middleware that emits a fire-and-forget page-view beacon for every
|
|
13
|
+
# page-content request, without changing the response. Mount it in front
|
|
14
|
+
# of the marketing site:
|
|
15
|
+
#
|
|
16
|
+
# use ZeroClick::Sellers::Middleware::PageViews, seller: SELLER, seller_id: "sel_public"
|
|
17
|
+
#
|
|
18
|
+
# The contract, shared by every ZeroClick seller SDK:
|
|
19
|
+
#
|
|
20
|
+
# - only GET requests to non-asset paths are reported (an asset is a
|
|
21
|
+
# final path segment ending in an extension — .css, .ico, .png);
|
|
22
|
+
# - the app runs FIRST, so the beacon carries the real status and
|
|
23
|
+
# duration; the visitor's user-agent, accept, and connecting IP are
|
|
24
|
+
# forwarded for server-side classification;
|
|
25
|
+
# - ANY beacon failure — missing key, network error, non-204, timeout,
|
|
26
|
+
# a bad resolve_url or clock — is handed to +on_error+ and swallowed.
|
|
27
|
+
# The middleware must never break or alter the customer's site.
|
|
28
|
+
#
|
|
29
|
+
# Compose it as the OUTER middleware around Agentify — mount PageViews
|
|
30
|
+
# before Agentify — so the agent-markdown variant's re-exposed x-zcj-id is
|
|
31
|
+
# visible on the response and stitches the view onto the same journey.
|
|
32
|
+
#
|
|
33
|
+
# Like Agentify it never reads the request body, so no buffering ceiling
|
|
34
|
+
# applies.
|
|
35
|
+
class PageViews
|
|
36
|
+
# A page-content request has no asset extension on its final path
|
|
37
|
+
# segment. This skips /_astro/app.css, /favicon.ico, /logo.png and the
|
|
38
|
+
# like while still reporting /pricing, /blog/post, and / for both
|
|
39
|
+
# browsers and agents.
|
|
40
|
+
ASSET_PATH = /\.[a-z0-9]+$/i
|
|
41
|
+
|
|
42
|
+
# Wall clock in epoch milliseconds, for the observation time and the
|
|
43
|
+
# duration. Injectable so a test can make both deterministic.
|
|
44
|
+
DEFAULT_CLOCK = -> { Process.clock_gettime(Process::CLOCK_REALTIME, :float_millisecond) }
|
|
45
|
+
|
|
46
|
+
# +seller+ may be a client, a callable returning one, or omitted to
|
|
47
|
+
# resolve the process-wide ZeroClick::Sellers.seller on the first
|
|
48
|
+
# request (the Rails path — see Base#initialize for why).
|
|
49
|
+
#
|
|
50
|
+
# +seller_id+ is the seller's public id, sent in every beacon (required
|
|
51
|
+
# by the wire contract). +clock+ returns epoch milliseconds.
|
|
52
|
+
# +resolve_url+ is a callable taking the Rack env and returning the
|
|
53
|
+
# public URL of the request, for servers behind a proxy that rewrites
|
|
54
|
+
# the host the runtime sees. +on_error+ observes the fire-and-forget
|
|
55
|
+
# path; it is never re-raised.
|
|
56
|
+
def initialize(app, seller_id:, seller: nil, clock: nil, resolve_url: nil, on_error: nil)
|
|
57
|
+
@app = app
|
|
58
|
+
@seller_source = seller
|
|
59
|
+
@seller_id = seller_id
|
|
60
|
+
@clock = clock || DEFAULT_CLOCK
|
|
61
|
+
@resolve_url = resolve_url
|
|
62
|
+
@on_error = on_error
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def call(env)
|
|
66
|
+
started_at = @clock.call
|
|
67
|
+
status, headers, body = @app.call(env)
|
|
68
|
+
|
|
69
|
+
# The whole observation path is wrapped: a bad resolve_url, an
|
|
70
|
+
# unparseable URL, a misbehaving clock, or the beacon delivery itself
|
|
71
|
+
# must never break the page. Every failure — synchronous or from the
|
|
72
|
+
# blocking beacon — is routed to on_error, and the app's response is
|
|
73
|
+
# returned untouched regardless.
|
|
74
|
+
begin
|
|
75
|
+
observe(env, started_at: started_at, status: status, headers: headers)
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
@on_error&.call(e, env)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
[status, headers, body]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def observe(env, started_at:, status:, headers:)
|
|
86
|
+
return unless env["REQUEST_METHOD"] == "GET"
|
|
87
|
+
|
|
88
|
+
url = @resolve_url ? @resolve_url.call(env) : Agentify.public_url(env)
|
|
89
|
+
path = URI.parse(url.to_s).path
|
|
90
|
+
path = "/" if path.nil? || path.empty?
|
|
91
|
+
return if ASSET_PATH.match?(path)
|
|
92
|
+
|
|
93
|
+
seller.report_page_view(
|
|
94
|
+
seller: @seller_id,
|
|
95
|
+
path: path,
|
|
96
|
+
status: status.to_i,
|
|
97
|
+
duration_ms: [0, (@clock.call - started_at).round].max,
|
|
98
|
+
representation: representation_of(headers),
|
|
99
|
+
captured_at: Time.at(started_at / 1000.0).utc.iso8601(3),
|
|
100
|
+
user_agent: env["HTTP_USER_AGENT"],
|
|
101
|
+
accept: env["HTTP_ACCEPT"],
|
|
102
|
+
client_ip: client_ip_of(env),
|
|
103
|
+
country: env["HTTP_CF_IPCOUNTRY"],
|
|
104
|
+
referrer_host: referrer_host_of(env),
|
|
105
|
+
journey_id: journey_id_of(headers)
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# What the app served for this page-content request, read off the
|
|
110
|
+
# response content-type so it reflects the real representation
|
|
111
|
+
# regardless of what produced it.
|
|
112
|
+
def representation_of(headers)
|
|
113
|
+
header_value(headers, "content-type").to_s.include?("markdown") ? "markdown" : "html"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The journey id an upstream Agentify conversion minted for this same
|
|
117
|
+
# request, re-exposed by Middleware::Agentify. Present only for the
|
|
118
|
+
# agent-markdown variant; omitted otherwise.
|
|
119
|
+
def journey_id_of(headers)
|
|
120
|
+
header_value(headers, "x-zcj-id")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The forwarded client IP, from the edge's connecting-IP headers. The
|
|
124
|
+
# API turns it into the viewer id and never stores it raw; nil when the
|
|
125
|
+
# runtime exposes no client address.
|
|
126
|
+
def client_ip_of(env)
|
|
127
|
+
direct = env["HTTP_CF_CONNECTING_IP"] || env["HTTP_X_REAL_IP"]
|
|
128
|
+
return direct.strip if direct && !direct.strip.empty?
|
|
129
|
+
|
|
130
|
+
forwarded = env["HTTP_X_FORWARDED_FOR"]
|
|
131
|
+
return nil if forwarded.nil?
|
|
132
|
+
|
|
133
|
+
first = forwarded.split(",").first.to_s.strip
|
|
134
|
+
first.empty? ? nil : first
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The referrer reduced to its host at the edge, so the full referrer
|
|
138
|
+
# (its path and query, which can carry identifying values) never leaves
|
|
139
|
+
# the site. A malformed referrer yields no host rather than the full URL.
|
|
140
|
+
def referrer_host_of(env)
|
|
141
|
+
referer = env["HTTP_REFERER"]
|
|
142
|
+
return nil if referer.nil? || referer.empty?
|
|
143
|
+
|
|
144
|
+
uri = begin
|
|
145
|
+
URI.parse(referer)
|
|
146
|
+
rescue URI::InvalidURIError
|
|
147
|
+
nil
|
|
148
|
+
end
|
|
149
|
+
host = uri&.host
|
|
150
|
+
return nil if host.nil? || host.empty?
|
|
151
|
+
|
|
152
|
+
# Host and any non-default port only, matching the JS SDK's URL.host.
|
|
153
|
+
uri.port && uri.port != uri.default_port ? "#{host}:#{uri.port}" : host
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Case-insensitive lookup over the app's response headers.
|
|
157
|
+
def header_value(headers, name)
|
|
158
|
+
target = name.downcase
|
|
159
|
+
headers.each { |key, value| return value if key.to_s.downcase == target }
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Resolved once, on first use rather than at wire-up, exactly as
|
|
164
|
+
# Base#seller does — see that comment for the Rails ordering.
|
|
165
|
+
def seller
|
|
166
|
+
@seller ||= if @seller_source.nil?
|
|
167
|
+
Sellers.seller
|
|
168
|
+
elsif @seller_source.respond_to?(:call)
|
|
169
|
+
@seller_source.call
|
|
170
|
+
else
|
|
171
|
+
@seller_source
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "contracts"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
|
|
6
|
+
module ZeroClick
|
|
7
|
+
module Sellers
|
|
8
|
+
# POST /v1/page-views: one fire-and-forget marketing page-view beacon.
|
|
9
|
+
#
|
|
10
|
+
# As in Usage and Agentify, building the payload and interpreting the
|
|
11
|
+
# response are separate from transport, so what a response MEANS is decided
|
|
12
|
+
# in one pure place. Unlike the usage endpoints the API answers 204 with no
|
|
13
|
+
# body, so the success path parses nothing — see #interpret_report.
|
|
14
|
+
#
|
|
15
|
+
# The visitor's user-agent/accept/IP are forwarded so the API can classify
|
|
16
|
+
# the viewer and derive the shared viewer id; they are consumed there and
|
|
17
|
+
# never stored raw. Only the path is ever sent, never the query.
|
|
18
|
+
module PageViews
|
|
19
|
+
REPORT_PATH = "/v1/page-views"
|
|
20
|
+
|
|
21
|
+
# What the site actually served for a page-content request: the agent
|
|
22
|
+
# markdown variant, or the normal HTML.
|
|
23
|
+
REPRESENTATIONS = %w[html markdown].freeze
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# Build the wire body: camelCase string keys, every absent optional key
|
|
28
|
+
# omitted. +seller+ is the seller's public id and +path+ must be an
|
|
29
|
+
# absolute path, both rejected before any request leaves the process.
|
|
30
|
+
def build_payload(seller:, path:, status: nil, duration_ms: nil, representation: nil,
|
|
31
|
+
journey_id: nil, captured_at: nil, user_agent: nil, accept: nil,
|
|
32
|
+
client_ip: nil, country: nil, referrer_host: nil)
|
|
33
|
+
Sellers.require_slug!(seller, "seller", "report_page_view")
|
|
34
|
+
unless path.is_a?(String) && path.start_with?("/")
|
|
35
|
+
raise Error.new("malformed_input", operation: "report_page_view",
|
|
36
|
+
message: "path must be a string beginning with \"/\"")
|
|
37
|
+
end
|
|
38
|
+
if !representation.nil? && !REPRESENTATIONS.include?(representation)
|
|
39
|
+
raise Error.new("malformed_input", operation: "report_page_view",
|
|
40
|
+
message: "representation must be \"html\" or \"markdown\"")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
payload = { "seller" => seller, "path" => path }
|
|
44
|
+
payload["status"] = status unless status.nil?
|
|
45
|
+
payload["durationMs"] = duration_ms unless duration_ms.nil?
|
|
46
|
+
payload["representation"] = representation unless representation.nil?
|
|
47
|
+
payload["journeyId"] = journey_id unless journey_id.nil?
|
|
48
|
+
payload["capturedAt"] = captured_at unless captured_at.nil?
|
|
49
|
+
payload["userAgent"] = user_agent unless user_agent.nil?
|
|
50
|
+
payload["accept"] = accept unless accept.nil?
|
|
51
|
+
payload["clientIp"] = client_ip unless client_ip.nil?
|
|
52
|
+
payload["country"] = country unless country.nil?
|
|
53
|
+
payload["referrerHost"] = referrer_host unless referrer_host.nil?
|
|
54
|
+
payload
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A 204 carries no body, so there is nothing to parse; a non-2xx is the
|
|
58
|
+
# only failure a well-formed beacon can hit here. Distinct from
|
|
59
|
+
# Usage.interpret_report, which requires a JSON body.
|
|
60
|
+
def interpret_report(status, _body)
|
|
61
|
+
return if (200..299).cover?(status)
|
|
62
|
+
|
|
63
|
+
raise Error.new("api_status_error", operation: "report_page_view", status: status)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/zeroclick/sellers.rb
CHANGED
|
@@ -7,9 +7,11 @@ require_relative "sellers/responses"
|
|
|
7
7
|
require_relative "sellers/verify"
|
|
8
8
|
require_relative "sellers/usage"
|
|
9
9
|
require_relative "sellers/agentify"
|
|
10
|
+
require_relative "sellers/page_views"
|
|
10
11
|
require_relative "sellers/client"
|
|
11
12
|
require_relative "sellers/middleware"
|
|
12
13
|
require_relative "sellers/middleware/agentify"
|
|
14
|
+
require_relative "sellers/middleware/page_views"
|
|
13
15
|
|
|
14
16
|
# The ZeroClick billing guard for Ruby backends.
|
|
15
17
|
#
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: zeroclick-sellers
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- ZeroClick
|
|
@@ -30,6 +30,8 @@ files:
|
|
|
30
30
|
- lib/zeroclick/sellers/errors.rb
|
|
31
31
|
- lib/zeroclick/sellers/middleware.rb
|
|
32
32
|
- lib/zeroclick/sellers/middleware/agentify.rb
|
|
33
|
+
- lib/zeroclick/sellers/middleware/page_views.rb
|
|
34
|
+
- lib/zeroclick/sellers/page_views.rb
|
|
33
35
|
- lib/zeroclick/sellers/railtie.rb
|
|
34
36
|
- lib/zeroclick/sellers/responses.rb
|
|
35
37
|
- lib/zeroclick/sellers/stateful.rb
|