runlight 0.2.0 → 0.3.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.
@@ -80,22 +80,30 @@ module Runlight
80
80
  return false if g.nil?
81
81
 
82
82
  embedded = ->(hi, lo) { [hi >> 8, hi & 255, lo >> 8, lo & 255] }
83
- zero = ->(groups) { groups.all?(&:zero?) }
84
- # IPv4 inside IPv6: mapped (::ffff:0:0/96), the old compatible form (::/96), and NAT64 (64:ff9b::/96).
85
- if zero.call(g[0, 5]) && (g[5] == 0xffff || g[5].zero?)
86
- return g[5].zero? && g[6].zero? && g[7] <= 1 ? false : public_v4?(embedded.call(g[6], g[7]))
87
- end
88
- return public_v4?(embedded.call(g[6], g[7])) if g[0] == 0x64 && g[1] == 0xff9b && zero.call(g[2, 4])
83
+ # IPv4 inside IPv6: mapped (::ffff:0:0/96), the old compatible form (::/96), translated (::ffff:0:0:0/96),
84
+ # and NAT64 (64:ff9b::/96).
85
+ inside = embedded_v4(g)
86
+ return g[5].zero? && g[4].zero? && g[6].zero? && g[7] <= 1 ? false : public_v4?(inside) unless inside.nil?
89
87
  # 6to4 carries an IPv4 address in its second and third groups.
90
88
  return public_v4?(embedded.call(g[1], g[2])) if g[0] == 0x2002
91
- return false if (g[0] & 0xfe00) == 0xfc00 || (g[0] & 0xffc0) == 0xfe80 || (g[0] & 0xff00) == 0xff00
92
- # Teredo, documentation, and discard prefixes.
93
- return false if g[0] == 0x2001 && (g[1].zero? || g[1] == 0xdb8)
94
- return false if g[0] == 0x100 && zero.call(g[1, 3])
89
+ # Only global unicast (2000::/3) is public, which leaves out unique local, link-local, the old site-local,
90
+ # multicast, discard, and local-use NAT64 (64:ff9b:1::/48), which may carry any address.
91
+ return false if (g[0] & 0xe000) != 0x2000
92
+ # The protocol assignments (2001::/23, with Teredo and ORCHID) and the documentation prefixes.
93
+ return false if g[0] == 0x2001 && (g[1] < 0x200 || g[1] == 0xdb8)
94
+ return false if (g[0] & 0xfff0) == 0x3ff0
95
95
 
96
96
  true
97
97
  end
98
98
 
99
+ # The IPv4 address inside a mapped, compatible, translated, or NAT64 IPv6 address, or nil.
100
+ def embedded_v4(g)
101
+ inside = (g[0, 5].all?(&:zero?) && (g[5] == 0xffff || g[5].zero?)) ||
102
+ (g[0, 4].all?(&:zero?) && g[4] == 0xffff && g[5].zero?) ||
103
+ (g[0] == 0x64 && g[1] == 0xff9b && g[2, 4].all?(&:zero?))
104
+ inside ? [g[6] >> 8, g[6] & 255, g[7] >> 8, g[7] & 255] : nil
105
+ end
106
+
99
107
  # Names clouds give their metadata services.
100
108
  METADATA_NAMES = %w[metadata metadata.google.internal metadata.goog instance-data instance-data.ec2.internal].freeze
101
109
 
@@ -105,10 +113,8 @@ module Runlight
105
113
  def metadata_address?(ip)
106
114
  four = v4(ip)
107
115
  g = four.nil? ? v6(ip) : nil
108
- # IPv4 inside IPv6, mapped (::ffff:0:0/96), compatible (::/96), or NAT64 (64:ff9b::/96), is read as IPv4.
109
- if four.nil? && !g.nil? && ((g[0, 5].all?(&:zero?) && (g[5] == 0xffff || g[5].zero?)) || (g[0] == 0x64 && g[1] == 0xff9b && g[2, 4].all?(&:zero?)))
110
- four = [g[6] >> 8, g[6] & 255, g[7] >> 8, g[7] & 255]
111
- end
116
+ # IPv4 inside IPv6, mapped, compatible, translated, or NAT64, is read as IPv4.
117
+ four = embedded_v4(g) if four.nil? && !g.nil?
112
118
  return (four[0] == 169 && four[1] == 254) || four == [100, 100, 100, 200] unless four.nil?
113
119
  return false if g.nil?
114
120
  return true if (g[0] & 0xffc0) == 0xfe80
@@ -16,7 +16,8 @@ module Runlight
16
16
  # RUNLIGHT_SECRET signs sessions and encrypts saved keys (made and kept in DATA_DIR if unset)
17
17
  # RUNLIGHT_TOKEN also accepted as a bearer token on the API, and makes the first account
18
18
  # RUNLIGHT_URL the dashboard's public address, which can never become a link domain
