logalizer 0.1.0__py3-none-any.whl

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.
logalizer/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """logalizer — export Kibana logs as CSV via the Reporting API."""
2
+
3
+ try:
4
+ from importlib.metadata import version as _version
5
+
6
+ __version__ = _version("logalizer")
7
+ except Exception: # pragma: no cover - fallback for uninstalled source tree
8
+ __version__ = "0.0.0"
logalizer/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from logalizer.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
logalizer/cli.py ADDED
@@ -0,0 +1,284 @@
1
+ """Command-line interface for logalizer."""
2
+ import argparse
3
+ import json
4
+ import os
5
+ import re
6
+ import sys
7
+ from datetime import datetime, timedelta, timezone
8
+
9
+ from logalizer import reporting, indexpatterns
10
+ from logalizer import init, ping
11
+ from logalizer.client import Client, ClientError
12
+ from logalizer.config import build_settings, config_file_path, load_config
13
+
14
+
15
+ class UsageError(Exception):
16
+ pass
17
+
18
+
19
+ _DURATION_RE = re.compile(r"^(\d+)([smhd])$")
20
+ _UNIT_SECONDS = {"s": 1, "m": 60, "h": 3600, "d": 86400}
21
+
22
+
23
+ def parse_duration(text):
24
+ m = _DURATION_RE.match(text)
25
+ if not m:
26
+ raise ValueError(f"invalid duration: {text!r} (expected like 15m, 1h, 24h, 7d)")
27
+ return timedelta(seconds=int(m.group(1)) * _UNIT_SECONDS[m.group(2)])
28
+
29
+
30
+ def _iso(dt):
31
+ return dt.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
32
+
33
+
34
+ def build_time_filter(from_iso, to_iso, last):
35
+ if from_iso and to_iso:
36
+ gte, lte = from_iso, to_iso
37
+ elif from_iso or to_iso:
38
+ raise UsageError("--from and --to must be used together")
39
+ else:
40
+ now = datetime.now(timezone.utc)
41
+ delta = parse_duration(last or "24h")
42
+ gte, lte = _iso(now - delta), _iso(now)
43
+ return {
44
+ "meta": {"field": "@timestamp", "params": {}},
45
+ "query": {"range": {"@timestamp": {
46
+ "format": "strict_date_optional_time",
47
+ "gte": gte, "lte": lte}}},
48
+ }
49
+
50
+
51
+ HELP_TEXT = """\
52
+ logalizer — export Kibana logs as CSV (Kibana 7.17 Reporting API)
53
+
54
+ USAGE
55
+ logalizer [export] [OPTIONS] # export CSV (default action)
56
+ logalizer --list-spaces # print available spaces
57
+ logalizer --list-indices [--space SPACE] # print index patterns
58
+ logalizer --list-fields --index PATTERN # print fields (best-effort)
59
+ logalizer --help-json # machine-readable help
60
+ logalizer --init # write config.ini (wizard)
61
+ logalizer --init --url URL --username U --password P [--space S] [--index P] [--insecure]
62
+ logalizer --ping # test config + connectivity
63
+
64
+ EXAMPLES (copy-paste ready)
65
+ # Last 24h of errors from brain service, clean columns
66
+ logalizer --index 'logs-*' --query 'level:error' --last 24h \\
67
+ --fields '@timestamp,level,msg,logger' -o brain-errors.csv
68
+
69
+ # Absolute time window, match a correlation id (your real query shape)
70
+ logalizer -i 'app-logs-*' \\
71
+ --query '"00000000-0000-0000-0000-000000000000"' \\
72
+ --from 2026-08-25T10:00:00Z --to 2026-08-25T14:00:00Z
73
+
74
+ # Everything from an index, all fields, streamed to stdout for piping
75
+ logalizer -i 'agent-logs-*' --last 1h | grep 'status:500'
76
+
77
+ # Discover what's available
78
+ logalizer --list-spaces
79
+ logalizer --list-indices --space default
80
+ logalizer --list-fields --index 'logs-*'
81
+
82
+ QUERY OPTIONS
83
+ -q, --query KQL KQL query (default: "" = match all)
84
+ Examples: 'level:error'
85
+ 'status:500 AND url:*api*'
86
+ 'session_id:"01a03931-..."'
87
+ -i, --index PATTERN index pattern (REQUIRED for export), e.g. 'logs-*'
88
+ -s, --space SPACE Kibana space (default: from config, else default)
89
+ --last DURATION relative range: 30s, 15m, 1h, 24h, 7d
90
+ --from ISO --to ISO absolute range (ISO 8601). Overrides --last.
91
+ Use both, or neither.
92
+ --fields LIST comma-separated columns, in order.
93
+ Omit to export ALL fields (wide, includes internals).
94
+
95
+ OUTPUT OPTIONS
96
+ -o, --out PATH write CSV to file (default: stdout)
97
+ --timeout SECONDS max wait for the async job (default: 120)
98
+ --insecure skip TLS cert verification (self-signed servers)
99
+ -v, --verbose progress messages to stderr
100
+
101
+ DISCOVERY OPTIONS (exit 0; print to stdout, one per line)
102
+ --list-spaces list spaces
103
+ --list-indices list index patterns in --space
104
+ --list-fields list fields for --index (best-effort, may be partial)
105
+
106
+
107
+ CONFIGURATION & DIAGNOSTICS
108
+ --init write ~/.config/logalizer/config.ini (0600).
109
+ Interactive wizard when url/username/password omitted;
110
+ non-interactive when all three are given via flags.
111
+ --ping health check: config -> TLS -> auth -> space -> index.
112
+ Prints one line per check; exit 0 all good, else first
113
+ failure (2 config, 3 auth, 5 network).
114
+ --url, --username, --password used only by --init (never for export).
115
+
116
+ CREDENTIALS (never pass on command line)
117
+ KIBANA_URL, KIBANA_USERNAME, KIBANA_PASSWORD # env vars (recommended)
118
+ or ~/.config/logalizer/config.ini # 0600 perms
119
+
120
+ I/O CONTRACT
121
+ stdout = CSV data (or discovery output). stderr = all logs/errors.
122
+ Safe to run: logalizer ... > out.csv (diagnostics never pollute CSV)
123
+
124
+ EXIT CODES
125
+ 0 success
126
+ 2 usage error (bad flags / missing required)
127
+ 3 auth or permission failure (check KIBANA_USERNAME/PASSWORD, space)
128
+ 4 job failed or timed out (see stderr for Kibana's error)
129
+ 5 network / connection error
130
+
131
+ GOTCHAS
132
+ - CSV export is capped by xpack.reporting.csv.maxSizeBytes (10 MB default).
133
+ Very large exports will fail the job; narrow --query or --last.
134
+ - Omitting --fields exports EVERY field (including _id, _index, _score,
135
+ _type, @version, agent.*). Usually you want an explicit --fields list.
136
+ - --list-fields may be incomplete for read-only roles; you can still pass
137
+ any field name directly.
138
+ """
139
+
140
+
141
+ def help_json():
142
+ return {
143
+ "name": "logalizer",
144
+ "summary": "Export Kibana logs as CSV via the Kibana 7.17 Reporting API",
145
+ "flags": [
146
+ {"flag": "--query", "alias": "-q", "type": "string", "format": "KQL",
147
+ "default": "\"\"", "example": "level:error"},
148
+ {"flag": "--index", "alias": "-i", "type": "string", "format": "index-pattern",
149
+ "required": True, "example": "logs-*"},
150
+ {"flag": "--last", "type": "duration", "format": "30s|15m|1h|24h|7d",
151
+ "default": "24h", "example": "1h"},
152
+ {"flag": "--fields", "type": "csv-list", "default": "all fields",
153
+ "example": "@timestamp,level,msg"},
154
+ {"flag": "--out", "alias": "-o", "type": "path", "default": "stdout"},
155
+ {"flag": "--space", "alias": "-s", "type": "string", "default": "default"},
156
+ {"flag": "--init", "type": "bool", "default": "false"},
157
+ {"flag": "--ping", "type": "bool", "default": "false"},
158
+ {"flag": "--url", "type": "string", "used_by": "--init"},
159
+ {"flag": "--username", "type": "string", "used_by": "--init"},
160
+ {"flag": "--password", "type": "string", "used_by": "--init"},
161
+ ],
162
+ "exit_codes": {"0": "success", "2": "usage", "3": "auth/permission",
163
+ "4": "job failed/timeout", "5": "network"},
164
+ "io_contract": {"stdout": "CSV data", "stderr": "diagnostics"},
165
+ "env": ["KIBANA_URL", "KIBANA_USERNAME", "KIBANA_PASSWORD"],
166
+ }
167
+
168
+
169
+ def build_parser():
170
+ p = argparse.ArgumentParser(
171
+ prog="logalizer",
172
+ description="Export Kibana logs as CSV (Kibana 7.17 Reporting API)",
173
+ formatter_class=argparse.RawDescriptionHelpFormatter,
174
+ epilog=HELP_TEXT,
175
+ )
176
+ p.add_argument("-q", "--query", default="", help="KQL query (default: match all)")
177
+ p.add_argument("-i", "--index", help="index pattern, e.g. 'logs-*'")
178
+ p.add_argument("-s", "--space", help="Kibana space (default: from config, else default)")
179
+ p.add_argument("--last", default="24h", help="relative range: 30s/15m/1h/24h/7d (default 24h)")
180
+ p.add_argument("--from", dest="from_iso", help="absolute range start (ISO 8601)")
181
+ p.add_argument("--to", dest="to_iso", help="absolute range end (ISO 8601)")
182
+ p.add_argument("--fields", help="comma-separated columns (omit = all fields)")
183
+ p.add_argument("-o", "--out", help="write CSV to file (default: stdout)")
184
+ p.add_argument("--timeout", type=int, help="max job wait in seconds (default 120)")
185
+ p.add_argument("--insecure", action="store_true", help="skip TLS verification")
186
+ p.add_argument("-v", "--verbose", action="store_true", help="progress to stderr")
187
+ p.add_argument("--list-spaces", action="store_true", help="list spaces")
188
+ p.add_argument("--list-indices", action="store_true", help="list index patterns")
189
+ p.add_argument("--list-fields", action="store_true", help="list fields for --index")
190
+ p.add_argument("--help-json", action="store_true", help="machine-readable help")
191
+ p.add_argument("--init", action="store_true", help="configure and write config.ini")
192
+ p.add_argument("--ping", action="store_true", help="test config + connectivity")
193
+ p.add_argument("--url", help="Kibana URL (for --init)")
194
+ p.add_argument("--username", help="username (for --init)")
195
+ p.add_argument("--password", help="password (for --init)")
196
+ return p
197
+
198
+
199
+ def _err(msg, code):
200
+ print(f"logalizer: {msg}", file=sys.stderr)
201
+ return code
202
+
203
+
204
+ def main(argv=None):
205
+ args = build_parser().parse_args(argv)
206
+
207
+ if args.help_json:
208
+ print(json.dumps(help_json(), indent=2))
209
+ return 0
210
+
211
+ try:
212
+ cfg = load_config()
213
+ settings = build_settings(
214
+ os.environ, cfg, space=args.space, index=args.index, fields=args.fields,
215
+ timeout=args.timeout, insecure=(True if args.insecure else None),
216
+ )
217
+
218
+ if args.init:
219
+ return init.run_init(args, os.environ, config_path=None)
220
+
221
+ if args.ping:
222
+ return ping.run_ping(settings, config_file_path())
223
+
224
+ if not settings.url or not settings.username or not settings.password:
225
+ return _err(
226
+ "missing credentials. Set KIBANA_URL/KIBANA_USERNAME/KIBANA_PASSWORD "
227
+ "or ~/.config/logalizer/config.ini. Exit code 2.", 2)
228
+
229
+ client = Client(settings.url, settings.username, settings.password,
230
+ insecure=settings.insecure)
231
+
232
+ if args.list_spaces:
233
+ for s in indexpatterns.list_spaces(client):
234
+ print(s)
235
+ return 0
236
+ if args.list_indices:
237
+ for t in indexpatterns.list_index_patterns(client, settings.space):
238
+ print(t)
239
+ return 0
240
+ if args.list_fields:
241
+ if not settings.index:
242
+ return _err("--list-fields requires --index PATTERN", 2)
243
+ for f in indexpatterns.list_fields(client, settings.space, settings.index):
244
+ print(f)
245
+ return 0
246
+
247
+ # export
248
+ if not settings.index:
249
+ return _err("--index is required for export", 2)
250
+ index_id = indexpatterns.resolve_index_pattern(
251
+ client, settings.space, settings.index)
252
+ if not index_id:
253
+ return _err(
254
+ f"index pattern {settings.index!r} not found in space "
255
+ f"{settings.space!r}. Use --list-indices to see available patterns.", 2)
256
+
257
+ time_filter = build_time_filter(args.from_iso, args.to_iso, args.last)
258
+ columns = [c.strip() for c in settings.fields.split(",") if c.strip()] if settings.fields else None
259
+
260
+ if args.verbose:
261
+ print(f"submitting CSV job (index={settings.index}, space={settings.space})...",
262
+ file=sys.stderr)
263
+ job_id = reporting.submit(client, settings.space, index_id,
264
+ args.query, time_filter, columns)
265
+ if args.verbose:
266
+ print(f"job {job_id} submitted, waiting...", file=sys.stderr)
267
+ reporting.poll(client, job_id, timeout=settings.timeout)
268
+ if args.verbose:
269
+ print("job completed, downloading...", file=sys.stderr)
270
+ csv_text = reporting.download(client, job_id)
271
+
272
+ if args.out:
273
+ with open(args.out, "w", encoding="utf-8") as fh:
274
+ fh.write(csv_text)
275
+ else:
276
+ sys.stdout.write(csv_text)
277
+ return 0
278
+
279
+ except ClientError as e:
280
+ return _err(str(e) + f" Exit code {e.exit_code}.", e.exit_code)
281
+ except UsageError as e:
282
+ return _err(str(e) + " Exit code 2.", 2)
283
+ except (ValueError, OSError) as e:
284
+ return _err(str(e) + " Exit code 2.", 2)
logalizer/client.py ADDED
@@ -0,0 +1,58 @@
1
+ """HTTP layer: basic auth + kbn-xsrf header, urllib-backed."""
2
+ import base64
3
+ import json
4
+ import ssl
5
+ import urllib.error
6
+ import urllib.request
7
+
8
+
9
+ class ClientError(Exception):
10
+ def __init__(self, message, exit_code):
11
+ super().__init__(message)
12
+ self.exit_code = exit_code
13
+
14
+
15
+ def raise_for_status(status, body, what):
16
+ if status == 401:
17
+ raise ClientError("authentication failed (401). Check KIBANA_USERNAME/KIBANA_PASSWORD.", 3)
18
+ if status == 403:
19
+ raise ClientError("permission denied (403). Role lacks access to this space.", 3)
20
+ if status >= 400:
21
+ raise ClientError(f"{what} failed ({status}): {body}", 5)
22
+
23
+
24
+ class Client:
25
+ def __init__(self, url, username, password, insecure=False, timeout=30):
26
+ self.base = (url or "").rstrip("/")
27
+ self.auth = "Basic " + base64.b64encode(
28
+ f"{username}:{password}".encode("utf-8")
29
+ ).decode("ascii")
30
+ self.insecure = insecure
31
+ self.timeout = timeout
32
+
33
+ def _context(self):
34
+ if self.insecure:
35
+ ctx = ssl.create_default_context()
36
+ ctx.check_hostname = False
37
+ ctx.verify_mode = ssl.CERT_NONE
38
+ return ctx
39
+ return None
40
+
41
+ def request(self, method, path, body=None):
42
+ data = json.dumps(body).encode("utf-8") if body is not None else None
43
+ headers = {"Authorization": self.auth}
44
+ if data is not None:
45
+ headers["Content-Type"] = "application/json"
46
+ req = urllib.request.Request(
47
+ self.base + path, data=data, method=method, headers=headers
48
+ )
49
+ req.headers["kbn-xsrf"] = "true"
50
+ try:
51
+ with urllib.request.urlopen(req, context=self._context(),
52
+ timeout=self.timeout) as resp:
53
+ raw = resp.read().decode("utf-8")
54
+ return resp.status, raw
55
+ except urllib.error.HTTPError as e:
56
+ return e.code, e.read().decode("utf-8")
57
+ except urllib.error.URLError as e:
58
+ raise ClientError(f"network error: {e.reason}", 5)
logalizer/config.py ADDED
@@ -0,0 +1,102 @@
1
+ """XDG path resolution and credential/settings assembly."""
2
+ import configparser
3
+ import os
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+
8
+ def xdg_config_home() -> Path:
9
+ return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
10
+
11
+
12
+ def xdg_state_home() -> Path:
13
+ return Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local/state"))
14
+
15
+
16
+ def xdg_cache_home() -> Path:
17
+ return Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
18
+
19
+
20
+ def config_file_path() -> Path:
21
+ return xdg_config_home() / "logalizer" / "config.ini"
22
+
23
+
24
+ def load_config(path=None) -> dict:
25
+ """Return parsed config as a nested dict; {} if missing."""
26
+ p = Path(path) if path else config_file_path()
27
+ if not p.exists():
28
+ return {}
29
+ parser = configparser.ConfigParser()
30
+ parser.read(str(p))
31
+ return {section: dict(parser.items(section)) for section in parser.sections()}
32
+
33
+
34
+ def write_config(sections, path=None):
35
+ """Write a nested {section: {key: value}} dict to config.ini, chmod 0600."""
36
+ p = Path(path) if path else config_file_path()
37
+ p.parent.mkdir(parents=True, exist_ok=True)
38
+ parser = configparser.ConfigParser()
39
+ for section, items in sections.items():
40
+ clean = {str(k): str(v) for k, v in items.items()
41
+ if v is not None and v != ""}
42
+ if clean:
43
+ parser[section] = clean
44
+ with open(p, "w", encoding="utf-8") as fh:
45
+ parser.write(fh)
46
+ os.chmod(p, 0o600)
47
+ return p
48
+
49
+
50
+ def _as_bool(value, default=False) -> bool:
51
+ if value is None:
52
+ return default
53
+ return str(value).strip().lower() in ("1", "true", "yes", "on")
54
+
55
+
56
+ @dataclass
57
+ class Settings:
58
+ url: str = ""
59
+ username: str = ""
60
+ password: str = ""
61
+ space: str = "default"
62
+ index: str = None
63
+ fields: str = None
64
+ timeout: int = 120
65
+ insecure: bool = False
66
+
67
+
68
+ def build_settings(env, cfg, space=None, index=None, fields=None,
69
+ timeout=None, insecure=None) -> Settings:
70
+ """Resolve settings with precedence flag > env > config > default."""
71
+ kb = cfg.get("kibana", {})
72
+ df = cfg.get("defaults", {})
73
+
74
+ url = env.get("KIBANA_URL") or kb.get("url", "")
75
+ username = env.get("KIBANA_USERNAME") or kb.get("username", "")
76
+ password = env.get("KIBANA_PASSWORD") or kb.get("password", "")
77
+
78
+ resolved_space = space or env.get("KIBANA_SPACE") or df.get("space", "default")
79
+
80
+ resolved_timeout = timeout
81
+ if resolved_timeout is None and df.get("timeout"):
82
+ resolved_timeout = int(df["timeout"])
83
+ if resolved_timeout is None:
84
+ resolved_timeout = 120
85
+
86
+ if insecure is None:
87
+ env_val = env.get("KIBANA_INSECURE")
88
+ if env_val is not None:
89
+ insecure = _as_bool(env_val)
90
+ else:
91
+ insecure = _as_bool(kb.get("insecure"))
92
+
93
+ return Settings(
94
+ url=url,
95
+ username=username,
96
+ password=password,
97
+ space=resolved_space,
98
+ index=index if index else df.get("index"),
99
+ fields=fields if fields else df.get("fields"),
100
+ timeout=resolved_timeout,
101
+ insecure=insecure,
102
+ )
@@ -0,0 +1,57 @@
1
+ """Discovery helpers: spaces, index patterns, and pattern→data-view-ID resolution."""
2
+ import json
3
+
4
+ from logalizer.client import raise_for_status
5
+
6
+
7
+ def list_spaces(client):
8
+ status, body = client.request("GET", "/api/spaces/space")
9
+ raise_for_status(status, body, "list spaces")
10
+ return [s["id"] for s in json.loads(body)]
11
+
12
+
13
+ def list_index_patterns(client, space):
14
+ path = f"/s/{space}/api/saved_objects/_find?type=index-pattern&per_page=100"
15
+ status, body = client.request("GET", path)
16
+ raise_for_status(status, body, "list index patterns")
17
+ data = json.loads(body)
18
+ return [so["attributes"]["title"] for so in data.get("saved_objects", [])]
19
+
20
+
21
+ def resolve_index_pattern(client, space, pattern):
22
+ """Return the data-view ID for a pattern title, or None."""
23
+ path = f"/s/{space}/api/saved_objects/_find?type=index-pattern&per_page=100"
24
+ status, body = client.request("GET", path)
25
+ raise_for_status(status, body, "resolve index pattern")
26
+ for so in json.loads(body).get("saved_objects", []):
27
+ if so["attributes"].get("title") == pattern:
28
+ return so["id"]
29
+ return None
30
+
31
+
32
+ def list_fields(client, space, pattern):
33
+ """Best-effort field listing. Tries _fields_for_wildcard, then falls back
34
+ to the index-pattern's fieldAttrs. May return [] for read-only roles."""
35
+ path = (f"/s/{space}/api/index_patterns/_fields_for_wildcard"
36
+ f"?pattern={pattern}&meta_fields=_source")
37
+ status, body = client.request("GET", path)
38
+ if status == 200:
39
+ try:
40
+ fields = json.loads(body).get("fields", [])
41
+ if fields:
42
+ return [f["name"] for f in fields]
43
+ except (ValueError, KeyError):
44
+ pass
45
+
46
+ # fallback: index-pattern saved object fieldAttrs
47
+ path = f"/s/{space}/api/saved_objects/_find?type=index-pattern&per_page=100"
48
+ status, body = client.request("GET", path)
49
+ raise_for_status(status, body, "list fields")
50
+ for so in json.loads(body).get("saved_objects", []):
51
+ if so["attributes"].get("title") == pattern:
52
+ raw = so["attributes"].get("fieldAttrs", "{}")
53
+ try:
54
+ return sorted(json.loads(raw).keys())
55
+ except (ValueError, TypeError):
56
+ return []
57
+ return []
logalizer/init.py ADDED
@@ -0,0 +1,125 @@
1
+ """Interactive / non-interactive configuration writer."""
2
+ import getpass
3
+ import json
4
+ import sys
5
+
6
+ from logalizer import indexpatterns
7
+ from logalizer.client import Client, ClientError
8
+ from logalizer.config import _as_bool, load_config, write_config
9
+
10
+
11
+ def _prompt(label, default):
12
+ suffix = f" [{default}]" if default else ""
13
+ val = input(f"{label}{suffix}: ").strip()
14
+ return val if val else default
15
+
16
+
17
+ def _prompt_password(default):
18
+ suffix = " [set]" if default else ""
19
+ val = getpass.getpass(f"Password{suffix} (blank to keep): ")
20
+ return val if val else default
21
+
22
+
23
+ def _connect(url, username, password, insecure):
24
+ """Return (client, message) on success, or (None, error) on failure."""
25
+ client = Client(url, username, password, insecure=insecure)
26
+ try:
27
+ status, body = client.request("GET", "/api/status")
28
+ except ClientError as e:
29
+ return None, f"connection failed: {e}"
30
+ version = "?"
31
+ if status == 200:
32
+ try:
33
+ version = json.loads(body)["version"]["number"]
34
+ except (ValueError, KeyError, TypeError):
35
+ pass
36
+ status, body = client.request("GET", "/internal/security/me")
37
+ if status in (401, 403):
38
+ return None, "authentication failed (wrong username/password?)"
39
+ if status != 200:
40
+ return None, f"auth endpoint returned HTTP {status}"
41
+ username_real = json.loads(body).get("username", "unknown")
42
+ return client, f"Kibana {version} as {username_real}"
43
+
44
+
45
+ def _pick_numbered(items, title, default):
46
+ """Offer a numbered list; return selection, typed name, or default on blank.
47
+ If items is None (listing failed), fall back to free-text."""
48
+ if items:
49
+ print(f"{title}:")
50
+ for i, name in enumerate(items, 1):
51
+ print(f" [{i}] {name}")
52
+ suffix = f" [{default}]" if default else ""
53
+ val = input(f"Pick a number, type a name, or Enter{suffix}: ").strip()
54
+ if val == "":
55
+ return default
56
+ if val.isdigit() and 1 <= int(val) <= len(items):
57
+ return items[int(val) - 1]
58
+ return val
59
+ return _prompt(title, default)
60
+
61
+
62
+ def run_init(args, env, config_path=None):
63
+ existing = load_config(config_path)
64
+ kb = existing.get("kibana", {})
65
+ df = existing.get("defaults", {})
66
+
67
+ url = args.url or kb.get("url", "")
68
+ username = args.username or kb.get("username", "")
69
+ password = args.password or env.get("KIBANA_PASSWORD") or kb.get("password", "")
70
+ space = args.space or df.get("space", "default")
71
+ index = args.index or df.get("index", "")
72
+ insecure = args.insecure if args.insecure else _as_bool(kb.get("insecure"))
73
+
74
+ interactive = not (url and username and password)
75
+
76
+ if interactive:
77
+ print("Configuring logalizer (blank = keep existing)", file=sys.stderr)
78
+ url = _prompt("Kibana URL", url)
79
+ username = _prompt("Username", username)
80
+ password = _prompt_password(password)
81
+ ans = _prompt("Skip TLS verification? (y/N)", "y" if insecure else "")
82
+ insecure = ans.strip().lower() in ("y", "yes")
83
+
84
+ while True:
85
+ client, msg = _connect(url, username, password, insecure)
86
+ if client is not None:
87
+ print(f" ✓ {msg}")
88
+ break
89
+ print(f" ✗ {msg} — fix and retry")
90
+ url = _prompt("Kibana URL", url)
91
+ username = _prompt("Username", username)
92
+ password = _prompt_password(password)
93
+ ans = _prompt("Skip TLS verification? (y/N)", "y" if insecure else "")
94
+ insecure = ans.strip().lower() in ("y", "yes")
95
+
96
+ try:
97
+ spaces = indexpatterns.list_spaces(client)
98
+ except ClientError:
99
+ spaces = None
100
+ space = _pick_numbered(spaces, "Available spaces", space)
101
+
102
+ try:
103
+ indices = indexpatterns.list_index_patterns(client, space)
104
+ except ClientError:
105
+ indices = None
106
+ index = _pick_numbered(indices, f"Index patterns in '{space}'", index)
107
+
108
+ if not url or not username or not password:
109
+ print("logalizer: url, username and password are required.", file=sys.stderr)
110
+ return 2
111
+
112
+ merged = {section: dict(items) for section, items in existing.items()}
113
+ merged.setdefault("kibana", {}).update({
114
+ "url": url, "username": username, "password": password,
115
+ "insecure": "true" if insecure else "false",
116
+ })
117
+ merged.setdefault("defaults", {}).update({"space": space})
118
+ if index:
119
+ merged["defaults"]["index"] = index
120
+ else:
121
+ merged["defaults"].pop("index", None)
122
+
123
+ path = write_config(merged, path=config_path)
124
+ print(f"Wrote {path} (password: ***)")
125
+ return 0
logalizer/ping.py ADDED
@@ -0,0 +1,78 @@
1
+ """Config + connectivity health check."""
2
+ import json
3
+
4
+ from logalizer.client import Client, ClientError
5
+
6
+
7
+ def _parse_version(body):
8
+ try:
9
+ return json.loads(body)["version"]["number"]
10
+ except (ValueError, KeyError, TypeError):
11
+ return "unknown"
12
+
13
+
14
+ def run_ping(settings, config_path):
15
+ print("logalizer ping")
16
+
17
+ if config_path.exists():
18
+ print(f" ✓ config: {config_path}")
19
+ else:
20
+ print(f" - no config file (using env vars) — run `logalizer --init` to persist")
21
+
22
+ if settings.url and settings.username and settings.password:
23
+ print(f" ✓ credentials present (user={settings.username}, password=***)")
24
+ else:
25
+ print(" ✗ missing credentials (url/username/password) — run `logalizer --init` "
26
+ "or set KIBANA_URL/KIBANA_USERNAME/KIBANA_PASSWORD")
27
+ return 2
28
+
29
+ client = Client(settings.url, settings.username, settings.password,
30
+ insecure=settings.insecure)
31
+
32
+ try:
33
+ status, body = client.request("GET", "/api/status")
34
+ except ClientError as e:
35
+ print(f" ✗ TLS/connectivity: {e}")
36
+ return 5
37
+ version = _parse_version(body) if status == 200 else "?"
38
+ print(f" ✓ TLS + connectivity (Kibana {version})")
39
+
40
+ status, body = client.request("GET", "/internal/security/me")
41
+ if status in (401, 403):
42
+ print(" ✗ auth failed (401/403) — check KIBANA_USERNAME/KIBANA_PASSWORD")
43
+ return 3
44
+ if status != 200:
45
+ print(f" ✗ auth endpoint returned HTTP {status}")
46
+ return 5
47
+ username = json.loads(body).get("username", "unknown")
48
+ roles = json.loads(body).get("roles", [])
49
+ print(f" ✓ auth OK — logged in as {username} (roles: {', '.join(roles)})")
50
+
51
+ if not settings.space:
52
+ print(" - space check skipped (no space configured)")
53
+ else:
54
+ status, body = client.request("GET", "/api/spaces/space")
55
+ if status != 200:
56
+ print(f" ✗ spaces list HTTP {status}")
57
+ return 5
58
+ spaces = [s["id"] for s in json.loads(body)]
59
+ if settings.space in spaces:
60
+ print(f" ✓ space '{settings.space}' exists")
61
+ else:
62
+ avail = ", ".join(spaces) or "none"
63
+ print(f" ✗ space '{settings.space}' not found (available: {avail})")
64
+ return 2
65
+
66
+ if not settings.index:
67
+ print(" - index check skipped (no index configured)")
68
+ else:
69
+ from logalizer.indexpatterns import resolve_index_pattern
70
+ index_id = resolve_index_pattern(client, settings.space or "default", settings.index)
71
+ if index_id:
72
+ print(f" ✓ index '{settings.index}' resolves → data-view {index_id}")
73
+ else:
74
+ print(f" ✗ index '{settings.index}' not found in space '{settings.space or 'default'}'")
75
+ return 2
76
+
77
+ print("All checks passed.")
78
+ return 0
logalizer/reporting.py ADDED
@@ -0,0 +1,60 @@
1
+ """Async CSV reporting job lifecycle: submit → poll → download."""
2
+ import json
3
+ import time
4
+
5
+ from logalizer import rison
6
+ from logalizer.client import ClientError, raise_for_status
7
+
8
+ KIBANA_VERSION = "7.17.29"
9
+
10
+
11
+ def submit(client, space, index_id, query, time_filter, columns):
12
+ search_source = {
13
+ "index": index_id,
14
+ "query": {"query": query, "language": "kuery"},
15
+ }
16
+ if time_filter is not None:
17
+ search_source["filter"] = [time_filter]
18
+ job_params = {
19
+ "title": "logalizer export",
20
+ "objectType": "search",
21
+ "browserTimezone": "UTC",
22
+ "version": KIBANA_VERSION,
23
+ "searchSource": search_source,
24
+ }
25
+ if columns:
26
+ job_params["columns"] = columns
27
+ body = {"jobParams": rison.encode(job_params)}
28
+ path = f"/s/{space}/api/reporting/generate/csv_searchsource"
29
+ status, raw = client.request("POST", path, body)
30
+ raise_for_status(status, raw, "submit CSV job")
31
+ return json.loads(raw)["job"]["id"]
32
+
33
+
34
+ def poll(client, job_id, timeout=120):
35
+ deadline = time.time() + timeout
36
+ while time.time() < deadline:
37
+ status, raw = client.request(
38
+ "GET", "/api/reporting/jobs/list?page=0&size=100"
39
+ )
40
+ raise_for_status(status, raw, "poll job")
41
+ for job in json.loads(raw):
42
+ if job["id"] == job_id:
43
+ if job["status"] == "completed":
44
+ return
45
+ if job["status"] == "failed":
46
+ out = job.get("output") or {}
47
+ detail = out.get("warnings") or out.get("error") or out
48
+ raise ClientError(
49
+ f"job {job_id} failed: {detail!r}. "
50
+ "Try narrowing --query or --last.", 4)
51
+ time.sleep(2)
52
+ raise ClientError(f"job {job_id} did not complete within {timeout}s", 4)
53
+
54
+
55
+ def download(client, job_id):
56
+ status, raw = client.request(
57
+ "GET", f"/api/reporting/jobs/download/{job_id}"
58
+ )
59
+ raise_for_status(status, raw, "download CSV")
60
+ return raw
logalizer/rison.py ADDED
@@ -0,0 +1,35 @@
1
+ """Minimal rison encoder (the wire format Kibana's reporting API expects).
2
+
3
+ Rison: strings '...' (! -> !!, ' -> !'), null !n, true !t, false !f,
4
+ array !(...), object (key:value,...). Keys matching an identifier are bare;
5
+ all other keys are quoted.
6
+ """
7
+ import re
8
+
9
+ _ID_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*$")
10
+
11
+
12
+ def _escape(s):
13
+ return s.replace("!", "!!").replace("'", "!'")
14
+
15
+
16
+ def encode(value):
17
+ if value is None:
18
+ return "!n"
19
+ if value is True:
20
+ return "!t"
21
+ if value is False:
22
+ return "!f"
23
+ if isinstance(value, str):
24
+ return "'" + _escape(value) + "'"
25
+ if isinstance(value, (int, float)):
26
+ return str(value)
27
+ if isinstance(value, dict):
28
+ parts = []
29
+ for k, v in value.items():
30
+ key = k if _ID_RE.match(k) else "'" + _escape(k) + "'"
31
+ parts.append(key + ":" + encode(v))
32
+ return "(" + ",".join(parts) + ")"
33
+ if isinstance(value, (list, tuple)):
34
+ return "!(" + ",".join(encode(v) for v in value) + ")"
35
+ raise TypeError(f"cannot rison-encode {type(value).__name__}")
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: logalizer
3
+ Version: 0.1.0
4
+ Summary: Export Kibana logs as CSV from the command line via the Kibana Reporting API.
5
+ Author: isdmx
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/isdmx/logalizer
8
+ Project-URL: Repository, https://github.com/isdmx/logalizer
9
+ Keywords: kibana,elasticsearch,csv,logs,export,cli
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Topic :: System :: Logging
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Dynamic: license-file
22
+
23
+ # logalizer
24
+
25
+ Export Kibana logs as CSV from the command line — via the Kibana 7.17
26
+ Reporting API. No third-party dependencies (pure Python stdlib).
27
+
28
+ `logalizer` authenticates to Kibana with basic auth, submits a CSV reporting
29
+ job, polls until it completes, and writes the result to stdout or a file. It is
30
+ built for both humans and scripts: clean exit codes and a strict stdout/stderr
31
+ separation so `logalizer ... > out.csv` always yields only CSV on stdout.
32
+
33
+ ## Install
34
+
35
+ pip install logalizer
36
+
37
+ Requires Python 3.10+.
38
+
39
+ ## Configure
40
+
41
+ logalizer --init
42
+
43
+ Interactive wizard that validates your credentials and lets you pick a space and
44
+ index pattern from live lists. Or set environment variables:
45
+
46
+ export KIBANA_URL=https://...
47
+ export KIBANA_USERNAME=...
48
+ export KIBANA_PASSWORD=...
49
+
50
+ ## Usage
51
+
52
+ # test connectivity and configuration
53
+ logalizer --ping
54
+
55
+ # discover what's available
56
+ logalizer --list-spaces
57
+ logalizer --list-indices --space <space>
58
+ logalizer --list-fields --index '<pattern>'
59
+
60
+ # export logs as CSV
61
+ logalizer -i '<index-pattern>' --query 'level:error' --last 24h \
62
+ --fields '@timestamp,level,msg' -o errors.csv
63
+
64
+ Run `logalizer --help` for the full reference and `logalizer --help-json` for a
65
+ machine-readable flag schema.
66
+
67
+ ## Release
68
+
69
+ Publishing is automated via GitHub Actions Trusted Publishing.
70
+
71
+ One-time setup on PyPI: project settings → Publishing → add a "pending
72
+ publisher" with owner `isdmx`, repository `logalizer`, workflow `publish.yml`,
73
+ environment `pypi`.
74
+
75
+ To cut a release, either use the GitHub Actions UI (Actions → "Release" → "Run
76
+ workflow", enter a version like 0.1.0) or from a local checkout:
77
+
78
+ git tag v0.1.0
79
+ git push --tags
80
+
81
+ Either way, CI builds the sdist + wheel and uploads them to PyPI automatically.
82
+
83
+ ## License
84
+
85
+ MIT
@@ -0,0 +1,16 @@
1
+ logalizer/__init__.py,sha256=M_m8V_l5YNOgfQvudDFxHF3Gr3t_o3V5Xqv9Z1rJwpw,274
2
+ logalizer/__main__.py,sha256=RWhM-Y1tMFNj4jILR4IQEIu1w9GNkt-B0BoqHwcg2uk,88
3
+ logalizer/cli.py,sha256=diVnJ28oxHwzW0EJ9BB9mW1MKvWKtdYOOx7hb5Li6ks,12401
4
+ logalizer/client.py,sha256=zli8YTBqK6y7ua-FEphFzWQzrT1A9QpNW3g759-V9mc,2073
5
+ logalizer/config.py,sha256=thLnhAQzjpouTrhlSokJw3XFKrrNrfHcHbGHJG2UdDc,3102
6
+ logalizer/indexpatterns.py,sha256=6jObERRK8WizNHgve0W-xqKOlO0LR4uR5yDgvJSh7u4,2271
7
+ logalizer/init.py,sha256=AcQgy_jW2CvhukpY1DW9fB4owUYSQDEgF3dkSiLuY0o,4676
8
+ logalizer/ping.py,sha256=rcCAa2JV8CylPD006ygif6wGIUJVWbwKvT3e27-85CM,2879
9
+ logalizer/reporting.py,sha256=0qToXB40DfLd1GPNRUR2Ztg3QLNKoEkfD0RYNBPRo0Y,2036
10
+ logalizer/rison.py,sha256=poIgsThnBwRSEm591FB6BOipTEdcGs5Nq0n6TjXPlGI,1084
11
+ logalizer-0.1.0.dist-info/licenses/LICENSE,sha256=xc4IY7C5WXvCeZlnKy2FUXMnZzkBPso8lbI8pi5FAOk,1062
12
+ logalizer-0.1.0.dist-info/METADATA,sha256=Ot4We1NKbTK-bg0MWvKhmDclPzchFDbxMOckA6cVUoE,2661
13
+ logalizer-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
14
+ logalizer-0.1.0.dist-info/entry_points.txt,sha256=R-_mtpglMTe7FNSoc3vapDAM54LNX49BUVP8ysI8bh0,49
15
+ logalizer-0.1.0.dist-info/top_level.txt,sha256=3fEVfUDMmKdVV1y-n9xZyKLFNGyBof5omlzyR46p-Rk,10
16
+ logalizer-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ logalizer = logalizer.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 isdmx
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ logalizer