runlight 0.1.6 → 0.1.8

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.
@@ -97,17 +97,32 @@ module Runlight
97
97
 
98
98
  # Keeps SQLite's planner statistics current, which it never gathers by itself. Without them it can
99
99
  # choose a plan that reads a table once for every row of another. A sample of each index is enough,
100
- # so this takes milliseconds even on a large database. Postgres gathers its own.
101
- def optimize(only_when_missing = false)
100
+ # so this takes a few seconds at most even on a large database. Postgres gathers its own.
101
+ #
102
+ # With only_when_stale, only when there are none, or the events have more than doubled since they were
103
+ # gathered. Statistics gathered on a new, empty database say every table is empty, and a goal over a
104
+ # million visits then took a minute rather than milliseconds.
105
+ def optimize(only_when_stale = false)
102
106
  return if @db.dialect != "sqlite"
103
107
 
104
108
  begin
105
- return if only_when_missing && !@db.all("SELECT name FROM sqlite_master WHERE name = 'sqlite_stat1'").empty?
106
-
107
- @db.run("PRAGMA analysis_limit = 1000")
108
- @db.run("ANALYZE")
109
+ events = Sql.num(@db.all("SELECT MAX(id) AS n FROM rl_events")[0]&.fetch("n", nil))
110
+ if only_when_stale
111
+ seen = @db.all("SELECT value FROM rl_meta WHERE \"key\" = 'statistics_events'")[0]
112
+ then_events = seen.nil? ? nil : Js.number(seen["value"])
113
+ gathered = !@db.all("SELECT name FROM sqlite_master WHERE name = 'sqlite_stat1'").empty?
114
+ return if gathered && !then_events.nil? && then_events.finite? && !(events > 2 * then_events && events - then_events >= 1000)
115
+ end
116
+ begin
117
+ @db.run("PRAGMA analysis_limit = 1000")
118
+ @db.run("ANALYZE")
119
+ rescue StandardError
120
+ # Some hosted SQLite services refuse these, and gather statistics themselves.
121
+ end
122
+ # Noted even when refused, so a service that refuses is not asked again until the events double.
123
+ @db.run(Sql.upsert(@db.dialect, "rl_meta", ['"key"', "value"], ['"key"'], ["value"]), ["statistics_events", events.to_s])
109
124
  rescue StandardError
110
- # Some hosted SQLite services refuse these, and gather statistics themselves.
125
+ # Statistics only help; a failure here never stops the app.
111
126
  end
112
127
  end
113
128
 
@@ -241,6 +256,14 @@ module Runlight
241
256
  window = [site, start, finish + Sql::EVENT_TAIL_MS, start, finish]
242
257
  transaction do |store|
243
258
  db = store.db
259
+ # MySQL keys a day's rows by no unique key, since a path is longer than its keys allow, so two processes
260
+ # building one day could each add its rows. There the day is marked first: the second waits on that
261
+ # mark until the first commits, then fails, and the day counts as built elsewhere. Elsewhere the rows'
262
+ # own key refuses a second set, and a database without transactions (D1) must not mark a day first.
263
+ if dialect == "mysql"
264
+ db.run("DELETE FROM rl_rollup_days WHERE site = ? AND day = ?", [site, day])
265
+ db.run("INSERT INTO rl_rollup_days (site, day, start_at, end_at) VALUES (?, ?, ?, ?)", [site, day, start, finish])
266
+ end
244
267
  db.run("DELETE FROM rl_rollups WHERE site = ? AND day = ?", [site, day])
245
268
  # The WITH goes after INSERT INTO, the one place every database takes it.
246
269
  params = [site, start, finish]
@@ -268,8 +291,10 @@ module Runlight
268
291
  SELECT ?, ?, 'event', e.name, COUNT(DISTINCT e.visitor), COUNT(*) #{of_day.call("event")} GROUP BY e.name",
269
292
  [site, day, *window],
270
293
  )
