privatus-analytics-rails 1.0.0 → 1.1.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: fce613e4182efd36c3b231cdae877639724e6ae9b786f09cd11adaddf05182d5
4
- data.tar.gz: c0a616f3c617a40484b81ebb855dab2e6ce9d042b5d5864fde8095900e469031
3
+ metadata.gz: 2184204c6532f24f22ae1c68f92000f5f0cc72f8c0ad580809dfa293a60e281d
4
+ data.tar.gz: 217a4fb72fabe001ce49e1bdbe206b652c5c77db4bbf22f18e820705c7f4e73a
5
5
  SHA512:
6
- metadata.gz: efe3c7d6d58028ecbb100cd663b34dfae9daca77fe045c9c19b7346223fa7d18d351f1edc91068ccd490f1aef3b19953fef2b762298fd9bf91745dc6bfa13b31
7
- data.tar.gz: 3d03ce8f72e0d0a755f9118f5fd3dc68caffab80c00bc5deebc60bec453f4b751cb0b6df3726f8ffbb660e17c19061c072cd4aeb7c7cc5256cf098ccf5f9a891
6
+ metadata.gz: 025e03b5bf68d3b5bc2dbbccfdbb42c0575e49d9fde7a119d11f087e962a64b143d436f765a543f3309663941c3f30680660bb94ac10494d2b31f7451a20f4c5
7
+ data.tar.gz: 561662158e22edfda33a9d08c2fc1cceac485fbca706c957d736b3259c5868808ddbfb1c3d9de03e6edbbaaf16a51dafa20e6781156342727743354eab0770fc
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.0 - 2026-10-26
4
+
5
+ - README: a new intro that starts with the problem Privatus Analytics solves.
6
+ - Changed: with `queue_stub` on, a tag that is off (development, `enabled: false` or no site id) now prints the
7
+ queue stub and nothing else, so `privatus.track(...)` calls in your own JavaScript never fail. Before, it
8
+ printed nothing. With `queue_stub` off it still prints nothing.
9
+ - Fixed: `privatus_analytics_tag(enabled: false)` and other per call `enabled`, `site_id`, `script_host` and
10
+ `extra_attributes` were ignored, so a page could not turn the tag off for itself. Found by the
11
+ conformance tests.
12
+ - `privatus_event_attrs` and `privatus_event_data` for custom events on buttons, links and forms.
13
+ - `privatus_click_attr` and `privatus_click_data` for click map names.
14
+ - Page props: `config.privatus_analytics.props` (a hash, or a lambda that gets the request) and
15
+ `privatus_analytics_tag(props: ...)`. They print the `privatusPageProps` script and `data-props`.
16
+ - `queue_stub` setting, so `privatus.track` calls made before the tracker loads are kept.
17
+ - The middleware now passes your props lambda a `Rack::Request` and adds the Rails CSP nonce when there is one.
18
+ - `run-docker.sh` now stops at the first failing step.
19
+
3
20
  ## 1.0.0 - 2026-10-20
4
21
 
5
22
  - Initial release.
data/README.md CHANGED
@@ -1,16 +1,39 @@
1
1
  # Privatus Analytics for Rails
2
2
 
