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 +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +193 -18
- data/lib/privatus_analytics/attributes.rb +62 -0
- data/lib/privatus_analytics/configuration.rb +18 -0
- data/lib/privatus_analytics/helper.rb +54 -2
- data/lib/privatus_analytics/middleware.rb +14 -3
- data/lib/privatus_analytics/page_props.rb +40 -0
- data/lib/privatus_analytics/tag.rb +64 -7
- data/lib/privatus_analytics/version.rb +1 -1
- data/lib/privatus_analytics.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: 2184204c6532f24f22ae1c68f92000f5f0cc72f8c0ad580809dfa293a60e281d
|
|
4
|
+
data.tar.gz: 217a4fb72fabe001ce49e1bdbe206b652c5c77db4bbf22f18e820705c7f4e73a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
8
|
-
cookies,
|
|
9
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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).
|
|
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
|
|
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
|
-
##
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
30
|
+
!site_id.nil? && enabled?
|
|
17
31
|
end
|
|
18
32
|
|
|
19
33
|
def to_s
|
|
20
|
-
|
|
34
|
+
html = []
|
|
35
|
+
html << inline_script(QUEUE_STUB) if option(:queue_stub) == true
|
|
36
|
+
return html.join("\n") unless render?
|
|
21
37
|
|
|
22
|
-
|
|
23
|
-
"
|
|
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" => "#{
|
|
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
|
-
|
|
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))
|
data/lib/privatus_analytics.rb
CHANGED
|
@@ -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.
|
|
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
|