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.
- checksums.yaml +4 -4
- data/assets/build.json +4 -4
- data/assets/dashboard.css +1 -1
- data/assets/dashboard.js +12 -12
- data/assets/intl.json +1 -1
- data/assets/locales.json +5 -5
- data/assets/tracker.js +1 -1
- data/lib/runlight/accounts/accounts.rb +23 -15
- data/lib/runlight/accounts/crypto.rb +41 -13
- data/lib/runlight/accounts/pages.rb +102 -63
- data/lib/runlight/accounts/web.rb +55 -50
- data/lib/runlight/assistant.rb +9 -1
- data/lib/runlight/core.rb +44 -11
- data/lib/runlight/dates.rb +8 -0
- data/lib/runlight/http/fetcher.rb +12 -0
- data/lib/runlight/http/request.rb +3 -0
- data/lib/runlight/http/url.rb +6 -1
- data/lib/runlight/messages.rb +15 -1
- data/lib/runlight/oauth.rb +60 -41
- data/lib/runlight/routes.rb +47 -29
- data/lib/runlight/server/config.rb +1 -1
- data/lib/runlight/server/standalone.rb +3 -8
- data/lib/runlight/store/sql.rb +13 -0
- data/lib/runlight/store/sql_store.rb +187 -35
- data/lib/runlight/version.rb +1 -1
- data/lib/runlight/zip.rb +3 -1
- metadata +2 -2
|
@@ -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
|
|
101
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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
|
|
341
|
-
def
|
|
342
|
-
@db.run("DELETE FROM rl_salts WHERE 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
|
-
|
|
367
|
-
|
|
368
|
-
row["
|
|
369
|
-
row["
|
|
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
|
-
|
|
741
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1293
|
-
|
|
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(
|
data/lib/runlight/version.rb
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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.
|
|
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-
|
|
11
|
+
date: 2026-10-17 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activerecord
|