271
- db.run("DELETE FROM rl_rollup_days WHERE site = ? AND day = ?", [site, day])
272
- db.run("INSERT INTO rl_rollup_days (site, day, start_at, end_at) VALUES (?, ?, ?, ?)", [site, day, start, finish])
294
+ if dialect != "mysql"
295
+ db.run("DELETE FROM rl_rollup_days WHERE site = ? AND day = ?", [site, day])
296
+ db.run("INSERT INTO rl_rollup_days (site, day, start_at, end_at) VALUES (?, ?, ?, ?)", [site, day, start, finish])
297
+ end
273
298
  end
274
299
  end
275
300
 
@@ -308,7 +333,13 @@ module Runlight
308
333
 
309
334
  # When the site last recorded a visit, or nil if it never has.
310
335
  def last_seen(site)
311
- row = @db.all("SELECT MAX(ts) AS t FROM rl_events WHERE site = ? AND kind IN ('pageview', 'event')", [site])[0]
336
+ # One MAX for each kind, which every database reads from the end of its (site, kind, ts) index. Asked for
337
+ # both kinds at once, MySQL read every pageview and event of the site: 2 s on a million visits.
338
+ row = @db.all(
339
+ "SELECT MAX(t) AS t FROM (SELECT MAX(ts) AS t FROM rl_events WHERE site = ? AND kind = 'pageview'
340
+ UNION ALL SELECT MAX(ts) AS t FROM rl_events WHERE site = ? AND kind = 'event') x",
341
+ [site, site],
342
+ )[0]
312
343
  row.nil? || row["t"].nil? ? nil : Sql.num(row["t"])
313
344
  end
314
345
 
@@ -337,9 +368,21 @@ module Runlight
337
368
  rows[0].nil? || rows[0]["salt"].nil? ? nil : Sql.string(rows[0]["salt"])
338
369
  end
339
370
 
340
- # Deletes every salt older than `day`, so old hashes can never be recomputed.
341
- def drop_salts_before(day)
342
- @db.run("DELETE FROM rl_salts WHERE day < ?", [day])
371
+ # Deletes every salt but those of these days, so the hashes made with them can never be made again.
372
+ def drop_salts_except(days)
373
+ @db.run("DELETE FROM rl_salts WHERE day NOT IN (#{(["?"] * days.length).join(", ")})", days)
374
+ end
375
+
376
+ # Whether a visit to one of these sites that started in a stretch of time was still going at `since`.
377
+ def visit_open(sites, from, to, since)
378
+ # As many sites a statement as keep it under D1's 100.
379
+ open = Sql.in_pieces(sites, Sql::MAX_PARAMS - 3) do |piece|
380
+ @db.all(
381
+ "SELECT 1 AS open FROM rl_sessions WHERE site IN (#{(["?"] * piece.length).join(", ")}) AND started_at >= ? AND started_at < ? AND last_at >= ? LIMIT 1",
382
+ [*piece, from, to, since],
383
+ )
384
+ end
385
+ !open.empty?
343
386
  end
344
387
 
345
388
  # Ingest
@@ -356,17 +399,35 @@ module Runlight
356
399
  rows[0].nil? ? nil : { "id" => Sql.string(rows[0]["id"]), "visitor" => Sql.string(rows[0]["visitor"]) }
357
400
  end
358
401
 
402
+ # After opening a visit: the visitor's earliest visit still going, which is this one unless another
403
+ # process opened one for the same visitor at the same moment. Then both settle on the earliest, and the
404
+ # one that is not drops its own, before anything is counted into it.
405
+ def settle_open_session(site, visitors, since, opened)
406
+ rows = @db.all(
407
+ # One already counting a hit first: the other process went on with it.
408
+ "SELECT id, visitor FROM rl_sessions WHERE site = ? AND visitor IN (#{(["?"] * visitors.length).join(", ")}) AND last_at >= ?
409
+ ORDER BY CASE WHEN pageviews > 0 OR events > 0 THEN 0 ELSE 1 END, started_at, id LIMIT 1",
410
+ [site, *visitors, since],
411
+ )
412
+ first = rows[0] || opened
413
+ @db.run("DELETE FROM rl_sessions WHERE id = ? AND pageviews = 0 AND events = 0", [opened["id"]]) if first["id"] != opened["id"]
414
+ { "id" => Sql.string(first["id"]), "visitor" => Sql.string(first["visitor"]) }
415
+ end
416
+
359
417
  def insert_session(row)