19
- # TRUST_PROXY "false" when no proxy sits in front, so forwarded addresses are ignored
19
+ # TRUST_PROXY unset believes forwarded addresses only from a private network; "false" never,
20
+ # "true" from anywhere, or the one header a CDN sets, such as cf-connecting-ip
20
21
  # RUNLIGHT_GEO city (the default), country, off, or the path to an MMDB file
21
22
  # CRON_SECRET lets a scheduler run the check over HTTP, at POST /api/check
22
23
  # RUNLIGHT_OBSERVE_KEY one key for every site's AI agent reports
@@ -115,7 +116,7 @@ module Runlight
115
116
  end
116
117
 
117
118
  # false with nothing in front, or the one header your proxy sets, such as cf-connecting-ip behind Cloudflare.
118
- # Unset stays nil, so the library's default applies and it can warn when nothing sits in front.
119
+ # Unset stays nil, so the library's default applies: forwarding headers count only from a private address.
119
120
  def trust_proxy
120
121
  value = (get("TRUST_PROXY") || "").downcase
121
122
  return nil if value == ""
@@ -27,6 +27,8 @@ module Runlight
27
27
 
28
28
  # The most visits journeys reads, newest first.
29
29
  JOURNEY_VISITS = 20_000
30
+ # The most visits a funnel is counted over: the newest in the range, so its rows fit in memory over any range.
31
+ FUNNEL_VISITS = 100_000
30
32
 
31
33
  # How long after a visit starts its events are looked for: far past any real visit.
32
34
  EVENT_TAIL_MS = 2 * 86_400_000
@@ -25,6 +25,7 @@ module Runlight
25
25
  class SqlStore
26
26
  BOUNCE_MS = Sql::BOUNCE_MS
27
27
  JOURNEY_VISITS = Sql::JOURNEY_VISITS
28
+ FUNNEL_VISITS = Sql::FUNNEL_VISITS
28
29
  EVENT_TAIL_MS = Sql::EVENT_TAIL_MS
29
30
  MYSQL_COLLATION = Sql::MYSQL_COLLATION
30
31
 
@@ -34,6 +35,8 @@ module Runlight
34
35
  attr_reader :db,
35
36
  # What the store's table and index names start with: `rl_` unless it was given another.
36
37
  :prefix
38
+ # The most visits a funnel is counted over; smaller in tests, to try the cap.
39
+ attr_accessor :funnel_visits
37
40
 
38
41
  # `prefix` takes the place of `rl_` at the start of every table and index name, so two apps can each keep
39
42
  # Runlight's tables in one database. A database that is already prefixed keeps its own.
@@ -45,6 +48,7 @@ module Runlight
45
48
  end
46
49
  @db = db
47
50
  @prefix = prefix
51
+ @funnel_visits = FUNNEL_VISITS
48
52
  @ready = false
49
53
  @checked_all = false
50
54
  end
@@ -575,7 +579,18 @@ module Runlight
575
579
  # How many visits reached each step, in order, within the same visit. Step
576
580
  # one is the first matching row in the range; each later step must come
577
581
  # after the step before it. Filters choose which visits enter the funnel.
582
+ # {"counts" => Array<Integer>, "sampled" => bool}
578
583
  def funnel_counts(query, funnel)
584
+ # Past funnel_visits visits in the range, only the newest are counted: the range starts where the oldest
585
+ # of them began.
586
+ scope = Sql.visit_scope(query["filters"], query["site"], query["from"], query["to"], @db.dialect)
587
+ first = @db.all(
588
+ "SELECT COUNT(*) AS n, MIN(started_at) AS t FROM (SELECT s.started_at AS started_at FROM rl_sessions s WHERE s.site = ? AND s.started_at >= ? AND s.started_at < ? AND #{Sql::IS_VISIT}#{scope["sql"]}
589
+ ORDER BY s.started_at DESC, s.id LIMIT #{@funnel_visits + 1}) x",
590
+ [query["site"], query["from"], query["to"], *scope["params"]],
591
+ )[0] || {}
592
+ sampled = Sql.num(first["n"]) > @funnel_visits
593
+ query = query.merge("from" => [query["from"], Sql.num(first["t"]) + 1].max) if sampled
579
594
  # The rows of the picked visits that match any step, in order, read once and walked here: a join from
580
595
  # each step to the next is planned badly by Postgres, which cannot guess how many visits go on.
581
596
  v = Sql.visit_rows(query["filters"], query["site"], query["from"], query["to"], @db.dialect)
@@ -611,7 +626,7 @@ module Runlight
611
626
  reached += 1 if reached < counts.length && Sql.num(row["m#{reached}"]) == 1
612
627
  end
613
628
  close.call
614
- counts
629
+ { "counts" => counts, "sampled" => sampled }
615
630
  end
616
631
 
617
632
  # Each visit's pageviews in order, at most `per_visit` of them, for journeys.
@@ -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.2.0"
7
+ GEM_VERSION = "0.3.0"
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
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.2.0
4
+ version: 0.3.0
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-18 00:00:00.000000000 Z
11
+ date: 2026-10-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord