openship 0.4.8 → 0.6.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.
Files changed (112) hide show
  1. package/README.md +27 -1
  2. package/dist/bare-FSUAM5V5.js +23 -0
  3. package/dist/{bowser-O3AEA6AN.js → bowser-HNOTPGJO.js} +2 -2
  4. package/dist/{chunk-5MZ624VX.js → chunk-2GWYYHOR.js} +1 -1
  5. package/dist/chunk-3FYIXVX5.js +8051 -0
  6. package/dist/{chunk-ILFETZOM.js → chunk-3LDWWY7U.js} +21 -1
  7. package/dist/{chunk-W73X43R7.js → chunk-3NQQXMDR.js} +3 -3
  8. package/dist/{chunk-OWED2GRM.js → chunk-42WQABXM.js} +2 -2
  9. package/dist/{chunk-TYVME3XD.js → chunk-6FH4AXVY.js} +20 -3
  10. package/dist/{chunk-OXWDY22G.js → chunk-6ODUBKUB.js} +3 -3
  11. package/dist/{chunk-DYPH4HCI.js → chunk-AO252K7Z.js} +1 -1
  12. package/dist/{chunk-XPPZ7B2J.js → chunk-B3CE2OMR.js} +14 -12
  13. package/dist/{chunk-IXCHURVK.js → chunk-DOD2BXHQ.js} +4 -4
  14. package/dist/{chunk-VWPEKOH6.js → chunk-DRK3MEDS.js} +3 -3
  15. package/dist/{chunk-V6AN6XLZ.js → chunk-EMXI4UCU.js} +125 -47
  16. package/dist/{chunk-GKHUSZCD.js → chunk-EP2TNALB.js} +2 -2
  17. package/dist/{chunk-SLTYVHU7.js → chunk-GUQ3QCJ7.js} +942 -519
  18. package/dist/chunk-I3DPMTSF.js +29 -0
  19. package/dist/{chunk-L2VOGB2X.js → chunk-IW2UKBVF.js} +3 -3
  20. package/dist/{chunk-VDMQJHPI.js → chunk-IXM5245F.js} +228 -13
  21. package/dist/{chunk-MZ2WEHHY.js → chunk-JIMUYEKN.js} +4 -4
  22. package/dist/chunk-OGY63DGD.js +705 -0
  23. package/dist/{chunk-O6ZZF52V.js → chunk-ONKEYSMY.js} +2 -2
  24. package/dist/{chunk-6APULWSU.js → chunk-OTLSJYAC.js} +1744 -118
  25. package/dist/{chunk-EI575GHN.js → chunk-PDW4L3NV.js} +3 -3
  26. package/dist/{chunk-5NYUCONN.js → chunk-PSZSKEPA.js} +33 -51
  27. package/dist/{chunk-KI2EY3WB.js → chunk-PXKCZB2I.js} +1 -1
  28. package/dist/{chunk-FXTBHTWY.js → chunk-QEH4QUBX.js} +649 -85
  29. package/dist/{chunk-6APIPIJY.js → chunk-QWHBRY2K.js} +5 -5
  30. package/dist/{chunk-6B5YCFFV.js → chunk-RVER2EOO.js} +5 -5
  31. package/dist/{chunk-Q2CIRAOK.js → chunk-RXQBD4ZY.js} +1 -1
  32. package/dist/{chunk-TGIINPVM.js → chunk-TWJCLD4W.js} +60 -10
  33. package/dist/{chunk-4Z5QO7N6.js → chunk-VDDMADOF.js} +3 -3
  34. package/dist/{chunk-BBHX7T4R.js → chunk-VE2YOK4I.js} +991 -254
  35. package/dist/{chunk-2D3I5G6P.js → chunk-VZ2ZHPS2.js} +73 -43
  36. package/dist/{chunk-AVIB7CT7.js → chunk-WNWQBB7F.js} +2 -2
  37. package/dist/{chunk-FRNAR33O.js → chunk-XZYFZ6A5.js} +3 -3
  38. package/dist/{chunk-RPHRPFEH.js → chunk-ZCFVHA25.js} +1 -1
  39. package/dist/{chunk-FPRHYBY2.js → chunk-ZFJXPCQP.js} +1 -1
  40. package/dist/{chunk-JYGD5WSN.js → chunk-ZWLDLTJP.js} +1 -1
  41. package/dist/cloud-E5M6VMCG.js +31 -0
  42. package/dist/{cloud-7QYOGF7F.js → cloud-KRTZ2IL5.js} +3 -3
  43. package/dist/{detect-AZLOHUFX.js → detect-ALF4GXRX.js} +7 -8
  44. package/dist/{dist-IQJ6H6UZ.js → dist-BM6NPWIH.js} +3 -3
  45. package/dist/{dist-es-X3B5QYKB.js → dist-es-2YRDXMUU.js} +6 -6
  46. package/dist/{dist-es-OSNJBLXB.js → dist-es-CCVZEFRZ.js} +5 -5
  47. package/dist/{dist-es-VNKHQVIB.js → dist-es-IOH6G4GM.js} +4 -4
  48. package/dist/{dist-es-RYHIPOTN.js → dist-es-JGBPABJI.js} +14 -14
  49. package/dist/{dist-es-JSTDUS7Q.js → dist-es-KWSMDPIP.js} +6 -6
  50. package/dist/{dist-es-5DY7K32L.js → dist-es-UYDDOVZ3.js} +6 -6
  51. package/dist/{dist-es-WV4AMOII.js → dist-es-XUOR4NQN.js} +8 -8
  52. package/dist/docker-QECQJ5P4.js +32 -0
  53. package/dist/docker-edge-executor-62N64MGS.js +17 -0
  54. package/dist/edge-import-CQWHN2Q3.js +48 -0
  55. package/dist/ensure-container-edge-MJVXJQG5.js +32 -0
  56. package/dist/{event-streams-EHRAKL75.js → event-streams-TR4GAC53.js} +4 -4
  57. package/dist/{executor-XJ6RP4Q2.js → executor-JELARVKC.js} +8 -8
  58. package/dist/index.js +1209 -560
  59. package/dist/{loadSso-46FQRIZS.js → loadSso-UGF4JMRK.js} +10 -10
  60. package/dist/{local-executor-V7U5VOZW.js → local-executor-WOFZD72X.js} +4 -4
  61. package/dist/nginx-AXGNWRKZ.js +31 -0
  62. package/dist/node-entry.js +4 -0
  63. package/dist/{noop-FMMFG2EZ.js → noop-NJ72F46T.js} +3 -3
  64. package/dist/openresty-lua-IE4P5YQX.js +81 -0
  65. package/dist/server/index.js +116248 -94749
  66. package/dist/server/lua/geo_country.lua +31 -6
  67. package/dist/server/lua/maxminddb.lua +403 -0
  68. package/dist/server/lua/mgmt_api.lua +431 -68
  69. package/dist/server/lua/pipe_log.lua +10 -4
  70. package/dist/server/lua/site_logger.lua +220 -12
  71. package/dist/server/migrations/0073_project_volumes_object_storage.sql +3 -0
  72. package/dist/server/migrations/0074_project_compose_path.sql +1 -0
  73. package/dist/server/migrations/0075_service_command_argv.sql +1 -0
  74. package/dist/server/migrations/0076_rollback_retention.sql +17 -0
  75. package/dist/server/migrations/0077_domain_redirect.sql +13 -0
  76. package/dist/server/migrations/0078_grant_source_scope.sql +30 -0
  77. package/dist/server/migrations/0079_project_readiness.sql +18 -0
  78. package/dist/server/migrations/0080_deployment_error_classification.sql +26 -0
  79. package/dist/server/migrations/0081_project_readiness_repair.sql +34 -0
  80. package/dist/server/migrations/0082_edge_target_verification.sql +39 -0
  81. package/dist/server/migrations/0083_service_incident.sql +84 -0
  82. package/dist/server/migrations/0084_analytics_daily_rollup.sql +72 -0
  83. package/dist/server/migrations/0085_resource_usage.sql +68 -0
  84. package/dist/server/migrations/0086_remote_infra_updates.sql +41 -0
  85. package/dist/server/migrations/0087_server_container_version_columns.sql +17 -0
  86. package/dist/server/migrations/0088_audit_source_and_settings.sql +27 -0
  87. package/dist/server/migrations/0089_auto_scan_infra.sql +7 -0
  88. package/dist/server/migrations/0090_project_collect_paths.sql +18 -0
  89. package/dist/server/migrations/0091_project_server_id.sql +26 -0
  90. package/dist/server/migrations/0092_project_internal_alias.sql +13 -0
  91. package/dist/server/migrations/0093_service_incident_org_index.sql +13 -0
  92. package/dist/server/migrations/0094_backup_restore_meta.sql +15 -0
  93. package/dist/server/migrations/0095_backup_restore_cancel.sql +19 -0
  94. package/dist/server/migrations/0096_backup_policy_retention_defaults.sql +20 -0
  95. package/dist/server/migrations/0097_update_status_upstream_only.sql +35 -0
  96. package/dist/server/migrations/meta/_journal.json +175 -0
  97. package/dist/setup-FA4PW4NU.js +21 -0
  98. package/dist/{signin-ITOPKKUB.js → signin-3M3UPB6K.js} +10 -10
  99. package/dist/{sso-oidc-NON2PD7Y.js → sso-oidc-3VDIYOEJ.js} +10 -10
  100. package/dist/{sts-HHFFSAY2.js → sts-B3WIYLXR.js} +9 -9
  101. package/package.json +6 -3
  102. package/dist/bare-JY5Y5ELU.js +0 -23
  103. package/dist/chunk-SJIFLLX4.js +0 -2931
  104. package/dist/chunk-VFINNTUL.js +0 -362
  105. package/dist/cloud-KYWY3RP4.js +0 -30
  106. package/dist/docker-LODK5Y2S.js +0 -16
  107. package/dist/docker-edge-executor-VHW3JPJS.js +0 -13
  108. package/dist/edge-import-ABUDKKJI.js +0 -45
  109. package/dist/ensure-container-edge-SW6HENSG.js +0 -31
  110. package/dist/nginx-3L4SIBJA.js +0 -24
  111. package/dist/openresty-lua-4WLGDSER.js +0 -42
  112. package/dist/setup-NU6T2PDU.js +0 -20
@@ -20,12 +20,51 @@ local function bad(msg)
20
20
  end
21
21
 
22
22
  -- ── GET /health ──────────────────────────────────────────────────────────────
23
+ -- Bare "ok", NOT JSON. probeMgmt() in apps/api/src/lib/project-analytics.ts
24
+ -- asserts `body.trim() === "ok"`, so anything richer here silently reports every
25
+ -- edge as unreachable and stops the analytics scrape. Richer diagnostics go on
26
+ -- /status below.
23
27
 
24
28
  if uri == "/health" then
25
29
  ngx.say("ok")
26
30
  return ngx.exit(200)
27
31
  end
28
32
 
33
+ -- ── GET /status - diagnosable edge health ────────────────────────────────────
34
+ -- Why this exists: geo failing is INVISIBLE otherwise. geo_country.lua pcalls its
35
+ -- FFI binding and returns nil on failure, so a missing libmaxminddb.so or a
36
+ -- missing database presents exactly like "nobody visited from a known country" —
37
+ -- an empty map with no error anywhere. This reports which it is.
38
+ --
39
+ -- Also reports shared-dict headroom: these zones LRU-EVICT when full rather than
40
+ -- erroring, so a full `visitors` zone silently UNDERCOUNTS. Callers surface that
41
+ -- as "approximate" instead of presenting an eviction artifact as a measurement.
42
+
43
+ if uri == "/status" then
44
+ local geo_available, geo_reason, geo_db = false, "geo_country module not loadable", nil
45
+ local geo_ok, geo = pcall(require, "openship.geo_country")
46
+ if geo_ok and geo and geo.status then
47
+ geo_available, geo_reason, geo_db = geo.status()
48
+ end
49
+
50
+ local dicts = {}
51
+ for _, name in ipairs({ "analytics", "request_data", "rules", "rl_counters", "visitors" }) do
52
+ local d = ngx.shared[name]
53
+ if d then
54
+ dicts[name] = {
55
+ capacity = d.capacity and d:capacity() or nil,
56
+ free_space = d.free_space and d:free_space() or nil,
57
+ }
58
+ end
59
+ end
60
+
61
+ return json({
62
+ ok = true,
63
+ geo = { available = geo_available, reason = geo_reason, db = geo_db },
64
+ dicts = dicts,
65
+ })
66
+ end
67
+
29
68
  -- ── /rules - per-route rules cache (written by the API, read by rules_guard) ──
30
69
  -- POST /rules body: { "host": "...", "rules": [ { "pathPrefix": "/", "spec": {...} } ] }
31
70
  -- → replaces the ruleset for that host in the `rules` shared dict (reload-free).
@@ -33,6 +72,68 @@ end
33
72
  -- GET /rules?host=... → the stored ruleset (debug).
34
73
  -- DELETE /rules?host=... → clear the host's ruleset.
35
74
  -- Handled BEFORE the analytics guard: rules use their own dict.
75
+ -- ── /analytics/config - per-host collection switches ─────────────────────────
76
+ --
77
+ -- POST body: { "hosts": [ { "host": "...", "paths": true|false }, ... ] }
78
+ -- GET ?host=... → { host, paths }
79
+ --
80
+ -- A LIST, not a single host: a shared dict is RAM, so a full nginx restart (a reboot, a
81
+ -- `docker restart`, a package upgrade) empties it and every host on the box has to be
82
+ -- re-set at once. `openresty -s reload` preserves the zone — verified — so only a real
83
+ -- restart loses it, which is why the reconcile below exists and why it needs to cost one
84
+ -- round trip per server rather than one per domain.
85
+ --
86
+ -- Lives in the `rules` zone rather than `analytics` for the reason site_logger states:
87
+ -- the analytics zone is a 256 MB churn of counters under LRU pressure, and an evicted
88
+ -- config flag would silently change what is collected. This zone holds a few small values.
89
+ --
90
+ -- Deliberately its own endpoint rather than a field on /rules: the API only pushes /rules
91
+ -- for hosts that HAVE rules, so a project with none would never receive this flag.
92
+ --
93
+ -- No TTL — a shared dict is RAM and is lost on restart either way, so the API re-pushes on
94
+ -- every route apply and the database stays the source of truth.
95
+ if uri == "/analytics/config" then
96
+ local rules = ngx.shared.rules
97
+ if not rules then return json({ error = "rules dict unavailable" }, 503) end
98
+ local method = ngx.req.get_method()
99
+
100
+ if method == "GET" then
101
+ local host = ngx.var.arg_host
102
+ if not host or host == "" then return bad("missing ?host=") end
103
+ host = host:lower()
104
+ return json({ host = host, paths = rules:get("cfg:" .. host .. ":paths") == 1 })
105
+ end
106
+
107
+ if method == "POST" then
108
+ ngx.req.read_body()
109
+ local body = ngx.req.get_body_data()
110
+ if not body then return bad("empty body") end
111
+ local payload = cjson.decode(body)
112
+ if type(payload) ~= "table" or type(payload.hosts) ~= "table" then
113
+ return bad("expected { hosts: [ { host, paths } ] }")
114
+ end
115
+ local applied = 0
116
+ for _, entry in ipairs(payload.hosts) do
117
+ if type(entry) == "table" and entry.host then
118
+ local key = "cfg:" .. tostring(entry.host):lower() .. ":paths"
119
+ if entry.paths == true then
120
+ rules:set(key, 1)
121
+ else
122
+ -- Deleted rather than set to 0, so "absent" is the only off state there
123
+ -- is. Two representations of off is how a reader ends up treating one of
124
+ -- them as on — and absent is also what a restarted box starts from, so
125
+ -- off has to mean exactly the same thing in both cases.
126
+ rules:delete(key)
127
+ end
128
+ applied = applied + 1
129
+ end
130
+ end
131
+ return json({ ok = true, applied = applied })
132
+ end
133
+
134
+ return json({ error = "method not allowed" }, 405)
135
+ end
136
+
36
137
  if uri == "/rules" then