418
+ c = Sql.method(:clip)
360
419
  @db.run(
361
420
  "INSERT INTO rl_sessions (id, site, visitor, started_at, last_at, engaged_ms, hostname, referrer_host, referrer_path,
362
421
  source, channel, utm_source, utm_medium, utm_campaign, utm_term, utm_content, country, region, city,
363
422
  browser, browser_version, os, os_version, device, screen, language)
364
423
  VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
365
424
  [
366
- row["id"], row["site"], row["visitor"], row["startedAt"], row["startedAt"], row["hostname"], row["referrerHost"], row["referrerPath"],
367
- row["source"], row["channel"], row["utmSource"], row["utmMedium"], row["utmCampaign"], row["utmTerm"], row["utmContent"],
368
- row["country"], row["region"], row["city"], row["browser"], row["browserVersion"], row["os"], row["osVersion"], row["device"],
369
- row["screen"], row["language"],
425
+ # Each text cut to its column's width, as MySQL's columns would hold it, so every database keeps the
426
+ # same, and no header, however long, can make a row Postgres's indexes refuse.
427
+ row["id"], row["site"], row["visitor"], row["startedAt"], row["startedAt"], c.(row["hostname"], 255), c.(row["referrerHost"], 255), c.(row["referrerPath"], 500),
428
+ c.(row["source"], 200), c.(row["channel"], 100), c.(row["utmSource"], 200), c.(row["utmMedium"], 200), c.(row["utmCampaign"], 200), c.(row["utmTerm"], 200), c.(row["utmContent"], 200),
429
+ c.(row["country"], 16), c.(row["region"], 100), c.(row["city"], 100), c.(row["browser"], 100), c.(row["browserVersion"], 100), c.(row["os"], 100), c.(row["osVersion"], 100), c.(row["device"], 50),
430
+ c.(row["screen"], 50), c.(row["language"], 50),
370
431
  ],
371
432
  )
372
433
  end
@@ -380,7 +441,7 @@ module Runlight
380
441
  @db.run(
381
442
  "UPDATE rl_sessions SET pageviews = pageviews + 1, last_at = ?, exit_path = ?,
382
443
  entry_path = CASE WHEN entry_path = '' THEN ? ELSE entry_path END WHERE id = ?",
383
- [ts, path, path, id],
444
+ [ts, Sql.clip(path, 1000), Sql.clip(path, 1000), id],
384
445
  )
385
446
  elsif reopen
386
447
  @db.run("UPDATE rl_sessions SET events = events + 1, last_at = ? WHERE id = ?", [ts, id])
@@ -425,8 +486,8 @@ module Runlight
425
486
  "INSERT INTO rl_events (site, ts, kind, visitor, session, pageview, path, hostname, title, name, props, engaged_ms, scroll, link)
426
487
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
427
488
  [
428
- row["site"], row["ts"], row["kind"], row["visitor"], row["session"], row["pageview"], row["path"], row["hostname"], row["title"],
429
- row["name"], props.nil? ? nil : Json.encode(props), row["engagedMs"], row["scroll"], row["link"],
489
+ row["site"], row["ts"], row["kind"], row["visitor"], row["session"], row["pageview"], Sql.clip(row["path"], 1000), Sql.clip(row["hostname"], 255), Sql.clip(row["title"], 500),
490
+ Sql.clip(row["name"], 255), props.nil? ? nil : Json.encode(props), row["engagedMs"], row["scroll"], row["link"],
430
491
  ],
431
492
  )
432
493
  end
@@ -735,21 +796,40 @@ module Runlight
735
796
  end
736
797
 
737
798
  def save_goal(g, before = nil)
