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.
- package/README.md +27 -1
- package/dist/bare-FSUAM5V5.js +23 -0
- package/dist/{bowser-O3AEA6AN.js → bowser-HNOTPGJO.js} +2 -2
- package/dist/{chunk-5MZ624VX.js → chunk-2GWYYHOR.js} +1 -1
- package/dist/chunk-3FYIXVX5.js +8051 -0
- package/dist/{chunk-ILFETZOM.js → chunk-3LDWWY7U.js} +21 -1
- package/dist/{chunk-W73X43R7.js → chunk-3NQQXMDR.js} +3 -3
- package/dist/{chunk-OWED2GRM.js → chunk-42WQABXM.js} +2 -2
- package/dist/{chunk-TYVME3XD.js → chunk-6FH4AXVY.js} +20 -3
- package/dist/{chunk-OXWDY22G.js → chunk-6ODUBKUB.js} +3 -3
- package/dist/{chunk-DYPH4HCI.js → chunk-AO252K7Z.js} +1 -1
- package/dist/{chunk-XPPZ7B2J.js → chunk-B3CE2OMR.js} +14 -12
- package/dist/{chunk-IXCHURVK.js → chunk-DOD2BXHQ.js} +4 -4
- package/dist/{chunk-VWPEKOH6.js → chunk-DRK3MEDS.js} +3 -3
- package/dist/{chunk-V6AN6XLZ.js → chunk-EMXI4UCU.js} +125 -47
- package/dist/{chunk-GKHUSZCD.js → chunk-EP2TNALB.js} +2 -2
- package/dist/{chunk-SLTYVHU7.js → chunk-GUQ3QCJ7.js} +942 -519
- package/dist/chunk-I3DPMTSF.js +29 -0
- package/dist/{chunk-L2VOGB2X.js → chunk-IW2UKBVF.js} +3 -3
- package/dist/{chunk-VDMQJHPI.js → chunk-IXM5245F.js} +228 -13
- package/dist/{chunk-MZ2WEHHY.js → chunk-JIMUYEKN.js} +4 -4
- package/dist/chunk-OGY63DGD.js +705 -0
- package/dist/{chunk-O6ZZF52V.js → chunk-ONKEYSMY.js} +2 -2
- package/dist/{chunk-6APULWSU.js → chunk-OTLSJYAC.js} +1744 -118
- package/dist/{chunk-EI575GHN.js → chunk-PDW4L3NV.js} +3 -3
- package/dist/{chunk-5NYUCONN.js → chunk-PSZSKEPA.js} +33 -51
- package/dist/{chunk-KI2EY3WB.js → chunk-PXKCZB2I.js} +1 -1
- package/dist/{chunk-FXTBHTWY.js → chunk-QEH4QUBX.js} +649 -85
- package/dist/{chunk-6APIPIJY.js → chunk-QWHBRY2K.js} +5 -5
- package/dist/{chunk-6B5YCFFV.js → chunk-RVER2EOO.js} +5 -5
- package/dist/{chunk-Q2CIRAOK.js → chunk-RXQBD4ZY.js} +1 -1
- package/dist/{chunk-TGIINPVM.js → chunk-TWJCLD4W.js} +60 -10
- package/dist/{chunk-4Z5QO7N6.js → chunk-VDDMADOF.js} +3 -3
- package/dist/{chunk-BBHX7T4R.js → chunk-VE2YOK4I.js} +991 -254
- package/dist/{chunk-2D3I5G6P.js → chunk-VZ2ZHPS2.js} +73 -43
- package/dist/{chunk-AVIB7CT7.js → chunk-WNWQBB7F.js} +2 -2
- package/dist/{chunk-FRNAR33O.js → chunk-XZYFZ6A5.js} +3 -3
- package/dist/{chunk-RPHRPFEH.js → chunk-ZCFVHA25.js} +1 -1
- package/dist/{chunk-FPRHYBY2.js → chunk-ZFJXPCQP.js} +1 -1
- package/dist/{chunk-JYGD5WSN.js → chunk-ZWLDLTJP.js} +1 -1
- package/dist/cloud-E5M6VMCG.js +31 -0
- package/dist/{cloud-7QYOGF7F.js → cloud-KRTZ2IL5.js} +3 -3
- package/dist/{detect-AZLOHUFX.js → detect-ALF4GXRX.js} +7 -8
- package/dist/{dist-IQJ6H6UZ.js → dist-BM6NPWIH.js} +3 -3
- package/dist/{dist-es-X3B5QYKB.js → dist-es-2YRDXMUU.js} +6 -6
- package/dist/{dist-es-OSNJBLXB.js → dist-es-CCVZEFRZ.js} +5 -5
- package/dist/{dist-es-VNKHQVIB.js → dist-es-IOH6G4GM.js} +4 -4
- package/dist/{dist-es-RYHIPOTN.js → dist-es-JGBPABJI.js} +14 -14
- package/dist/{dist-es-JSTDUS7Q.js → dist-es-KWSMDPIP.js} +6 -6
- package/dist/{dist-es-5DY7K32L.js → dist-es-UYDDOVZ3.js} +6 -6
- package/dist/{dist-es-WV4AMOII.js → dist-es-XUOR4NQN.js} +8 -8
- package/dist/docker-QECQJ5P4.js +32 -0
- package/dist/docker-edge-executor-62N64MGS.js +17 -0
- package/dist/edge-import-CQWHN2Q3.js +48 -0
- package/dist/ensure-container-edge-MJVXJQG5.js +32 -0
- package/dist/{event-streams-EHRAKL75.js → event-streams-TR4GAC53.js} +4 -4
- package/dist/{executor-XJ6RP4Q2.js → executor-JELARVKC.js} +8 -8
- package/dist/index.js +1209 -560
- package/dist/{loadSso-46FQRIZS.js → loadSso-UGF4JMRK.js} +10 -10
- package/dist/{local-executor-V7U5VOZW.js → local-executor-WOFZD72X.js} +4 -4
- package/dist/nginx-AXGNWRKZ.js +31 -0
- package/dist/node-entry.js +4 -0
- package/dist/{noop-FMMFG2EZ.js → noop-NJ72F46T.js} +3 -3
- package/dist/openresty-lua-IE4P5YQX.js +81 -0
- package/dist/server/index.js +116248 -94749
- package/dist/server/lua/geo_country.lua +31 -6
- package/dist/server/lua/maxminddb.lua +403 -0
- package/dist/server/lua/mgmt_api.lua +431 -68
- package/dist/server/lua/pipe_log.lua +10 -4
- package/dist/server/lua/site_logger.lua +220 -12
- package/dist/server/migrations/0073_project_volumes_object_storage.sql +3 -0
- package/dist/server/migrations/0074_project_compose_path.sql +1 -0
- package/dist/server/migrations/0075_service_command_argv.sql +1 -0
- package/dist/server/migrations/0076_rollback_retention.sql +17 -0
- package/dist/server/migrations/0077_domain_redirect.sql +13 -0
- package/dist/server/migrations/0078_grant_source_scope.sql +30 -0
- package/dist/server/migrations/0079_project_readiness.sql +18 -0
- package/dist/server/migrations/0080_deployment_error_classification.sql +26 -0
- package/dist/server/migrations/0081_project_readiness_repair.sql +34 -0
- package/dist/server/migrations/0082_edge_target_verification.sql +39 -0
- package/dist/server/migrations/0083_service_incident.sql +84 -0
- package/dist/server/migrations/0084_analytics_daily_rollup.sql +72 -0
- package/dist/server/migrations/0085_resource_usage.sql +68 -0
- package/dist/server/migrations/0086_remote_infra_updates.sql +41 -0
- package/dist/server/migrations/0087_server_container_version_columns.sql +17 -0
- package/dist/server/migrations/0088_audit_source_and_settings.sql +27 -0
- package/dist/server/migrations/0089_auto_scan_infra.sql +7 -0
- package/dist/server/migrations/0090_project_collect_paths.sql +18 -0
- package/dist/server/migrations/0091_project_server_id.sql +26 -0
- package/dist/server/migrations/0092_project_internal_alias.sql +13 -0
- package/dist/server/migrations/0093_service_incident_org_index.sql +13 -0
- package/dist/server/migrations/0094_backup_restore_meta.sql +15 -0
- package/dist/server/migrations/0095_backup_restore_cancel.sql +19 -0
- package/dist/server/migrations/0096_backup_policy_retention_defaults.sql +20 -0
- package/dist/server/migrations/0097_update_status_upstream_only.sql +35 -0
- package/dist/server/migrations/meta/_journal.json +175 -0
- package/dist/setup-FA4PW4NU.js +21 -0
- package/dist/{signin-ITOPKKUB.js → signin-3M3UPB6K.js} +10 -10
- package/dist/{sso-oidc-NON2PD7Y.js → sso-oidc-3VDIYOEJ.js} +10 -10
- package/dist/{sts-HHFFSAY2.js → sts-B3WIYLXR.js} +9 -9
- package/package.json +6 -3
- package/dist/bare-JY5Y5ELU.js +0 -23
- package/dist/chunk-SJIFLLX4.js +0 -2931
- package/dist/chunk-VFINNTUL.js +0 -362
- package/dist/cloud-KYWY3RP4.js +0 -30
- package/dist/docker-LODK5Y2S.js +0 -16
- package/dist/docker-edge-executor-VHW3JPJS.js +0 -13
- package/dist/edge-import-ABUDKKJI.js +0 -45
- package/dist/ensure-container-edge-SW6HENSG.js +0 -31
- package/dist/nginx-3L4SIBJA.js +0 -24
- package/dist/openresty-lua-4WLGDSER.js +0 -42
- 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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
local
|
|
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 =
|
|
164
|
-
bandwidth_in =
|
|
165
|
-
bandwidth_out =
|
|
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
|
-
|
|
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 _,
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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 -
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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))
|