3
- Adds the cookieless [Privatus Analytics](https://privatusanalytics.com)
4
- tracking script to your Rails app with a view helper, or with an optional Rack
5
- middleware that puts it in the `<head>` for you.
3
+ **Real visitor numbers for your Rails app. No cookies, no cookie pop-up, no bots.**
6
4
 
7
- Privatus Analytics counts visits, sources, pages and conversions without
8
- cookies, local storage or fingerprinting, so analytics does not need a consent
9
- banner by itself. Check with your own counsel for your country.
5
+ You want to know if people use your Rails app. But Google Analytics uses
6
+ cookies, so in many countries you must show a cookie pop-up first (the box that
7
+ asks visitors to say yes to cookies). Many people click no, so they never show
8
+ up in your numbers. Bots add fake visits on top.
10
9
 
11
- The gem only prints the browser script. It never sends events from your server
12
- and sets no cookies. You need a Privatus Analytics account to see reports, and
13
- there is a free plan. Sign up at https://privatusanalytics.com.
10
+ This gem adds the Privatus Analytics script to your Rails app with a view
11
+ helper, or a Rack middleware that puts it in the page head for you. The script
12
+ sets no cookies and keeps no IP addresses. Bots are filtered out before they are
13
+ counted.
14
+
15
+ So you see how many real people came, which pages they looked at and where they
16
+ came from, on one simple dashboard. A report comes to your inbox each week, and
17
+ you can set alerts for when traffic jumps or drops. And you don't need a cookie
18
+ pop-up for your analytics.
19
+
20
+ **Free plan, no card:** 10,000 events a month (an event is a page view or an action you track) on up to 3 sites. [Start free at privatusanalytics.com](https://privatusanalytics.com).
21
+
22
+ - **No cookies.** No cookies and no hidden IDs on your visitors' devices, and no IP
23
+ addresses are kept. Laws differ by country, so check your own legal advice.
24
+ - **Real people, not bots.** Known bots, data centers and spam visits are left
25
+ out on every plan.
26
+ - **Small and fast.** The script is about 3 KB when compressed.
27
+
28
+ ## What this package does
29
+
30
+ - Adds the tracking script to your Rails app with a view helper.
31
+ - An optional Rack middleware can put the script in the `<head>` for you.
32
+ - The gem only prints the browser script. It never sends events from your
33
+ server and sets no cookies.
34
+
35
+ You need a Privatus Analytics account to see your reports, and there is a
36
+ free plan. [Sign up at privatusanalytics.com](https://privatusanalytics.com).
14
37
 
15
38
  ## Requirements
16
39
 
@@ -61,7 +84,8 @@ In production it prints:
61
84
  ```
62
85
 
63
86
  The tag prints nothing when there is no site id, and nothing outside the
64
- production environment (see below). If your app uses a Content Security Policy
87
+ production environment (see below). The one exception is the queue stub: when
88
+ you turn it on, it still prints. If your app uses a Content Security Policy
65
89
  nonce, the helper adds it for you.
66
90
 
67
91
  ### Or let the gem add it
@@ -74,9 +98,10 @@ config.privatus_analytics.auto_inject = true
74
98
 
75
99
  It adds the tag just before `</head>` in uncompressed `text/html` responses with
76
100
  status 200. It skips Ajax requests, pages without a `<head>`, and pages that
77
- already have the tag. Responses that carry a `Content-Encoding` header are left
101
+ already have the tag. Responses that have a `Content-Encoding` header are left
78
102
  alone, so if you compress inside the Rack stack, add the middleware above
79
- `Rack::Deflater` or use the helper instead.
103
+ `Rack::Deflater` or use the helper instead. The middleware also adds the
104
+ Rails Content Security Policy nonce when your app has one.
80
105
 
81
106
  ## Configuration
82
107
 
@@ -95,6 +120,8 @@ Rails.application.configure do
95
120
  config.privatus_analytics.auto_inject = false
96
121
  config.privatus_analytics.enabled_environments = %w[production]
97
122
  config.privatus_analytics.extra_attributes = { "data-mask" => "/orders/*->/orders/:id" }
123
+ config.privatus_analytics.props = ->(request) { { plan: Current.account&.plan } }
124
+ config.privatus_analytics.queue_stub = false
98
125
  end
99
126
  ```
100
127
 
@@ -111,6 +138,8 @@ end
111
138
  | `enabled` | `nil` | `true` or `false` forces the tag on or off in every environment |
112
139
  | `auto_inject` | `false` | Adds the tag with a Rack middleware |
113
140
  | `extra_attributes` | `{}` | Any other `data-*` attribute |
141
+ | `props` | `{}` | Page props sent with every pageview and event. A hash, or a lambda that gets the request. See below |
142
+ | `queue_stub` | `false` | Prints a small script first, so `privatus.track` calls made before the tracker loads are kept and sent later. It still prints when the tag is off, so those calls never fail |
114
143
 
115
144
  Each tracker attribute is explained in the [script attributes
116
145
  reference](https://privatusanalytics.com/docs/tracker/attributes), and the
@@ -143,22 +172,168 @@ signed-in staff, render the helper conditionally:
143
172
  <%= privatus_analytics_tag unless current_user&.admin? %>
144
173
  ```
145
174
 
146
- ## Track your own events
175
+ ## Product analytics: see which features people use
176
+
177
+ Privatus Analytics can show which buttons, forms and features people really use. You
178
+ add a few attributes to your HTML. The tracker reads them in the browser and
179
+ sends the events. Your server still sends nothing.
180
+
181
+ ### Name events well
147
182
 
148
- The gem only loads the tracker. Track custom events from the browser, as
149
- described in the [JavaScript API
150
- docs](https://privatusanalytics.com/docs/tracker/javascript-api):
183
+ Use a short "Noun verb" name, like `Report exported` or `Invite sent`. Put the
184
+ details in props, not in the name. So write `Report exported` with
185
+ `format: "csv"`, not `Report exported as CSV`. Then all exports add up in one
186
+ place, and you can split them by format.
187
+
188
+ ### Events on buttons, links and forms
189
+
190
+ `privatus_event_attrs` prints the attributes inside a tag you write yourself:
151
191
 
152
192
  ```erb
153
- <button data-privatus-event="Newsletter signup" data-privatus-prop-list="weekly">Subscribe</button>
193
+ <button <%= privatus_event_attrs("Report exported", format: "csv", source: "toolbar") %>>Export</button>
194
+ ```
195
+
196
+ It prints:
197
+
198
+ ```html
199
+ <button data-privatus-event="Report exported" data-privatus-prop-format="csv" data-privatus-prop-source="toolbar">Export</button>
154
200
  ```
155
201
 
202
+ With the Rails tag helpers, use `privatus_event_data`. It returns a hash with
203
+ the full attribute names, so you spread it into the options:
204
+
205
+ ```erb
206
+ <%= tag.button "Export", **privatus_event_data("Report exported", format: "csv"), class: "btn" %>
207
+ <%= button_tag "Export", privatus_event_data("Report exported", format: "csv") %>
208
+ <%= link_to "Read the guide", guide_path, **privatus_event_data("Guide opened") %>
209
+ ```
210
+
211
+ On a form, the event is sent when the form is submitted. `form_with` needs
212
+ the hash under `html:`:
213
+
214
+ ```erb
215
+ <%= form_with url: newsletter_path, html: privatus_event_data("Newsletter joined", list: "weekly") do |f| %>
216
+ ```
217
+
218
+ The rules:
219
+
220
+ * The name is trimmed and cut to 120 characters. An empty name prints nothing.
221
+ * Prop keys are made lowercase. Anything that is not a letter, a digit or `_`
222
+ becomes `_`, so `"Report Type"` becomes `report_type`. Keys are cut to 64
223
+ characters.
224
+ * At most 30 props. Values become text: `true`, `false`, numbers as written,
225
+ lists joined with commas. `nil` values are left out. Values are cut to 500
226
+ characters.
227
+ * Everything is escaped for HTML.
228
+ * The attributes print in every environment, even when the tag is off. They do
229
+ nothing without the tracker, so your pages look the same in development and
230
+ production.
231
+
232
+ ### Click maps
233
+
234
+ A click map shows where people click on a page. Give the important parts a
235
+ name so they stay easy to find, even when the page changes:
236
+
237
+ ```erb
238
+ <a href="/pricing" <%= privatus_click_attr("pricing.cta") %>>See plans</a>
239
+ <%= link_to "See plans", pricing_path, **privatus_click_data("pricing.cta") %>
240
+ ```
241
+
242
+ A name may use letters, digits and `_ . : -`, up to 100 characters. Anything
243
+ else prints nothing, and in development the gem writes a warning to the Rails
244
+ log.
245
+
246
+ ### Page props: plan, role and other groups
247
+
248
+ Page props are sent with every pageview and event on the page. Use them to
249
+ split your reports by plan, role, app version, feature flag or account tier.
250
+
251
+ Set them for every page with a lambda. It runs on each request and gets the
252
+ request:
253
+
254
+ ```ruby
255
+ config.privatus_analytics.props = lambda do |request|
256
+ { plan: Current.account&.plan, role: Current.user&.role, app_version: "2.4" }
257
+ end
258
+ ```
259
+
260
+ A plain hash works too. You can also set or change props for one page. They
261
+ are merged over the configured ones, and `nil` removes one:
262
+
263
+ ```erb
264
+ <%= privatus_analytics_tag(props: { section: "billing", role: nil }) %>
265
+ ```
266
+
267
+ The tag then prints a small script before the tracker, with your Content
268
+ Security Policy nonce when you have one, and points the tracker at it:
269
+
270
+ ```html
271
+ <script nonce="...">window.privatusPageProps=function(){return {"plan":"pro","role":"admin"}}</script>
272
+ <script defer src="https://privatusstats.com/js/pa.js" data-site="pa_YOURSITEID" data-props="privatusPageProps"></script>
273
+ ```
274
+
275
+ Keys follow the same rules as event props. Numbers and true or false stay as
276
+ they are, and lists stay lists (up to 30 items). The props are escaped so they
277
+ are safe inside a script. The middleware uses the same setting. There it calls
278
+ your lambda with a `Rack::Request`, so only use what both kinds of request
279
+ have, like `path`, `params`, `session` and `env`.
280
+
281
+ **Never put names, emails, user ids or anything that points to one person in
282
+ props.** Privatus Analytics counts visitors without any id. Put groups (like the plan or
283
+ the role), not people. The server also removes emails and long ids it finds.
284
+
285
+ ### Track from your own JavaScript
286
+
287
+ For things that are not a click or a submit, call the tracker from your
288
+ JavaScript, as the [JavaScript API
289
+ docs](https://privatusanalytics.com/docs/tracker/javascript-api) explain:
290
+
291
+ ```js
292
+ privatus.track("Feature used", { feature: "export" })
293
+ ```
294
+
295
+ If your code may run before the tracker has loaded, switch on the queue stub.
296
+ It prints a tiny script before the tracker that keeps your calls in a list and
297
+ sends them once the tracker is ready:
298
+
299
+ ```ruby
300
+ config.privatus_analytics.queue_stub = true
301
+ ```
302
+
303
+ or for one layout: `<%= privatus_analytics_tag(queue_stub: true) %>`. The stub
304
+ prints even where the tag is off (in development, or with `enabled: false`).
305
+ Then only the stub prints: no tracker and no page props. So you can always call
306
+ `privatus.track(...)`. In development the calls just wait in the queue and are
307
+ never sent.
308
+
309
+ ### See the results
310
+
311
+ * [Funnels](https://privatusanalytics.com/docs/features/funnels): how many
312
+ people go from one step to the next.
313
+ * [Click maps](https://privatusanalytics.com/docs/features/click-maps): where
314
+ people click.
315
+ * [Goals](https://privatusanalytics.com/docs/features/goals): turn an event
316
+ into a goal to count conversions.
317
+ * [Events](https://privatusanalytics.com/docs/events): every custom event and
318
+ its props.
319
+
156
320
  ## Privacy
157
321
 
158
- The script sets no cookies and stores nothing in the browser. See the
322
+ The script sets no cookies and stores nothing in the browser. The event, click
323
+ and props helpers only print HTML attributes and a small script. Keep people
324
+ out of your props and event props: no names, emails or user ids. See the
159
325
  [privacy documentation](https://privatusanalytics.com/docs/privacy) for what is
160
326
  collected.
161
327
 
328
+ If you have a privacy policy, it should say which tools handle your visitors' data. You can paste this:
329
+
330
+ > This website uses [Privatus Analytics](https://privatusanalytics.com/privacy-analytics) to count visits. It
331
+ > does not use cookies and does not follow you from one website to another. Your IP address is used for a moment
332
+ > to count visits and is never stored.
333
+
334
+ Change it to fit your site. The [privacy policy generator](https://privatusanalytics.com/tools/privacy-policy-generator)
335
+ makes a longer version with your site's name in it.
336
+
162
337
  ## Development
163
338
 
164
339
  The tests run in Docker so your local Ruby does not matter:
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ module PrivatusAnalytics
6
+ # Builds the data attributes the tracker reads for custom events and click maps.
7
+ # Plain Ruby, no Rails needed. The view helpers wrap these methods.
8
+ module Attributes
9
+ MAX_NAME = 120
10
+ MAX_KEY = 64
11
+ MAX_PROPS = 30
12
+ MAX_VALUE = 500
13
+ CLICK_NAME = /\A[A-Za-z0-9_.:-]{1,100}\z/
14
+
15
+ module_function
16
+
17
+ # { "data-privatus-event" => name, "data-privatus-prop-<key>" => value, ... }, or {} for an empty name.
18
+ def event(name, props = {})
19
+ name = name.to_s.strip[0, MAX_NAME].to_s.strip
20
+ return {} if name.empty?
21
+
22
+ attrs = { "data-privatus-event" => name }
23
+ normalize(props).each { |key, value| attrs["data-privatus-prop-#{key}"] = string_value(value) }
24
+ attrs
25
+ end
26
+
27
+ # { "data-privatus-click" => name }, or {} when the name is not valid.
28
+ def click(name)
29
+ name.to_s.match?(CLICK_NAME) ? { "data-privatus-click" => name.to_s } : {}
30
+ end
31
+
32
+ # Whether a click map name is valid: letters, digits, _ . : - and at most 100 characters.
33
+ def valid_click?(name)
34
+ name.to_s.match?(CLICK_NAME)
35
+ end
36
+
37
+ # Turns an attribute hash into HTML, with every value escaped.
38
+ def to_html(attrs)
39
+ attrs.map { |name, value| %(#{name}="#{CGI.escapeHTML(value.to_s)}") }.join(" ")
40
+ end
41
+
42
+ # Lowercases keys, swaps anything outside a-z 0-9 _ for _, cuts them to 64 characters,
43
+ # drops nil values and keeps the first 30 props.
44
+ def normalize(props)
45
+ clean = {}
46
+ (props || {}).to_h.each do |key, value|
47
+ key = normalize_key(key)
48
+ clean[key] = value unless key.empty? || value.nil?
49
+ end
50
+ clean.first(MAX_PROPS).to_h
51
+ end
52
+
53
+ def normalize_key(key)
54
+ key.to_s.downcase.gsub(/[^a-z0-9_]/, "_")[0, MAX_KEY]
55
+ end
56
+
57
+ def string_value(value)
58
+ text = value.is_a?(Array) ? value.compact.join(",") : value.to_s
59
+ text[0, MAX_VALUE]
60
+ end
61
+ end
62
+ end
@@ -31,6 +31,12 @@ module PrivatusAnalytics
31
31
  attr_accessor :auto_inject
32
32
  # Extra data- attributes as a hash, for example { "data-mask" => "/orders/*->/orders/:id" }.
33
33
  attr_accessor :extra_attributes
34
+ # Page props sent with every pageview and event, for example { plan: "pro", role: "admin" }.
35
+ # A hash, or a lambda called on each request: ->(request) { { plan: Current.account&.plan } }.
36
+ # Never put names, emails, user ids or anything else that points to one person here.
37
+ attr_accessor :props
38
+ # true prints the tracker's queue stub first, so privatus.track works before pa.js loads.
39
+ attr_accessor :queue_stub
34
40
 
35
41
  def initialize
36
42
  @site_id = nil
@@ -44,6 +50,8 @@ module PrivatusAnalytics
44
50
  @enabled = nil
45
51
  @auto_inject = false
46
52
  @extra_attributes = {}
53
+ @props = {}
54
+ @queue_stub = false
47
55
  end
48
56
 
49
57
  def site_id
@@ -66,6 +74,16 @@ module PrivatusAnalytics
66
74
  Array(enabled_environments).map(&:to_s).include?(environment.to_s)
67
75
  end
68
76
 
77
+ # The configured props for one request. Calls the lambda when props is one.
78
+ # The request is an ActionDispatch::Request in views and a Rack::Request in the middleware.
79
+ def page_props(request = nil)
80
+ value = props
81
+ if value.respond_to?(:call)
82
+ value = value.respond_to?(:arity) && value.arity.zero? ? value.call : value.call(request)
83
+ end
84
+ value.respond_to?(:to_h) ? value.to_h : {}
85
+ end
86
+
69
87
  private
70
88
 
71
89
  def current_environment
@@ -1,19 +1,71 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PrivatusAnalytics
4
- # View helper. Included into ActionView by the Railtie.
4
+ # View helpers. Included into ActionView by the Railtie.
5
5
  module Helper
6
6
  # Prints the Privatus Analytics script tag. Put it in the head of your layout.
7
7
  #
8
8
  # <%= privatus_analytics_tag %>
9
9
  # <%= privatus_analytics_tag(modules: %w[engage auto]) %>
10
+ # <%= privatus_analytics_tag(props: { plan: "pro" }, queue_stub: true) %>
10
11
  #
11
12
  # Prints nothing without a site id, or outside the enabled environments.
13
+ # The one exception: with queue_stub on, the queue stub still prints (alone),
14
+ # so privatus.track calls in your own JavaScript never fail in development.
12
15
  def privatus_analytics_tag(options = {})
13
16
  options = options.dup
14
17
  options[:nonce] ||= content_security_policy_nonce if respond_to?(:content_security_policy_nonce)
15
- html = Tag.new(PrivatusAnalytics.configuration, options).to_s
18
+ options[:request] ||= request if respond_to?(:request)
19
+ privatus_safe(Tag.new(PrivatusAnalytics.configuration, options).to_s)
20
+ end
21
+
22
+ # Data attributes for a custom event, as safe HTML to put inside a tag:
23
+ #
24
+ # <button <%= privatus_event_attrs("Export CSV", format: "csv") %>>Export</button>
25
+ #
26
+ # On a <form> the event is sent when the form is submitted.
27
+ def privatus_event_attrs(name, props = nil, **more_props)
28
+ privatus_safe(Attributes.to_html(privatus_event_data(name, props, **more_props)))
29
+ end
30
+
31
+ # The same attributes as a hash with full attribute names, for the Rails tag helpers:
32
+ #
33
+ # <%= tag.button "Export", **privatus_event_data("Export CSV", format: "csv"), class: "btn" %>
34
+ # <%= button_tag "Export", privatus_event_data("Export CSV") %>
35
+ # <%= form_with url: signup_path, html: privatus_event_data("Newsletter signup") do |f| %>
36
+ #
37
+ # Returns {} for an empty name.
38
+ def privatus_event_data(name, props = nil, **more_props)
39
+ Attributes.event(name, (props || {}).to_h.merge(more_props))
40
+ end
41
+
42
+ # The click map name attribute, as safe HTML: <a <%= privatus_click_attr("pricing.cta") %>>
43
+ # Prints nothing when the name is not valid.
44
+ def privatus_click_attr(name)
45
+ privatus_safe(Attributes.to_html(privatus_click_data(name)))
46
+ end
47
+
48
+ # The click map name as a hash for the tag helpers:
49
+ #
50
+ # <%= link_to "Pricing", pricing_path, **privatus_click_data("pricing.cta") %>
51
+ def privatus_click_data(name)
52
+ data = Attributes.click(name)
53
+ privatus_warn_invalid_click(name) if data.empty?
54
+ data
55
+ end
56
+
57
+ private
58
+
59
+ def privatus_safe(html)
16
60
  html.respond_to?(:html_safe) ? html.html_safe : html
17
61
  end
62
+
63
+ def privatus_warn_invalid_click(name)
64
+ return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
65
+ return if Rails.respond_to?(:env) && Rails.env.production?
66
+
67
+ Rails.logger.warn("[privatus-analytics] click map name #{name.inspect} is not valid. Use letters, " \
68
+ "digits and _ . : - only, at most 100 characters. Nothing was printed.")
69
+ end
18
70
  end
19
71
  end
@@ -1,11 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rack/request"
4
+
3
5
  module PrivatusAnalytics
4
6
  # Adds the script tag just before </head> in HTML responses.
5
7
  # Off unless `config.privatus_analytics.auto_inject = true`.
6
8
  class Middleware
7
9
  HEAD_CLOSE = %r{</head>}i
8
10
  MARKER = "/js/pa.js"
11
+ NONCE_KEY = "action_dispatch.content_security_policy_nonce"
9
12
 
10
13
  def initialize(app, configuration = nil)
11
14
  @app = app
@@ -16,7 +19,7 @@ module PrivatusAnalytics
16
19
  status, headers, body = @app.call(env)
17
20
  return [status, headers, body] unless inject?(status, headers, env)
18
21
 
19
- inject(status, headers, body)
22
+ inject(status, headers, body, env)
20
23
  end
21
24
 
22
25
  private
@@ -33,6 +36,14 @@ module PrivatusAnalytics
33
36
  Tag.new(configuration).render?
34
37
  end
35
38
 
39
+ # The Rails CSP nonce for this request, so the inline props and stub scripts are allowed.
40
+ def nonce(env)
41
+ return env[NONCE_KEY] if env[NONCE_KEY]
42
+ return unless defined?(ActionDispatch::Request)
43
+
44
+ ActionDispatch::Request.new(env).content_security_policy_nonce
45
+ end
46
+
36
47
  def encoded?(headers)
37
48
  !(headers["content-encoding"] || headers["Content-Encoding"]).nil?
38
49
  end
@@ -42,13 +53,13 @@ module PrivatusAnalytics
42
53
  type.to_s.downcase.start_with?("text/html")
43
54
  end
44
55
 
45
- def inject(status, headers, body)
56
+ def inject(status, headers, body, env)
46
57
  html = +""
47
58
  body.each { |part| html << part.to_s }
48
59
  body.close if body.respond_to?(:close)
49
60
 
50
61
  unless html.include?(MARKER) || !html.match?(HEAD_CLOSE)
51
- tag = Tag.new(configuration).to_s
62
+ tag = Tag.new(configuration, request: Rack::Request.new(env), nonce: nonce(env)).to_s
52
63
  html = html.sub(HEAD_CLOSE) { "#{tag}\n</head>" }
53
64
  end
54
65
 
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module PrivatusAnalytics
6
+ # Turns page props (plan, role and so on) into JSON that is safe inside a <script>.
7
+ # Keys follow the same rules as event props. Numbers, booleans and lists keep their JSON types.
8
+ module PageProps
9
+ MAX_ITEMS = 30
10
+ UNSAFE = /[<>&\u2028\u2029]/
11
+
12
+ module_function
13
+
14
+ # A JSON object string, or nil when there are no props to send.
15
+ def json(props)
16
+ clean = Attributes.normalize(props).filter_map do |key, value|
17
+ value = json_value(value)
18
+ [key, value] unless value.nil?
19
+ end
20
+ return nil if clean.empty?
21
+
22
+ JSON.generate(clean.to_h).gsub(UNSAFE) { |char| format("\\u%04x", char.ord) }
23
+ end
24
+
25
+ def json_value(value)
26
+ return value.compact.first(MAX_ITEMS).filter_map { |item| scalar(item) } if value.is_a?(Array)
27
+
28
+ scalar(value)
29
+ end
30
+
31
+ def scalar(value)
32
+ case value
33
+ when nil then nil
34
+ when true, false, Integer then value
35
+ when Float then value.finite? ? value : nil
36
+ else value.to_s.scrub[0, Attributes::MAX_VALUE]
37
+ end
38
+ end
39
+ end
40
+ end
@@ -4,36 +4,93 @@ require "cgi"
4
4
 
5
5
  module PrivatusAnalytics
6
6
  # Builds the script tag. Returns an empty string when no tag should print.
7
+ #
8
+ # Before the tracker tag it can also print the queue stub (queue_stub) and the
9
+ # page props script (props), each with the CSP nonce when there is one.
10
+ #
11
+ # When the tag is off (disabled, or no site id) and queue_stub is on, it prints
12
+ # the queue stub alone, so privatus.track calls in your own JavaScript do not
13
+ # fail in development. With queue_stub off it prints nothing.
7
14
  class Tag
8
15
  LIST_OPTIONS = { modules: "data-modules", excluded_paths: "data-exclude", domains: "data-domains" }.freeze
16
+ PROPS_FUNCTION = "privatusPageProps"
17
+ QUEUE_STUB = "window.privatus=window.privatus||{q:[]};['track','pageview','props','optOut','optIn','ready']" \
18
+ ".forEach(function(m){window.privatus[m]=window.privatus[m]||function(){window.privatus.q.push(" \
19
+ "[m,Array.prototype.slice.call(arguments)])}})"
9
20
 
21
+ # overrides: any setting (site_id, script_host, enabled and the rest), plus nonce:,
22
+ # request: (passed to a props lambda) and props: (merged over the configured props;
23
+ # a nil value removes one).
10
24
  def initialize(configuration = PrivatusAnalytics.configuration, overrides = {})
11
25
  @configuration = configuration
12
26
  @overrides = overrides.transform_keys(&:to_sym)
13
27
  end
14
28
 
15
29
  def render?
16
- !@configuration.site_id.nil? && @configuration.enabled?
30
+ !site_id.nil? && enabled?
17
31
  end
18
32
 
19
33
  def to_s
20
- return "" unless render?
34
+ html = []
35
+ html << inline_script(QUEUE_STUB) if option(:queue_stub) == true
36
+ return html.join("\n") unless render?
21
37
 
22
- parts = attributes.map { |name, value| value == true ? name : %(#{name}="#{CGI.escapeHTML(value.to_s)}") }
23
- "<script #{parts.join(' ')}></script>"
38
+ props_json = PageProps.json(page_props)
39
+ html << inline_script("window.#{PROPS_FUNCTION}=function(){return #{props_json}}") if props_json
40
+ html << script_tag(props_json ? { "data-props" => PROPS_FUNCTION } : {})
41
+ html.join("\n")
24
42
  end
25
43
 
26
44
  def attributes
27
- attrs = { "defer" => true, "src" => "#{@configuration.script_host}/js/pa.js",
28
- "data-site" => @configuration.site_id }
45
+ attrs = { "defer" => true, "src" => "#{script_host}/js/pa.js", "data-site" => site_id }
29
46
  add_options(attrs)
30
- @configuration.extra_attributes.each { |name, value| attrs[name.to_s] = value unless value.nil? }
47
+ (option(:extra_attributes) || {}).each { |name, value| attrs[name.to_s] = value unless value.nil? }
31
48
  attrs["nonce"] = @overrides[:nonce] if @overrides[:nonce]
32
49
  attrs
33
50
  end
34
51
 
52
+ # Configured props for this request with the per call props merged on top.
53
+ def page_props
54
+ props = keyed(@configuration.page_props(@overrides[:request]))
55
+ props.merge(keyed(@overrides[:props]))
56
+ end
57
+
35
58
  private
36
59
 
60
+ # A per call site_id, script_host or enabled wins over the configuration.
61
+ def site_id
62
+ value = @overrides[:site_id].to_s.strip
63
+ value.empty? ? @configuration.site_id : value
64
+ end
65
+
66
+ def script_host
67
+ value = @overrides[:script_host].to_s.strip
68
+ value.empty? ? @configuration.script_host : value.chomp("/")
69
+ end
70
+
71
+ def enabled?
72
+ return @overrides[:enabled] ? true : false unless @overrides[:enabled].nil?
73
+
74
+ @configuration.enabled?
75
+ end
76
+
77
+ def script_tag(more)
78
+ parts = attributes.merge(more).map do |name, value|
79
+ value == true ? name : %(#{name}="#{CGI.escapeHTML(value.to_s)}")
80
+ end
81
+ "<script #{parts.join(' ')}></script>"
82
+ end
83
+
84
+ def inline_script(code)
85
+ nonce = @overrides[:nonce]
86
+ opening = nonce ? %(<script nonce="#{CGI.escapeHTML(nonce.to_s)}">) : "<script>"
87
+ "#{opening}#{code}</script>"
88
+ end
89
+
90
+ def keyed(props)
91
+ (props || {}).to_h.transform_keys { |key| Attributes.normalize_key(key) }
92
+ end
93
+
37
94
  def add_options(attrs)
38
95
  LIST_OPTIONS.each do |name, attribute|
39
96
  values = list(option(name))
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PrivatusAnalytics
4
- VERSION = "1.0.0"
4
+ VERSION = "1.1.0"
5
5
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "privatus_analytics/version"
4
4
  require "privatus_analytics/configuration"
5
+ require "privatus_analytics/attributes"
6
+ require "privatus_analytics/page_props"
5
7
  require "privatus_analytics/tag"
6
8
  require "privatus_analytics/middleware"
7
9
  require "privatus_analytics/helper"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: privatus-analytics-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Two Phase LLC
@@ -63,9 +63,11 @@ files:
63
63
  - README.md
64
64
  - lib/privatus-analytics-rails.rb
65
65
  - lib/privatus_analytics.rb
66
+ - lib/privatus_analytics/attributes.rb
66
67
  - lib/privatus_analytics/configuration.rb
67
68
  - lib/privatus_analytics/helper.rb
68
69
  - lib/privatus_analytics/middleware.rb
70
+ - lib/privatus_analytics/page_props.rb
69
71
  - lib/privatus_analytics/railtie.rb
70
72
  - lib/privatus_analytics/tag.rb
71
73
  - lib/privatus_analytics/version.rb