799
+ write = lambda do |db|
800
+ db.run(
801
+ Sql.upsert(
802
+ db.dialect,
803
+ "rl_goals",
804
+ ["id", "site", "name", "kind", '"match"', "click_by", "value_mode", "value", "value_prop", "currency", "created_at"],
805
+ ["id"],
806
+ ["name", "kind", '"match"', "click_by", "value_mode", "value", "value_prop", "currency"],
807
+ ),
808
+ [g["id"], g["site"], g["name"], g["kind"], g["match"], g["clickBy"], g["valueMode"], g["value"], g["valueProp"], g["currency"], g["createdAt"]],
809
+ )
810
+ end
738
811
  # A click goal is counted by its name, which the tracker sends as the event
739
812
  # name. Renaming one renames its past clicks too, so its history stays.
740
- if !before.nil? && before["kind"] == "click" && g["kind"] == "click" && before["name"] != g["name"]
741
- @db.run("UPDATE rl_events SET name = ? WHERE site = ? AND kind = 'event' AND name = ?", [g["name"], g["site"], before["name"]])
813
+ return write.call(@db) unless !before.nil? && before["kind"] == "click" && g["kind"] == "click" && before["name"] != g["name"]
814
+
815
+ transaction do |store|
816
+ db = store.db
817
+ db.run("UPDATE rl_events SET name = ? WHERE site = ? AND kind = 'event' AND name = ?", [g["name"], g["site"], before["name"]])
818
+ # The daily rollups count events by name too, and goals read them, so they take the new name as well.
819
+ # A day that already has events under the new name counts both together now, so it is built again.
820
+ both = db.all(
821
+ "SELECT day FROM rl_rollups WHERE site = ? AND dim = 'event' AND value = ?
822
+ AND day IN (SELECT day FROM rl_rollups WHERE site = ? AND dim = 'event' AND value = ?)",
823
+ [g["site"], before["name"], g["site"], g["name"]],
824
+ ).map { |r| Sql.string(r["day"]) }
825
+ both.each do |day|
826
+ db.run("DELETE FROM rl_rollup_days WHERE site = ? AND day = ?", [g["site"], day])
827
+ db.run("DELETE FROM rl_rollups WHERE site = ? AND day = ?", [g["site"], day])
828
+ end
829
+ db.run("UPDATE rl_rollups SET value = ? WHERE site = ? AND dim = 'event' AND value = ?", [g["name"], g["site"], before["name"]])
830
+ write.call(db)
742
831
  end
743
- @db.run(
744
- Sql.upsert(
745
- @db.dialect,
746
- "rl_goals",
747
- ["id", "site", "name", "kind", '"match"', "click_by", "value_mode", "value", "value_prop", "currency", "created_at"],
748
- ["id"],
749
- ["name", "kind", '"match"', "click_by", "value_mode", "value", "value_prop", "currency"],
750
- ),
751
- [g["id"], g["site"], g["name"], g["kind"], g["match"], g["clickBy"], g["valueMode"], g["value"], g["valueProp"], g["currency"], g["createdAt"]],
752
- )
832
+ nil
753
833
  end
754
834
 
755
835
  def delete_goal(id)
@@ -809,7 +889,50 @@ module Runlight
809
889
  # per goal: each goal adds a conditional count, distinct count, and sum.
810
890
  # Keyed by goal id: {"conversions", "visitors", "revenue"}.
811
891
  def goal_totals_all(query, goals)
