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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c4d8cedea5994f6b40e5d7505027dfe9bf1fa9df5dfe32863070534bee183859
4
- data.tar.gz: ff203775e81db08e529b7fe0908da260c3142b7e1a82783f9eaac4cf3624fdcd
3
+ metadata.gz: 6127c1b9413ac6cbf8d9690e972e319418e2bee6ba3a025ba057c6752d04a370
4
+ data.tar.gz: 384bfdd1ba3393497943aa9c37138f0b9fae69098a2404b3dfd6b2e51f61493b
5
5
  SHA512:
6
- metadata.gz: ccdf34f7bfccb29b898b0e71212b4573124c58b852417a883a812d24520a776710a73a1c46c2a5741e85bbad2760bc20dfd97047639a7e59e5623884937cc778
7
- data.tar.gz: 0ddb34edef539de4e5cbdd1d78747e6424bf4f95db680fde8dbf1cb7db2492b350711ee83c93653ca0df7edf64e96ab57174587c22cf881c579e46f8c6e92c78
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
- def initialize(markdown:, cache_control: nil, content_type: nil, etag: nil)
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 has no construction-time
48
- # requirement: the capability is optional, so a missing key surfaces
49
- # as a typed error at call time instead of failing every create that
50
- # never agentifies.
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
@@ -4,6 +4,6 @@ module ZeroClick
4
4
  module Sellers
5
5
  # Bump this to publish. The workflow reads it, asks RubyGems whether this
6
6
  # version already exists, and publishes only when it does not.
7
- VERSION = "0.5.0"
7
+ VERSION = "0.6.0"
8
8
  end
9
9
  end
@@ -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.5.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