zeroclick-sellers 0.5.0 → 0.7.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 +49 -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 +244 -0
- data/lib/zeroclick/sellers/page_views.rb +80 -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: 27bd4f5d8f10f026b12a89ee8681fef45ffbf58129df180f40903a55ec9743d3
|
|
4
|
+
data.tar.gz: 1ea73b94f08215d28c2974ac4937bdc423aa2f52a66c688a5746656ad837c8f2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 73ef4fc05515a9d11212ca90c7df1c9f97c157bdc2628c4b1bb81738849552fbd7aca334986fc60f1c4d0d5cb6ca311b7663c453c1633a756950e82af4f06b42
|
|
7
|
+
data.tar.gz: 00dbf768601b7808759518ad6a507e0d238f4c6c65aecdd91ce5106488841a19538f5ea49ea5df93e776a049330f32f834d7d9216dd0ce58b212bf6da24e1748
|
|
@@ -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,45 @@ 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, host: nil,
|
|
195
|
+
eval_tag: nil, eval_signature: nil, eval_signature_input: nil,
|
|
196
|
+
eval_signature_agent: nil, source: nil, source_signature: nil)
|
|
197
|
+
if @page_views_key.nil? || @page_views_key.empty?
|
|
198
|
+
raise Error.new("page_views_not_configured", operation: "report_page_view")
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
payload = PageViews.build_payload(
|
|
202
|
+
seller: seller, path: path, status: status, duration_ms: duration_ms,
|
|
203
|
+
representation: representation, journey_id: journey_id, captured_at: captured_at,
|
|
204
|
+
user_agent: user_agent, accept: accept, client_ip: client_ip,
|
|
205
|
+
country: country, referrer_host: referrer_host, host: host,
|
|
206
|
+
eval_tag: eval_tag, eval_signature: eval_signature,
|
|
207
|
+
eval_signature_input: eval_signature_input, eval_signature_agent: eval_signature_agent,
|
|
208
|
+
source: source, source_signature: source_signature
|
|
209
|
+
)
|
|
210
|
+
status_code, body = @http_post.call(
|
|
211
|
+
base_url: @api_base_url,
|
|
212
|
+
path: PageViews::REPORT_PATH,
|
|
213
|
+
api_key: @page_views_key,
|
|
214
|
+
payload: payload,
|
|
215
|
+
timeout: @page_view_timeout,
|
|
216
|
+
operation: "report_page_view"
|
|
217
|
+
)
|
|
218
|
+
PageViews.interpret_report(status_code, body)
|
|
219
|
+
end
|
|
220
|
+
|
|
176
221
|
def check_allowance(zc_request_id:, service_slug:, usage:)
|
|
177
222
|
status, body = @http_post.call(
|
|
178
223
|
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,244 @@
|
|
|
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
|
+
# - a ZeroClick agent-eval request's tag and signature are forwarded
|
|
26
|
+
# with the page's host, so the API can keep ZeroClick's own eval
|
|
27
|
+
# traffic out of the seller's numbers; a browser-based run, which
|
|
28
|
+
# cannot sign, is told by the +src+ marker on its start URL,
|
|
29
|
+
# forwarded with the host and the signature beside it;
|
|
30
|
+
# - ANY beacon failure — missing key, network error, non-204, timeout,
|
|
31
|
+
# a bad resolve_url or clock — is handed to +on_error+ and swallowed.
|
|
32
|
+
# The middleware must never break or alter the customer's site.
|
|
33
|
+
#
|
|
34
|
+
# Compose it as the OUTER middleware around Agentify — mount PageViews
|
|
35
|
+
# before Agentify — so the agent-markdown variant's re-exposed x-zcj-id is
|
|
36
|
+
# visible on the response and stitches the view onto the same journey.
|
|
37
|
+
#
|
|
38
|
+
# Like Agentify it never reads the request body, so no buffering ceiling
|
|
39
|
+
# applies.
|
|
40
|
+
class PageViews
|
|
41
|
+
# A page-content request has no asset extension on its final path
|
|
42
|
+
# segment. This skips /_astro/app.css, /favicon.ico, /logo.png and the
|
|
43
|
+
# like while still reporting /pricing, /blog/post, and / for both
|
|
44
|
+
# browsers and agents.
|
|
45
|
+
ASSET_PATH = /\.[a-z0-9]+$/i
|
|
46
|
+
|
|
47
|
+
# Wall clock in epoch milliseconds, for the observation time and the
|
|
48
|
+
# duration. Injectable so a test can make both deterministic.
|
|
49
|
+
DEFAULT_CLOCK = -> { Process.clock_gettime(Process::CLOCK_REALTIME, :float_millisecond) }
|
|
50
|
+
|
|
51
|
+
# ZeroClick's own start marker: the one query value the middleware
|
|
52
|
+
# forwards. A browser-based eval run's requests leave from the agent
|
|
53
|
+
# vendor's cloud, out of reach of the signed tag, so agent-eval marks
|
|
54
|
+
# its start URL as ?src=ze-<experiment>-<n> instead, and the API keeps
|
|
55
|
+
# the whole visit out of the seller's numbers from it.
|
|
56
|
+
EVAL_SOURCE_PREFIX = "ze-"
|
|
57
|
+
|
|
58
|
+
# +seller+ may be a client, a callable returning one, or omitted to
|
|
59
|
+
# resolve the process-wide ZeroClick::Sellers.seller on the first
|
|
60
|
+
# request (the Rails path — see Base#initialize for why).
|
|
61
|
+
#
|
|
62
|
+
# +seller_id+ is the seller's public id, sent in every beacon (required
|
|
63
|
+
# by the wire contract). +clock+ returns epoch milliseconds.
|
|
64
|
+
# +resolve_url+ is a callable taking the Rack env and returning the
|
|
65
|
+
# public URL of the request, for servers behind a proxy that rewrites
|
|
66
|
+
# the host the runtime sees; its path is reported and, with an eval
|
|
67
|
+
# tag, its host. +on_error+ observes the fire-and-forget
|
|
68
|
+
# path; it is never re-raised.
|
|
69
|
+
def initialize(app, seller_id:, seller: nil, clock: nil, resolve_url: nil, on_error: nil)
|
|
70
|
+
@app = app
|
|
71
|
+
@seller_source = seller
|
|
72
|
+
@seller_id = seller_id
|
|
73
|
+
@clock = clock || DEFAULT_CLOCK
|
|
74
|
+
@resolve_url = resolve_url
|
|
75
|
+
@on_error = on_error
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def call(env)
|
|
79
|
+
started_at = @clock.call
|
|
80
|
+
status, headers, body = @app.call(env)
|
|
81
|
+
|
|
82
|
+
# The whole observation path is wrapped: a bad resolve_url, an
|
|
83
|
+
# unparseable URL, a misbehaving clock, or the beacon delivery itself
|
|
84
|
+
# must never break the page. Every failure — synchronous or from the
|
|
85
|
+
# blocking beacon — is routed to on_error, and the app's response is
|
|
86
|
+
# returned untouched regardless.
|
|
87
|
+
begin
|
|
88
|
+
observe(env, started_at: started_at, status: status, headers: headers)
|
|
89
|
+
rescue StandardError => e
|
|
90
|
+
@on_error&.call(e, env)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
[status, headers, body]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def observe(env, started_at:, status:, headers:)
|
|
99
|
+
return unless env["REQUEST_METHOD"] == "GET"
|
|
100
|
+
|
|
101
|
+
url = @resolve_url ? @resolve_url.call(env) : Agentify.public_url(env)
|
|
102
|
+
uri = URI.parse(url.to_s)
|
|
103
|
+
path = uri.path
|
|
104
|
+
path = "/" if path.nil? || path.empty?
|
|
105
|
+
return if ASSET_PATH.match?(path)
|
|
106
|
+
|
|
107
|
+
seller.report_page_view(
|
|
108
|
+
seller: @seller_id,
|
|
109
|
+
path: path,
|
|
110
|
+
status: status.to_i,
|
|
111
|
+
duration_ms: [0, (@clock.call - started_at).round].max,
|
|
112
|
+
representation: representation_of(headers),
|
|
113
|
+
captured_at: Time.at(started_at / 1000.0).utc.iso8601(3),
|
|
114
|
+
user_agent: env["HTTP_USER_AGENT"],
|
|
115
|
+
accept: env["HTTP_ACCEPT"],
|
|
116
|
+
client_ip: client_ip_of(env),
|
|
117
|
+
country: env["HTTP_CF_IPCOUNTRY"],
|
|
118
|
+
referrer_host: referrer_host_of(env),
|
|
119
|
+
journey_id: journey_id_of(headers),
|
|
120
|
+
**start_marker_of(uri),
|
|
121
|
+
**eval_run_of(env, uri)
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# ZeroClick's own agent-eval run, when the visitor carried its tag: the
|
|
126
|
+
# tag, the signature agent-eval puts on a seller's site (under
|
|
127
|
+
# ZeroClick-only header names, never Web Bot Auth's) and the public
|
|
128
|
+
# host it was signed for. Forwarded as sent; the API labels the view
|
|
129
|
+
# only when the signature verifies, so a forged tag labels nothing. A
|
|
130
|
+
# value outside what the beacon accepts (empty, or longer than
|
|
131
|
+
# agent-eval ever sends) is left out rather than sent, so a visitor's
|
|
132
|
+
# odd header can never cost the page view itself.
|
|
133
|
+
def eval_run_of(env, uri)
|
|
134
|
+
tag = capped(env["HTTP_X_ZEROCLICK_EVAL"], 512)
|
|
135
|
+
return {} if tag.nil?
|
|
136
|
+
|
|
137
|
+
{
|
|
138
|
+
host: capped(host_of(uri), 300),
|
|
139
|
+
eval_tag: tag,
|
|
140
|
+
eval_signature: capped(env["HTTP_X_ZEROCLICK_EVAL_SIGNATURE"], 1_024),
|
|
141
|
+
eval_signature_input: capped(env["HTTP_X_ZEROCLICK_EVAL_SIGNATURE_INPUT"], 1_024),
|
|
142
|
+
eval_signature_agent: capped(env["HTTP_X_ZEROCLICK_EVAL_SIGNATURE_AGENT"], 512)
|
|
143
|
+
}
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def capped(value, max)
|
|
147
|
+
value && !value.empty? && value.length <= max ? value : nil
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# The public host the page was requested on, as the JS SDK's URL.host
|
|
151
|
+
# reads it: the host and any non-default port.
|
|
152
|
+
def host_of(uri)
|
|
153
|
+
return nil if uri.host.nil? || uri.host.empty?
|
|
154
|
+
|
|
155
|
+
uri.port && uri.port != uri.default_port ? "#{uri.host}:#{uri.port}" : uri.host
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# ZeroClick's own start marker, keyed like report_page_view: empty
|
|
159
|
+
# without one. The +src+ query value when it is ours, the page's host
|
|
160
|
+
# and the +signature+ agent-eval puts beside the marker, as sent: the
|
|
161
|
+
# only query values the beacon carries. Any other +src+ stays on the
|
|
162
|
+
# site, and a marker or signature past what the beacon accepts (or a
|
|
163
|
+
# query that does not decode) is left out rather than sent.
|
|
164
|
+
def start_marker_of(uri)
|
|
165
|
+
return {} if uri.query.nil? || uri.query.empty?
|
|
166
|
+
|
|
167
|
+
values = URI.decode_www_form(uri.query)
|
|
168
|
+
src = values.assoc("src")&.last
|
|
169
|
+
return {} unless src&.start_with?(EVAL_SOURCE_PREFIX) && src.length <= 32
|
|
170
|
+
|
|
171
|
+
{
|
|
172
|
+
host: capped(host_of(uri), 300),
|
|
173
|
+
source: src,
|
|
174
|
+
source_signature: capped(values.assoc("signature")&.last, 256)
|
|
175
|
+
}
|
|
176
|
+
rescue ArgumentError
|
|
177
|
+
{}
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# What the app served for this page-content request, read off the
|
|
181
|
+
# response content-type so it reflects the real representation
|
|
182
|
+
# regardless of what produced it.
|
|
183
|
+
def representation_of(headers)
|
|
184
|
+
header_value(headers, "content-type").to_s.include?("markdown") ? "markdown" : "html"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# The journey id an upstream Agentify conversion minted for this same
|
|
188
|
+
# request, re-exposed by Middleware::Agentify. Present only for the
|
|
189
|
+
# agent-markdown variant; omitted otherwise.
|
|
190
|
+
def journey_id_of(headers)
|
|
191
|
+
header_value(headers, "x-zcj-id")
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# The forwarded client IP, from the edge's connecting-IP headers. The
|
|
195
|
+
# API turns it into the viewer id and never stores it raw; nil when the
|
|
196
|
+
# runtime exposes no client address.
|
|
197
|
+
def client_ip_of(env)
|
|
198
|
+
direct = env["HTTP_CF_CONNECTING_IP"] || env["HTTP_X_REAL_IP"]
|
|
199
|
+
return direct.strip if direct && !direct.strip.empty?
|
|
200
|
+
|
|
201
|
+
forwarded = env["HTTP_X_FORWARDED_FOR"]
|
|
202
|
+
return nil if forwarded.nil?
|
|
203
|
+
|
|
204
|
+
first = forwarded.split(",").first.to_s.strip
|
|
205
|
+
first.empty? ? nil : first
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# The referrer reduced to its host at the edge, so the full referrer
|
|
209
|
+
# (its path and query, which can carry identifying values) never leaves
|
|
210
|
+
# the site. A malformed referrer yields no host rather than the full URL.
|
|
211
|
+
def referrer_host_of(env)
|
|
212
|
+
referer = env["HTTP_REFERER"]
|
|
213
|
+
return nil if referer.nil? || referer.empty?
|
|
214
|
+
|
|
215
|
+
uri = begin
|
|
216
|
+
URI.parse(referer)
|
|
217
|
+
rescue URI::InvalidURIError
|
|
218
|
+
nil
|
|
219
|
+
end
|
|
220
|
+
uri && host_of(uri)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Case-insensitive lookup over the app's response headers.
|
|
224
|
+
def header_value(headers, name)
|
|
225
|
+
target = name.downcase
|
|
226
|
+
headers.each { |key, value| return value if key.to_s.downcase == target }
|
|
227
|
+
nil
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Resolved once, on first use rather than at wire-up, exactly as
|
|
231
|
+
# Base#seller does — see that comment for the Rails ordering.
|
|
232
|
+
def seller
|
|
233
|
+
@seller ||= if @seller_source.nil?
|
|
234
|
+
Sellers.seller
|
|
235
|
+
elsif @seller_source.respond_to?(:call)
|
|
236
|
+
@seller_source.call
|
|
237
|
+
else
|
|
238
|
+
@seller_source
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
# +host+ and the +eval_*+ values describe ZeroClick's own agent-eval run
|
|
31
|
+
# when the visitor carried its tag: the public host the run signed for
|
|
32
|
+
# and the tag with its signature, as sent. The API labels the view as
|
|
33
|
+
# eval traffic only when the signature verifies.
|
|
34
|
+
def build_payload(seller:, path:, status: nil, duration_ms: nil, representation: nil,
|
|
35
|
+
journey_id: nil, captured_at: nil, user_agent: nil, accept: nil,
|
|
36
|
+
client_ip: nil, country: nil, referrer_host: nil, host: nil,
|
|
37
|
+
eval_tag: nil, eval_signature: nil, eval_signature_input: nil,
|
|
38
|
+
eval_signature_agent: nil, source: nil, source_signature: nil)
|
|
39
|
+
Sellers.require_slug!(seller, "seller", "report_page_view")
|
|
40
|
+
unless path.is_a?(String) && path.start_with?("/")
|
|
41
|
+
raise Error.new("malformed_input", operation: "report_page_view",
|
|
42
|
+
message: "path must be a string beginning with \"/\"")
|
|
43
|
+
end
|
|
44
|
+
if !representation.nil? && !REPRESENTATIONS.include?(representation)
|
|
45
|
+
raise Error.new("malformed_input", operation: "report_page_view",
|
|
46
|
+
message: "representation must be \"html\" or \"markdown\"")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
payload = { "seller" => seller, "path" => path }
|
|
50
|
+
payload["status"] = status unless status.nil?
|
|
51
|
+
payload["durationMs"] = duration_ms unless duration_ms.nil?
|
|
52
|
+
payload["representation"] = representation unless representation.nil?
|
|
53
|
+
payload["journeyId"] = journey_id unless journey_id.nil?
|
|
54
|
+
payload["capturedAt"] = captured_at unless captured_at.nil?
|
|
55
|
+
payload["userAgent"] = user_agent unless user_agent.nil?
|
|
56
|
+
payload["accept"] = accept unless accept.nil?
|
|
57
|
+
payload["clientIp"] = client_ip unless client_ip.nil?
|
|
58
|
+
payload["country"] = country unless country.nil?
|
|
59
|
+
payload["referrerHost"] = referrer_host unless referrer_host.nil?
|
|
60
|
+
payload["host"] = host unless host.nil?
|
|
61
|
+
payload["evalTag"] = eval_tag unless eval_tag.nil?
|
|
62
|
+
payload["evalSignature"] = eval_signature unless eval_signature.nil?
|
|
63
|
+
payload["evalSignatureInput"] = eval_signature_input unless eval_signature_input.nil?
|
|
64
|
+
payload["evalSignatureAgent"] = eval_signature_agent unless eval_signature_agent.nil?
|
|
65
|
+
payload["source"] = source unless source.nil?
|
|
66
|
+
payload["sourceSignature"] = source_signature unless source_signature.nil?
|
|
67
|
+
payload
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A 204 carries no body, so there is nothing to parse; a non-2xx is the
|
|
71
|
+
# only failure a well-formed beacon can hit here. Distinct from
|
|
72
|
+
# Usage.interpret_report, which requires a JSON body.
|
|
73
|
+
def interpret_report(status, _body)
|
|
74
|
+
return if (200..299).cover?(status)
|
|
75
|
+
|
|
76
|
+
raise Error.new("api_status_error", operation: "report_page_view", status: status)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
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.7.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
|