892
+ # A goal the daily rollups count exactly comes from the built days and the visits of the rest: a page
893
+ # by its whole path, or an event or click by name, worth nothing or a fixed amount. Visitor ids last a
894
+ # day, so each day's visitors add up, as they do for every other report. A pattern or a value sent with
895
+ # each event needs the events themselves.
896
+ rollable = ->(goal) { goal["valueMode"] != "prop" && !(goal["kind"] == "page" && goal["match"].include?("*")) }
897
+ plan = goals.any?(&rollable) ? rollup_plan(query, query["from"], query["to"]) : nil
898
+ return raw_goal_totals_all(query, goals) if plan.nil? || plan["days"].empty?
899
+
900
+ rolled = goals.select(&rollable)
901
+ out = raw_goal_totals_all(query, goals.reject(&rollable))
902
+ counted = {}
903
+ [
904
+ ["page", "pageviews", rolled.select { |g| g["kind"] == "page" }.map { |g| g["match"] }],
905
+ ["event", "events", rolled.reject { |g| g["kind"] == "page" }.map { |g| g["kind"] == "click" ? g["name"] : g["match"] }],
906
+ ].each do |dim, column, values|
907
+ rows = Sql.in_pieces(values.uniq, Sql::VALUES_PER_QUERY) do |piece|
908
+ @db.all(
909
+ "SELECT value, SUM(visitors) AS visitors, SUM(#{column}) AS n FROM rl_rollups
910
+ WHERE site = ? AND dim = '#{dim}' AND day IN (#{Sql::BUILT_DAYS}) AND value IN (#{piece.map { "?" }.join(", ")}) GROUP BY value",
911
+ [query["site"], query["site"], query["from"], query["to"], *piece],
912
+ )
913
+ end
914
+ rows.each { |row| counted["#{dim}:#{Sql.string(row["value"])}"] = { "visitors" => Sql.num(row["visitors"]), "n" => Sql.num(row["n"]) } }
915
+ end
916
+ rest = plan["rest"].map { |from, to| raw_goal_totals_all(query.merge("from" => from, "to" => to), rolled) }
917
+ rolled.each do |goal|
918
+ key = goal["kind"] == "page" ? "page:#{goal["match"]}" : "event:#{goal["kind"] == "click" ? goal["name"] : goal["match"]}"
919
+ sum = { "conversions" => counted.dig(key, "n") || 0, "visitors" => counted.dig(key, "visitors") || 0 }
920
+ rest.each do |part|
921
+ sum["visitors"] += part[goal["id"]]["visitors"]
922
+ sum["conversions"] += part[goal["id"]]["conversions"]
923
+ end
924
+ revenue = goal["valueMode"] == "fixed" ? cents(sum["conversions"] * goal["value"]) : 0
925
+ out[goal["id"]] = sum.merge("revenue" => revenue)
926
+ end
927
+ # In the order the goals came in, as the single pass answers.
928
+ goals.to_h { |goal| [goal["id"], out[goal["id"]]] }
929
+ end
930
+
931
+ # Every goal's totals read from the events of the range's visits.
932
+ def raw_goal_totals_all(query, goals)
812
933
  out = {}
934
+ return out if goals.empty?
935
+
813
936
  v = Sql.visit_rows(query["filters"], query["site"], query["from"], query["to"], @db.dialect)
814
937
  # As many goals per query as keep it under D1's parameter limit.
815
938
  chunks = [[]]
@@ -1003,9 +1126,14 @@ module Runlight
1003
1126
 
1004
1127
  # Just the visitor count from stats(), in one query, for conversion rates.
1005
1128
  def visitors(query)
1129
+ # Built days add up their visitors, as the stats do.
1130
+ rolled = rolled_stats(query)
1131
+ return rolled["visitors"] if rolled
1132
+
1006
1133
  scope = Sql.visit_scope(query["filters"], query["site"], query["from"], query["to"], @db.dialect)
1134
+ table = @db.dialect == "sqlite" && query["filters"].empty? ? "rl_sessions s INDEXED BY rl_sessions_visit_visitors" : "rl_sessions s"
1007
1135
  row = @db.all(
1008
- "SELECT COUNT(DISTINCT s.visitor) AS visitors FROM rl_sessions s WHERE s.site = ? AND s.started_at >= ? AND s.started_at < ? AND #{Sql::IS_VISIT}#{scope["sql"]}",
1136
+ "SELECT COUNT(DISTINCT s.visitor) AS visitors FROM #{table} WHERE s.site = ? AND s.started_at >= ? AND s.started_at < ? AND #{Sql::IS_VISIT}#{scope["sql"]}",
1009
1137
  [query["site"], query["from"], query["to"], *scope["params"]],
1010
1138
  )[0] || {}
1011
1139
  Sql.num(row["visitors"])
@@ -1114,6 +1242,29 @@ module Runlight
1114
1242
  end
1115
1243
  end
1116
1244
 
1245
+ # Seek one matching event per hostname, skipping its remaining events before the next seek.
1246
+ def hostname_suggestions(query, limit)
1247
+ dialect = @db.dialect
1248
+ v = Sql.visit_rows(query["filters"], query["site"], query["from"], query["to"], dialect)
1249
+ within = Sql.row_scope(query["filters"], %w[page hostname], dialect)
1250
+ table = dialect == "sqlite" ? "rl_events e INDEXED BY rl_events_hostname_suggest" : "rl_events e"
1251
+ rows = []
1252
+ after = nil
1253
+ while rows.length < limit
1254
+ found = @db.all(
1255
+ "SELECT e.hostname AS value FROM #{table} CROSS JOIN rl_sessions s
1256
+ WHERE #{v["sql"]} AND e.kind = 'pageview'#{within["sql"]}#{after.nil? ? "" : " AND e.hostname#{text_order} > ?"}
1257
+ ORDER BY e.hostname#{text_order} LIMIT 1",
1258
+ [*v["params"], *within["params"], *(after.nil? ? [] : [after])],
1259
+ )
1260
+ break if found.empty?
1261
+
1262
+ after = Sql.string(found[0]["value"])
1263
+ rows << { "value" => after }
1264
+ end
1265
+ rows
1266
+ end
1267
+
1117
1268
  # BreakdownRow list.
1118
1269
  def breakdown(query, dimension, limit, offset)
1119
1270
  page = [limit, offset]
@@ -1289,8 +1440,9 @@ module Runlight
1289
1440
  [site, since],
1290
1441
  )[0] || {}