37
138
  local rules = ngx.shared.rules
38
139
  if not rules then return json({ error = "rules dict unavailable" }, 503) end
@@ -79,6 +180,263 @@ if not analytics then
79
180
  return json({ error = "analytics dict unavailable" }, 503)
80
181
  end
81
182
 
183
+ -- ── Per-minute country index ─────────────────────────────────────────────────
184
+ --
185
+ -- site_logger writes one key per (domain, minute, country): `c:{domain}:{min}:{CC}`.
186
+ -- Reading them back needs key DISCOVERY, and `get_keys` is the only mechanism.
187
+ --
188
+ -- This used to sit INSIDE the per-minute loop, so a 24h window ran up to 1440
189
+ -- `get_keys(10000)` calls — each a full scan of a 256 MB zone, each holding the
190
+ -- dict lock against every worker. One scan per request instead, bucketed by
191
+ -- minute in a single pass; the loop then does plain table lookups.
192
+ --
193
+ -- `get_keys(0)` (all keys) rather than 10000: the cap silently TRUNCATED, so a
194
+ -- busy box just lost countries with no error. Unbounded is safe precisely because
195
+ -- this is now called once — see the comment on the call.
196
+ --- Read an enumerable set written by site_logger's index_add.
197
+ --
198
+ -- Replaces `get_keys`, which walked EVERY key in the 256 MB zone holding its lock — cost
199
+ -- proportional to zone population rather than to the answer, on a path called four
200
+ -- different ways. Here the counter says how many members exist and the slots name them, so
201
+ -- a read costs exactly as much as the data it returns.
202
+ --
203
+ -- Over-reads slightly by design: a slot can be written twice if two workers race on a
204
+ -- member's first sighting, and slots outlive nothing (same TTL as their counters). Both
205
+ -- collapse harmlessly because the caller keys results by member.
206
+ local function read_index(counter_key, slot_prefix)
207
+ local n = tonumber(analytics:get(counter_key)) or 0
208
+ if n <= 0 then return {} end
209
+ local out, seen = {}, {}
210
+ for i = 1, n do
211
+ local m = analytics:get(slot_prefix .. i)
212
+ if m and not seen[m] then
213
+ seen[m] = true
214
+ out[#out + 1] = m
215
+ end
216
+ end
217
+ return out
218
+ end
219
+
220
+ --- Drain a counter WITHOUT losing a concurrent increment.
221
+ --
222
+ -- Was `get` then `delete`: a request landing between the two had its count deleted along
223
+ -- with the value that had just been read. Subtracting exactly what was read leaves any
224
+ -- concurrent increment behind as a residual the next scrape collects.
225
+ local function drain(key)
226
+ local v = tonumber(analytics:get(key)) or 0
227
+ if v ~= 0 then analytics:incr(key, -v) end
228
+ return v
229
+ end
230
+
231
+ --- One day's rollup for one domain, read through its indexes.
232
+ --
233
+ -- Returns the same `suffix → value` shape the old whole-zone classifier produced, so both
234
+ -- callers stayed unchanged: bare country codes, `s:{code}`, `p:{path}`, `v`, `pn`.
235
+ local function read_rollup(domain, day)
236
+ local gpfx = "g:" .. domain .. ":" .. day .. ":"
237
+ local out = {}
238
+
239
+ for _, cc in ipairs(read_index(gpfx .. "cn", gpfx .. "ci:")) do
240
+ out[cc] = tonumber(analytics:get(gpfx .. cc)) or 0
241
+ end
242
+ for _, code in ipairs(read_index(gpfx .. "sn", gpfx .. "si:")) do
243
+ out["s:" .. code] = tonumber(analytics:get(gpfx .. "s:" .. code)) or 0
244
+ end
245
+ -- `pn` doubles as the path index's counter, so paths need no separate one. When path
246
+ -- collection is switched off for this host there is no counter and this reads nothing.
247
+ for _, path in ipairs(read_index(gpfx .. "pn", gpfx .. "pi:")) do
248
+ out["p:" .. path] = tonumber(analytics:get(gpfx .. "p:" .. path)) or 0
249
+ end
250
+ -- The over-cap bucket has a fixed name, so it is not in the index.
251
+ local other = tonumber(analytics:get(gpfx .. "p:other"))
252
+ if other then out["p:other"] = other end
253
+
254
+ local v = tonumber(analytics:get(gpfx .. "v"))
255
+ if v then out["v"] = v end
256
+ return out
257
+ end
258
+
259
+ local function scan_country_index(domain, from_m, to_m)
260
+ -- Walks the requested MINUTES and reads each one's own index. Bounded by the window
261
+ -- (capped at 1440 by the caller), not by how much is in the zone.
262
+ local by_minute = {}
263
+ for m = from_m, to_m do
264
+ local mpfx = "c:" .. domain .. ":" .. m .. ":"
265
+ local ccs = read_index(mpfx .. "n", mpfx .. "i:")
266
+ if #ccs > 0 then
267
+ local slot = {}
268
+ for i = 1, #ccs do
269
+ -- Full key, because the flush drains by key.
270
+ slot[ccs[i]] = mpfx .. ccs[i]
271
+ end
272
+ by_minute[m] = slot
273
+ end
274
+ end
275
+ return by_minute
276
+ end
277
+
278
+ --- Split a namespaced analytics key into kind / domain / mid / rest.
279
+ --
280
+ -- Layouts: `c:{domain}:{minute}:{CC}` and `g:{domain}:{day}:{suffix}`.
281
+ -- Positional, NOT a full split on ":", because `suffix` legitimately contains
282
+ -- colons — a normalized path key looks like `g:x.com:20260803:p:/users/:id`.
283
+ -- Hostnames cannot contain ":", so the first three separators are unambiguous.
284
+ local function split_key(k)
285
+ local p1 = k:find(":", 1, true)
286
+ if not p1 then return nil end
287
+ local p2 = k:find(":", p1 + 1, true)
288
+ if not p2 then return nil end
289
+ local p3 = k:find(":", p2 + 1, true)
290
+ if not p3 then
291
+ return k:sub(1, p1 - 1), k:sub(p1 + 1, p2 - 1), k:sub(p2 + 1), nil
292
+ end
293
+ return k:sub(1, p1 - 1), k:sub(p1 + 1, p2 - 1), k:sub(p2 + 1, p3 - 1), k:sub(p3 + 1)
294
+ end
295
+
296
+ --- Read one minute's country counts out of a scanned index.
297
+ -- @param slot table|nil CC → full dict key, from scan_country_index
298
+ -- @param del boolean drain each key after reading (flush semantics)
299
+ -- @return table|nil counts, or nil when the minute had no country data
300
+ local function read_minute_countries(slot, del)
301
+ if not slot then return nil end
302
+ local counts, any = {}, false
303
+ for cc, key in pairs(slot) do
304
+ -- `drain` subtracts what it read instead of deleting, so a request arriving
305
+ -- mid-flush is not thrown away with the value that was just read.
306
+ counts[cc] = del and drain(key) or (tonumber(analytics:get(key)) or 0)
307
+ any = true
308
+ end
309
+ return any and counts or nil
310
+ end
311
+
312
+ -- ── POST /analytics/collect - the SCRAPER's endpoint: every domain, ONE scan ──
313
+ --
314
+ -- Body: { "day": "YYYYMMDD", "domains": [ { "domain": "x.com", "from": M, "to": M } ] }
315
+ -- Returns: { "domains": { "x.com": { buckets: [...], flushed: N, rollup: {...} } } }
316
+ --
317
+ -- Why this exists rather than looping the single-domain endpoints:
318
+ --
319
+ -- `get_keys` is the only key-discovery mechanism a shared dict offers, it walks the
320
+ -- WHOLE zone, and it holds the dict lock against every nginx worker while it does.
321
+ -- The scraper needs both the per-minute countries (flush) and the daily rollup for
322
+ -- each domain, so calling the per-domain endpoints cost 2N+1 unbounded scans of a
323
+ -- 256 MB zone — plus 2N+1 SSH/exec round trips — every sweep. On a 50-domain box
324
+ -- that is 101 full scans and 101 round trips, on a schedule, to collect data that
325
+ -- one pass already has in hand.
326
+ --
327
+ -- So: one `get_keys(0)`, classify every key by (kind, domain) in a single pass, then
328
+ -- serve all domains from the in-memory index. 2N+1 scans → 1, and 2N+1 round trips
329
+ -- → 1. The per-domain GET endpoints stay for the READ path, where a page view wants
330
+ -- one domain's live tail and a single scan is the honest cost.
331
+ --
332
+ -- Flush semantics are unchanged and still atomic per key: minute counters and their
333
+ -- country keys are deleted as they are read, so a bucket is handed to exactly one
334
+ -- caller no matter how many collectors race. The daily rollup is read-only — the
335
+ -- edge keeps running totals for the day, and deleting them would lose the totals for
336
+ -- every later scrape of the same day.
337
+
338
+ if uri == "/analytics/collect" and ngx.req.get_method() == "POST" then
339
+ ngx.req.read_body()
340
+ local body = ngx.req.get_body_data()
341
+ if not body then return bad("empty body") end
342
+ local payload = cjson.decode(body)
343
+ if type(payload) ~= "table" or type(payload.domains) ~= "table" then
344
+ return bad('expected { day, domains: [{domain, from, to}] }')
345
+ end
346
+
347
+ local day = payload.day
348
+ if type(day) ~= "string" or #day ~= 8 then day = os.date("!%Y%m%d") end
349
+
350
+ -- Requested domains → their minute window. Also the membership test the scan
351
+ -- uses, so an unrequested domain's keys are skipped without a string compare
352
+ -- per domain.
353
+ local want, order = {}, {}
354
+ for i = 1, #payload.domains do
355
+ local d = payload.domains[i]
356
+ if type(d) == "table" and type(d.domain) == "string" and d.domain ~= "" then
357
+ local name = d.domain:lower()
358
+ local from_m, to_m = tonumber(d.from), tonumber(d.to)
359
+ if from_m and to_m then
360
+ if to_m - from_m > 1440 then to_m = from_m + 1440 end -- cap at 24h
361
+ if not want[name] then order[#order + 1] = name end
362
+ want[name] = { from = from_m, to = to_m }
363
+ end
364
+ end
365
+ end
366
+ if #order == 0 then return json({ domains = {} }) end
367
+
368
+ -- ── Per-domain index reads ───────────────────────────────────────────────
369
+ --
370
+ -- Was a single `get_keys(0)` pass classifying every key in the zone. One scan is better
371
+ -- than 2N+1, but it still walked keys belonging to domains nobody asked about, and held
372
+ -- the zone lock while doing it. Now each requested domain reads only its own indexes.
373
+ local countries = {} -- domain → minute → CC → dict key
374
+ local rollups = {} -- domain → suffix → value
375
+ for i = 1, #order do
376
+ local domain = order[i]
377
+ local w = want[domain]
378
+ countries[domain] = scan_country_index(domain, w.from, w.to)
379
+ rollups[domain] = read_rollup(domain, day)
380
+ end
381
+
382
+ -- ── Serve each domain from the index ─────────────────────────────────────
383
+ local out = {}
384
+ for i = 1, #order do
385
+ local domain = order[i]
386
+ local w = want[domain]
387
+ local cidx = countries[domain] or {}
388
+
389
+ local buckets, flushed = {}, 0
390
+ for m = w.from, w.to do
391
+ local p = "s:" .. domain .. ":" .. m
392
+ -- Drain FIRST, then decide: `drain` returns what it took, and a counter left
393
+ -- at 0 by an earlier flush must not emit a bucket of zeros. (Zero is truthy in
394
+ -- Lua, so the old `if r then` would have started doing exactly that the moment
395
+ -- keys stopped being deleted.)
396
+ local r = drain(p .. ":r")
397
+ if r > 0 then
398
+ local rt_us = drain(p .. ":t")
399
+ buckets[#buckets + 1] = {
400
+ minute = m,
401
+ requests = r,
402
+ unique_requests = drain(p .. ":u"),
403
+ bandwidth_in = drain(p .. ":i"),
404
+ bandwidth_out = drain(p .. ":o"),
405
+ response_time = rt_us / 1000000,
406
+ countries = read_minute_countries(cidx[m], true),
407
+ }
408
+ flushed = flushed + 1
409
+ end
410
+ end
411
+
412
+ -- Daily rollup, same key classification the single-domain endpoint uses.
413
+ local geo, paths, statuses, visitors = {}, {}, {}, 0
414
+ for suffix, v in pairs(rollups[domain] or {}) do
415
+ if suffix == "v" then
416
+ visitors = v
417
+ elseif suffix:sub(1, 2) == "p:" then
418
+ paths[suffix:sub(3)] = v
419
+ elseif suffix:sub(1, 2) == "s:" then
420
+ statuses[suffix:sub(3)] = v
421
+ elseif suffix:match("^%u%u$") then
422
+ geo[suffix] = v
423
+ end
424
+ -- Anything else (`pn`) is bookkeeping: never reported as a country.
425
+ end
426
+
427
+ out[domain] = {
428
+ buckets = buckets,
429
+ flushed = flushed,
430
+ rollup = {
431
+ day = day, countries = geo, visitors = visitors,
432
+ paths = paths, statuses = statuses,
433
+ },
434
+ }
435
+ end
436
+
437
+ return json({ day = day, domains = out })
438
+ end
439
+
82
440
  -- ── GET /analytics - minute-bucket time series ───────────────────────────────
83
441
  -- ?domain=example.com&from=EPOCH_MIN&to=EPOCH_MIN
84
442
 
@@ -95,6 +453,9 @@ if uri == "/analytics" then
95
453
  -- Cap at 24h
96
454
  if to_m - from_m > 1440 then to_m = from_m + 1440 end
97
455
 
456
+ -- ONE scan for the whole window (see scan_country_index).
457
+ local country_index = scan_country_index(domain, from_m, to_m)
458
+
98
459
  local buckets = {}
99
460
  for m = from_m, to_m do
100
461
  local p = "s:" .. domain .. ":" .. m
@@ -102,31 +463,15 @@ if uri == "/analytics" then
102
463
  if r then
103
464
  -- Response time stored as microseconds, convert to seconds (float)
104
465
  local rt_us = analytics:get(p .. ":t") or 0
105
- local bucket = {
466
+ buckets[#buckets + 1] = {
106
467
  minute = m,
107
468
  requests = r,
108
469
  unique_requests = analytics:get(p .. ":u") or 0,
109
470
  bandwidth_in = analytics:get(p .. ":i") or 0,
110
471
  bandwidth_out = analytics:get(p .. ":o") or 0,
111
472
  response_time = rt_us / 1000000,
473
+ countries = read_minute_countries(country_index[m], false),
112
474
  }
113
-
114
- -- Collect per-minute country data if present
115
- local cpfx = "c:" .. domain .. ":" .. m .. ":"
116
- local all_keys = analytics:get_keys(10000)
117
- local countries = {}
118
- local has_countries = false
119
- for _, k in ipairs(all_keys) do
120
- if k:sub(1, #cpfx) == cpfx then
121
- countries[k:sub(#cpfx + 1)] = analytics:get(k) or 0
122
- has_countries = true
123
- end
124
- end
125
- if has_countries then
126
- bucket.countries = countries
127
- end
128
-
129
- buckets[#buckets + 1] = bucket
130
475
  end
131
476
  end
132
477
 
@@ -150,44 +495,28 @@ if uri == "/analytics/flush" and ngx.req.get_method() == "POST" then
150
495
  end
151
496
  if to_m - from_m > 1440 then to_m = from_m + 1440 end
152
497
 
498
+ -- ONE scan for the whole window (see scan_country_index).
499
+ local country_index = scan_country_index(domain, from_m, to_m)
500
+
153
501
  local buckets = {}
154
502
  local flushed = 0
155
503
  for m = from_m, to_m do
156
504
  local p = "s:" .. domain .. ":" .. m
157
- local r = analytics:get(p .. ":r")
158
- if r then
159
- local rt_us = analytics:get(p .. ":t") or 0
160
- local bucket = {
505
+ -- Drain, don't delete see `drain`. A counter already at 0 is not a bucket.
506
+ local r = drain(p .. ":r")
507
+ if r > 0 then
508
+ local rt_us = drain(p .. ":t")
509
+ buckets[#buckets + 1] = {
161
510
  minute = m,
162
511
  requests = r,
163
- unique_requests = analytics:get(p .. ":u") or 0,
164
- bandwidth_in = analytics:get(p .. ":i") or 0,
165
- bandwidth_out = analytics:get(p .. ":o") or 0,
512
+ unique_requests = drain(p .. ":u"),
513
+ bandwidth_in = drain(p .. ":i"),
514
+ bandwidth_out = drain(p .. ":o"),
166
515
  response_time = rt_us / 1000000,
516
+ -- Drained too: this endpoint hands the minute to the DB, so the country
517
+ -- keys must go with it or they would be counted twice.
518
+ countries = read_minute_countries(country_index[m], true),
167
519
  }
168
-
169
- -- Collect per-minute country data
170
- local cpfx = "c:" .. domain .. ":" .. m .. ":"
171
- local all_keys = analytics:get_keys(10000)
172
- local countries = {}
173
- local has_countries = false
174
- for _, k in ipairs(all_keys) do
175
- if k:sub(1, #cpfx) == cpfx then
176
- countries[k:sub(#cpfx + 1)] = analytics:get(k) or 0
177
- analytics:delete(k)
178
- has_countries = true
179
- end
180
- end
181
- if has_countries then bucket.countries = countries end
182
-
183
- buckets[#buckets + 1] = bucket
184
-
185
- -- Delete the minute-bucket counter keys
186
- analytics:delete(p .. ":r")
187
- analytics:delete(p .. ":i")
188
- analytics:delete(p .. ":o")
189
- analytics:delete(p .. ":t")
190
- analytics:delete(p .. ":u")
191
520
  flushed = flushed + 1
192
521
  end
193
522
  end
@@ -203,18 +532,20 @@ if uri == "/analytics/totals" then
203
532
  local domain = ngx.var.arg_domain
204
533
 
205
534
  if not domain or domain == "" then
206
- local keys = analytics:get_keys(10000)
535
+ -- The scraper's domain-discovery call. Read from the `dn`/`di:` index that
536
+ -- site_logger appends to the first time it ever sees a host, so this no longer
537
+ -- walks the zone. It was the worst place for a scan: a cap here silently dropped
538
+ -- whole domains from every subsequent flush.
207
539
  local domains = {}
208
- for _, k in ipairs(keys) do
209
- local d = k:match("^d:(.+)$")
210
- if d then
211
- domains[#domains + 1] = {
212
- domain = d,
213
- requests = analytics:get("t:" .. d .. ":r") or 0,
214
- bandwidth_in = analytics:get("t:" .. d .. ":i") or 0,
215
- bandwidth_out = analytics:get("t:" .. d .. ":o") or 0,
216
- }
217
- end
540
+ for _, d in ipairs(read_index("dn", "di:")) do
541
+ -- A host can be indexed but have had its lifetime totals evicted; report it
542
+ -- with zeros rather than omitting it, so the scraper still asks for its buckets.
543
+ domains[#domains + 1] = {
544
+ domain = d,
545
+ requests = analytics:get("t:" .. d .. ":r") or 0,
546
+ bandwidth_in = analytics:get("t:" .. d .. ":i") or 0,
547
+ bandwidth_out = analytics:get("t:" .. d .. ":o") or 0,
548
+ }
218
549
  end
219
550
  return json({ domains = domains })
220
551
  end
@@ -228,8 +559,23 @@ if uri == "/analytics/totals" then
228
559
  })
229
560
  end
230
561
 
231
- -- ── GET /analytics/geo - country breakdown for a day ─────────────────────────
562
+ -- ── GET /analytics/geo - daily rollup for a day ──────────────────────────────
232
563
  -- ?domain=example.com&day=YYYYMMDD
564
+ --
565
+ -- Named /geo for compatibility; it now returns the whole daily rollup — countries,
566
+ -- distinct visitors, top paths, status-code mix. One scan serves all four: they
567
+ -- share the `g:{domain}:{day}:` prefix precisely so a reader never needs a second
568
+ -- pass over the zone.
569
+ --
570
+ -- Key layout under that prefix (written by site_logger.lua):
571
+ -- <CC> EXACTLY two uppercase letters → country hit count
572
+ -- v distinct visitors today
573
+ -- p:<path> path counter
574
+ -- pn distinct-path counter (cardinality cap bookkeeping)
575
+ -- s:<status> status-code counter
576
+ --
577
+ -- Country matching is strict (^%u%u$) rather than "whatever's left": a bookkeeping
578
+ -- key like `pn` would otherwise surface as a country named "pn" and get plotted.
233
579
 
234
580
  if uri == "/analytics/geo" then
235
581
  local domain = ngx.var.arg_domain
@@ -241,16 +587,33 @@ if uri == "/analytics/geo" then
241
587
  day = os.date("!%Y%m%d")
242
588
  end
243
589
 
244
- local prefix = "g:" .. domain .. ":" .. day .. ":"
245
- local keys = analytics:get_keys(10000)
246
- local countries = {}
247
- for _, k in ipairs(keys) do
248
- if k:sub(1, #prefix) == prefix then
249
- countries[k:sub(#prefix + 1)] = analytics:get(k) or 0
590
+ -- Read through the write-time indexes same helper /analytics/collect uses, so the two
591
+ -- endpoints cannot disagree about what a day contains. Replaces a `get_keys(0)` that
592
+ -- walked the whole zone to find keys under one prefix.
593
+ local rollup = read_rollup(domain, day)
594
+ local countries, paths, statuses = {}, {}, {}
595
+ local visitors = tonumber(rollup["v"]) or 0
596
+ for suffix, v in pairs(rollup) do
597
+ if suffix == "v" or suffix == "pn" then
598
+ -- bookkeeping, not data
599
+ elseif suffix:sub(1, 2) == "p:" then
600
+ paths[suffix:sub(3)] = v
601
+ elseif suffix:sub(1, 2) == "s:" then
602
+ statuses[suffix:sub(3)] = v
603
+ elseif suffix:match("^%u%u$") then
604
+ -- Country codes only. Anything else under this prefix is bookkeeping, and
605
+ -- letting one through made `pn` plot as a country and skew every percentage.
606
+ countries[suffix] = v
250
607
  end
251
608
  end
252
-
253
- return json({ domain = domain, day = day, countries = countries })
609
+ return json({
610
+ domain = domain,
611
+ day = day,
612
+ countries = countries,
613
+ visitors = visitors,
614
+ paths = paths,
615
+ statuses = statuses,
616
+ })
254
617
  end
255
618
 
256
619
  -- ── GET /logs/recent - raw request ring buffer ───────────────────────────────
@@ -19,8 +19,11 @@ if not geo_ok then geo = nil end
19
19
  -- Reused payload table per worker (cleared after encode to avoid GC pressure)
20
20
  local payload = {}
21
21
 
22
+ -- `cc` is the country site_logger already resolved for this request. Passed in rather
23
+ -- than looked up again: one mmdb hit per request, and the live frame can never disagree
24
+ -- with the rollup or the ring buffer about where a hit came from.
22
25
  local function pipe_request_log(premature, host, ip, ts, ua, uri,
23
- req_len, bytes, rt, method, status)
26
+ req_len, bytes, rt, method, status, cc)
24
27
  if premature then return end
25
28
 
26
29
  local sh = ngx.shared.request_data
@@ -30,9 +33,12 @@ local function pipe_request_log(premature, host, ip, ts, ua, uri,
30
33
  if not sh:get(SUB_PREFIX .. host) then return end
31
34
 
32
35
  pcall(function()
33
- local country = nil
34
- if geo and geo.get_country_code then
35
- country = geo.get_country_code(ip)
36
+ -- Falls back to its own lookup only if the caller passed nothing, which happens
37
+ -- when a box is mid-deploy and still has the previous site_logger.
38
+ local country = cc
39
+ if (not country or country == "") and geo and geo.get_country_code then
40
+ local ok_cc, res = pcall(geo.get_country_code, ip)
41
+ if ok_cc then country = res end
36
42
  end
37
43
 
38
44
  payload.id = string.format("%.3f-%d", ts, math.random(10000, 99999))