openship 0.6.0 → 0.6.1

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.
@@ -14,9 +14,10 @@
14
14
  -- image from apps/api/assets/geoip, so no runtime download)
15
15
  --
16
16
  -- Falls back gracefully - if the library or database is missing,
17
- -- get_country_code() returns nil. Both site_logger.lua and pipe_log.lua
18
- -- wrap this module in pcall(require, ...) so a missing DB never crashes
19
- -- the request pipeline. mgmt_api `GET /status` reports whether geo actually
17
+ -- get_country_code() returns nil. site_logger.lua wraps this module in
18
+ -- pcall(require, ...) so a missing DB never crashes the request pipeline
19
+ -- (the country it resolves flows into the ring buffer, and from there to
20
+ -- both /logs/recent and the live SSE stream). mgmt_api `GET /status` reports whether geo actually
20
21
  -- resolved, so a silently-dark edge is visible instead of just yielding an
21
22
  -- empty country map.
22
23
 
@@ -2,6 +2,14 @@
2
2
  -- content_by_lua: SSE endpoint for real-time request log streaming.
3
3
  -- GET /logs/stream?domain=example.com
4
4
  -- Internal only - 127.0.0.1:9145
5
+ --
6
+ -- Reads the SAME ring buffer site_logger.lua writes (rlog:{domain}:seq +
7
+ -- rlog:{domain}:{slot}), by a PER-CONNECTION cursor. Nothing is consumed: N
8
+ -- concurrent watchers each walk the ring independently and none steals another's
9
+ -- frames. This is what replaced the old single-subscriber log_pipe:q queue —
10
+ -- a containerized edge tails this over `docker exec curl`, and a curl that
11
+ -- outlives its client (the daemon buffers its stdout, so no EPIPE) can no longer
12
+ -- drain the queue out from under the next reader.
5
13
 
6
14
  local sh = ngx.shared.request_data
7
15
  if not sh then
@@ -20,12 +28,10 @@ end
20
28
  domain = domain:lower()
21
29
  if domain:sub(1, 4) == "www." then domain = domain:sub(5) end
22
30
 
23
- local SUB_KEY = "log_pipe:sub:" .. domain
24
- local QUEUE_KEY = "log_pipe:q:" .. domain
25
-
26
- -- Clear stale queue entries, mark subscriber active
27
- sh:delete(QUEUE_KEY)
28
- sh:set(SUB_KEY, true, 30)
31
+ -- MUST match site_logger.lua's RING — same slot arithmetic on both ends.
32
+ local RING = 1000
33
+ local SEQ_KEY = "rlog:" .. domain .. ":seq"
34
+ local slot_key = function(seq) return "rlog:" .. domain .. ":" .. (seq % RING) end
29
35
 
30
36
  ngx.header["Content-Type"] = "text/event-stream"
31
37
  ngx.header["Cache-Control"] = "no-cache, no-store"
@@ -36,55 +42,62 @@ ngx.header["X-Accel-Buffering"] = "no"
36
42
  -- Without this, ngx.flush on an empty buffer sends chunked-EOF (0\r\n\r\n)
37
43
  -- and terminates the response immediately.
38
44
  ngx.print(": connected\n\n")
39
- if not ngx.flush(true) then
40
- sh:delete(SUB_KEY)
41
- return
42
- end
45
+ if not ngx.flush(true) then return end
43
46
 
44
- local started = ngx.now()
45
- local last_hb = started
46
- local last_ref = started
47
+ -- Start at the current head: stream only requests that arrive AFTER connect.
48
+ -- History up to this point is served separately by /logs/recent, and the client
49
+ -- dedups the two by the deterministic `{host}:{seq}` id, so a row seen by both
50
+ -- collapses to one.
51
+ local cursor = tonumber(sh:get(SEQ_KEY)) or 0
52
+ local started = ngx.now()
53
+ local last_hb = started
47
54
 
48
55
  while true do
49
- -- Max 1 hour per connection
50
- if ngx.now() - started > 3600 then
51
- sh:delete(SUB_KEY)
52
- return
53
- end
56
+ -- Max 1 hour per connection.
57
+ if ngx.now() - started > 3600 then return end
58
+
59
+ local head = tonumber(sh:get(SEQ_KEY)) or 0
60
+
61
+ -- If we've fallen more than a full ring behind, the oldest unread slots have
62
+ -- already been overwritten. Jump to the oldest slot still holding its own
63
+ -- entry (head-RING maps to head's slot, so head-RING+1 is the oldest live one)
64
+ -- and skip the lost range — same bound the old 2000-entry queue had.
65
+ if cursor < head - RING then cursor = head - RING end
54
66
 
55
- -- Drain up to 100 queued entries per cycle
56
67
  local sent = 0
57
- while sent < 100 do
58
- local entry = sh:rpop(QUEUE_KEY)
59
- if not entry then break end
60
- ngx.print("event: request\ndata: ", entry, "\n\n")
61
- sent = sent + 1
68
+ while cursor < head and sent < 100 do
69
+ local next_seq = cursor + 1
70
+ local entry = sh:get(slot_key(next_seq))
71
+ if entry then
72
+ ngx.print("event: request\ndata: ", entry, "\n\n")
73
+ sent = sent + 1
74
+ cursor = next_seq
75
+ elseif next_seq < head then
76
+ -- A later seq already exists, so this can't be the incr(seq)→set(slot)
77
+ -- write window: the slot is genuinely lost (expired or wrapped). Skip it.
78
+ cursor = next_seq
79
+ else
80
+ -- next_seq == head: the newest entry's slot may not be written yet
81
+ -- (the microsecond window between incr(seq) and set(slot)). Retry it
82
+ -- next cycle without advancing.
83
+ break
84
+ end
62
85
  end
63
86
 
64
87
  if sent > 0 then
65
- if not ngx.flush(true) then
66
- sh:delete(SUB_KEY)
67
- return
68
- end
88
+ if not ngx.flush(true) then return end
69
89
  end
70
90
 
71
91
  local now = ngx.now()
72
92
 
73
- -- Heartbeat every 15s
93
+ -- Heartbeat every 15s. Its flush is also how a dead client is detected: once
94
+ -- the reader (a `docker exec curl`) goes away and its socket closes, this
95
+ -- flush fails and the connection tears down here.
74
96
  if now - last_hb > 15 then
75
97
  ngx.print(": ping\n\n")
76
- if not ngx.flush(true) then
77
- sh:delete(SUB_KEY)
78
- return
79
- end
98
+ if not ngx.flush(true) then return end
80
99
  last_hb = now
81
100
  end
82
101
 
83
- -- Refresh subscriber TTL every 10s
84
- if now - last_ref > 10 then
85
- sh:set(SUB_KEY, true, 30)
86
- last_ref = now
87
- end
88
-
89
102
  ngx.sleep(0.05)
90
103
  end
@@ -27,8 +27,10 @@
27
27
  -- Shared dict key schema (request_data zone):
28
28
  -- rlog:{domain}:seq monotonic write pointer
29
29
  -- rlog:{domain}:{slot} JSON entry (ring buf) (TTL 1h)
30
- -- log_pipe:sub:{domain} live subscriber flag (TTL 30s)
31
- -- log_pipe:q:{domain} live log queue entries
30
+ --
31
+ -- The ring is ALSO the live source: pipe_stream.lua's SSE endpoint reads these
32
+ -- slots by a per-connection cursor, so there is no separate live queue or
33
+ -- subscriber flag — every watcher sees the full stream and steals from none.
32
34
  --
33
35
  -- NOTE on `:u` — it counts non-static REQUESTS, not people. It was surfaced as
34
36
  -- "unique IPs" all the way to the dashboard, which it never was. Real distinct
@@ -43,10 +45,6 @@ if not geo_ok then
43
45
  ngx.log(ngx.WARN, "[site_logger] geo_country module not available. GeoIP disabled.")
44
46
  end
45
47
 
46
- -- SAFE LOAD: Pipe module (per-worker, cached by require)
47
- local pipe_ok, pipe_log = pcall(require, "openship.pipe_log")
48
- if not pipe_ok then pipe_log = nil end
49
-
50
48
  local analytics = ngx.shared.analytics
51
49
  local request_data = ngx.shared.request_data
52
50
  -- Per-host edge config, pushed by the API (see mgmt_api's /analytics/config).
@@ -194,11 +192,12 @@ local status = ngx.var.status or "0"
194
192
 
195
193
  -- Client country, resolved ONCE per request.
196
194
  --
197
- -- Hoisted out of the geo block because THREE consumers need it — the daily/per-minute
198
- -- rollups, the raw ring buffer behind /logs/recent, and the live SSE pipe. It used to be
199
- -- looked up inside the rollup and again inside pipe_log, so the ring buffer had no
200
- -- country at all: a request-log list showed flags on rows that arrived live and none on
201
- -- rows backfilled from /logs/recent, for the same traffic.
195
+ -- Hoisted out of the geo block because both the daily/per-minute rollups and the raw
196
+ -- ring buffer need it. The ring is the single source for the request log — /logs/recent
197
+ -- reads it directly and the live SSE stream (pipe_stream.lua) reads the same slots by
198
+ -- cursor so resolving the country here means every row carries its flag no matter
199
+ -- which path delivered it. An earlier split (looked up in the rollup, again in a
200
+ -- separate live pipe) left the ring with no country at all.
202
201
  --
203
202
  -- pcall'd because a corrupt or partially-written mmdb makes the lookup raise, and an
204
203
  -- error here would abort every counter below it (see the section header).
@@ -356,10 +355,20 @@ pcall(function()
356
355
  bump_indexed(gpfx .. "s:" .. status, D48H, gpfx .. "sn", gpfx .. "si:", status)
357
356
  end)
358
357
 
359
- -- ── 4. Raw request ring buffer ──────────────────────────────────────────────
358
+ -- ── 4. Raw request ring buffer (also the live SSE source) ────────────────────
359
+ --
360
+ -- `seq` is claimed BEFORE encoding so the stored JSON can carry a deterministic
361
+ -- id (`{host}:{seq}`). That id is the dedup key everywhere downstream: /logs/recent
362
+ -- returns these entries verbatim, and pipe_stream.lua streams the same slots live,
363
+ -- so the same request reaches the client with one stable id instead of a fresh
364
+ -- random one per source. `incr` with init 0 never returns nil here; guard anyway
365
+ -- so a shdict hiccup can't index a nil into the slot key.
360
366
 
361
367
  pcall(function()
368
+ local seq = request_data:incr("rlog:" .. host .. ":seq", 1, 0)
369
+ if not seq then return end
362
370
  local ok_j, j = pcall(cjson.encode, {
371
+ id = host .. ":" .. seq,
363
372
  ip = ip,
364
373
  -- nil is simply omitted by cjson, so a box with no GeoIP writes the same shape
365
374
  -- minus this key rather than a null the reader has to special-case.
@@ -374,17 +383,6 @@ pcall(function()
374
383
  rt = rt,
375
384
  })
376
385
  if ok_j and j then
377
- local seq = request_data:incr("rlog:" .. host .. ":seq", 1, 0)
378
- local slot = seq % RING
379
- request_data:set("rlog:" .. host .. ":" .. slot, j, D1H)
386
+ request_data:set("rlog:" .. host .. ":" .. (seq % RING), j, D1H)
380
387
  end
381
388
  end)
382
-
383
- -- ── 5. Live-log pipe (only when a subscriber is watching) ───────────────────
384
- -- Fire via timer to avoid blocking the log phase
385
-
386
- if pipe_log and pipe_log.pipe_request_log
387
- and request_data:get("log_pipe:sub:" .. host) then
388
- ngx.timer.at(0, pipe_log.pipe_request_log,
389
- host, ip, ts, ua, uri, req_len, bytes, rt, method, status, cc)
390
- end
@@ -4,15 +4,15 @@ import { createRequire as __ospCreateRequire } from "node:module";
4
4
  const require = __ospCreateRequire(import.meta.url);
5
5
  import {
6
6
  SystemManager
7
- } from "./chunk-EP2TNALB.js";
8
- import "./chunk-GUQ3QCJ7.js";
7
+ } from "./chunk-3NZICQHF.js";
8
+ import "./chunk-IOT5ZX23.js";
9
9
  import "./chunk-IW2UKBVF.js";
10
10
  import "./chunk-ZCFVHA25.js";
11
- import "./chunk-QEH4QUBX.js";
11
+ import "./chunk-EW6KB2J7.js";
12
12
  import "./chunk-I3DPMTSF.js";
13
13
  import "./chunk-VZ2ZHPS2.js";
14
14
  import "./chunk-3LDWWY7U.js";
15
- import "./chunk-OGY63DGD.js";
15
+ import "./chunk-YIPMDGG6.js";
16
16
  import "./chunk-ZFJXPCQP.js";
17
17
  import "./chunk-OTLSJYAC.js";
18
18
  import "./chunk-PXKCZB2I.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openship",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Openship CLI - deploy, manage, and open your Openship platform from the terminal",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -1,77 +0,0 @@
1
- -- pipe_log.lua
2
- -- Push request log into shared dict queue log_pipe:q:{host} for /stream SSE.
3
- -- Payload shape matches requestLogger.js (id, ip, country when known,
4
- -- timestamp, date, uri, method, status, userAgent, requestSize, responseSize,
5
- -- responseTime).
6
- --
7
- -- Called via ngx.timer.at from site_logger (premature is first arg).
8
-
9
- local cjson = require "cjson.safe"
10
-
11
- local SUB_PREFIX = "log_pipe:sub:"
12
- local QUEUE_PREFIX = "log_pipe:q:"
13
- local QUEUE_MAX_LEN = 2000
14
-
15
- -- SAFE LOAD: GeoIP (per-worker, cached by require)
16
- local geo_ok, geo = pcall(require, "openship.geo_country")
17
- if not geo_ok then geo = nil end
18
-
19
- -- Reused payload table per worker (cleared after encode to avoid GC pressure)
20
- local payload = {}
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.
25
- local function pipe_request_log(premature, host, ip, ts, ua, uri,
26
- req_len, bytes, rt, method, status, cc)
27
- if premature then return end
28
-
29
- local sh = ngx.shared.request_data
30
- if not sh then return end
31
-
32
- -- Double-check subscriber is still active (guard against race)
33
- if not sh:get(SUB_PREFIX .. host) then return end
34
-
35
- pcall(function()
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
42
- end
43
-
44
- payload.id = string.format("%.3f-%d", ts, math.random(10000, 99999))
45
- payload.host = host
46
- payload.ip = ip
47
- if country and type(country) == "string" and country ~= "" then
48
- payload.country = country
49
- end
50
- payload.timestamp = ts
51
- payload.date = os.date("!%Y-%m-%d %H:%M:%S", ts)
52
- payload.uri = uri
53
- payload.method = method or "GET"
54
- payload.status = tonumber(status) or 0
55
- payload.userAgent = ua or ""
56
- payload.requestSize = tonumber(req_len) or 0
57
- payload.responseSize = tonumber(bytes) or 0
58
- payload.responseTime = tonumber(rt) or 0
59
-
60
- local body = cjson.encode(payload)
61
-
62
- -- Clear payload table for next reuse
63
- for k in pairs(payload) do payload[k] = nil end
64
-
65
- if not body then return end
66
-
67
- local qkey = QUEUE_PREFIX .. host
68
- sh:lpush(qkey, body)
69
- if sh:llen(qkey) > QUEUE_MAX_LEN then
70
- sh:rpop(qkey)
71
- end
72
- end)
73
- end
74
-
75
- return {
76
- pipe_request_log = pipe_request_log,
77
- }