1291
1442
  pages = @db.all(
1292
- "SELECT path AS value, COUNT(DISTINCT visitor) AS visitors FROM rl_events
1293
- WHERE site = ? AND ts >= ? AND kind = 'pageview' GROUP BY path ORDER BY visitors DESC, path#{text_order} LIMIT 10",
1443
+ # Through the kind and time index: SQLite otherwise reads the site's whole history by path to group it.
1444
+ "SELECT e.path AS value, COUNT(DISTINCT e.visitor) AS visitors FROM #{Sql.event_kind_table(@db.dialect)}
1445
+ WHERE e.site = ? AND e.ts >= ? AND e.kind = 'pageview' GROUP BY e.path ORDER BY visitors DESC, e.path#{text_order} LIMIT 10",
1294
1446
  [site, since],
1295
1447
  )
1296
1448
  sources = @db.all(
@@ -4,7 +4,7 @@ require "json"
4
4
 
5
5
  module Runlight
6
6
  # The gem's own version, which the release keeps equal to the npm package's.
7
- GEM_VERSION = "0.1.6"
7
+ GEM_VERSION = "0.1.8"
8
8
 
9
9
  # The SDK's version and the HTTP API's, read from assets/build.json, which
10
10
  # scripts/ruby-assets.mts writes from the TypeScript SDK, so the two always
data/lib/runlight/zip.rb CHANGED
@@ -42,7 +42,9 @@ module Runlight
42
42
  values.map do |v|
43
43
  s = v.nil? || v.equal?(UNDEFINED) ? "" : Js.string(v)
44
44
  s = "'#{s}" if s.match?(/\A[=+\-@\t\r]/) && !s.match?(/\A-?\d+(\.\d+)?\z/)
45
- s.match?(/[",\n\r]/) ? "\"#{s.gsub('"', '""')}\"" : s
45
+ # Quoted with a semicolon or tab in it too: Excel in a locale that separates lists with ";" splits an
46
+ # unquoted cell there, and what follows could start with = and run as a formula.
47
+ s.match?(/[",;\t\n\r]/) ? "\"#{s.gsub('"', '""')}\"" : s
46
48
  end.join(",")
47
49
  end
48
50
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: runlight
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.6
4
+ version: 0.1.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jon C. Phillips
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-14 00:00:00.000000000 Z
11
+ date: 2026-10-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord