openobserve-cli 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.
oo_cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """CLI for the OpenObserve HTTP API."""
2
+
3
+ __version__ = "0.1.0"
oo_cli/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from oo_cli.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
oo_cli/cli.py ADDED
@@ -0,0 +1,270 @@
1
+ """Command line entry point.
2
+
3
+ Commands are a transcription of the API: `oo get dashboards` is
4
+ `GET /api/{org}/dashboards`. Where the instance offers a v2 of an endpoint, the
5
+ v2 one is used; the spec module decides that, there is no version switch.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import sys
13
+ from typing import TextIO
14
+
15
+ import httpx
16
+
17
+ from oo_cli import __version__
18
+ from oo_cli import spec as spec_module
19
+ from oo_cli.client import Client, HTTPError, OOError
20
+ from oo_cli.config import Config, ConfigError
21
+ from oo_cli.timeutil import TimeError, to_micros
22
+
23
+ VERBS = ("get", "post", "put", "patch", "delete")
24
+
25
+ EPILOG = """\
26
+ examples:
27
+ oo get dashboards
28
+ oo get alerts --folder default -> /api/v2/{org}/alerts?folder=default
29
+ oo get streams/app_logs
30
+ oo post alerts -f alert.json
31
+ oo delete dashboards/0194f0e1 --folder default
32
+ oo search --sql "select * from default limit 10" --from -30m
33
+ oo api GET /api/{org}/prometheus/api/v1/query --query "up"
34
+
35
+ environment:
36
+ OO_ENDPOINT base URL, default http://localhost:5080
37
+ OO_ORG organization, default "default"
38
+ OO_TOKEN base64 of "email:token", sent as HTTP basic auth
39
+ OO_USER alternative to OO_TOKEN, together with OO_PASSWORD
40
+ OO_COOKIE Cookie header for a gateway in front of OpenObserve
41
+ OO_TIMEOUT request timeout in seconds, default 60
42
+
43
+ Any other --name value or --name=value is passed through as a query parameter.
44
+ """
45
+
46
+
47
+ class UsageError(Exception):
48
+ pass
49
+
50
+
51
+ def main(argv: list[str] | None = None) -> int:
52
+ try:
53
+ return run(sys.argv[1:] if argv is None else argv)
54
+ except UsageError as exc:
55
+ print(f"oo: {exc}", file=sys.stderr)
56
+ return 2
57
+ except (ConfigError, TimeError) as exc:
58
+ print(f"oo: {exc}", file=sys.stderr)
59
+ return 2
60
+ except HTTPError as exc:
61
+ print(f"oo: {exc}", file=sys.stderr)
62
+ _emit(exc.response, raw=False, stream=sys.stderr)
63
+ return 1
64
+ except OOError as exc:
65
+ print(f"oo: {exc}", file=sys.stderr)
66
+ return 1
67
+ except BrokenPipeError:
68
+ return 0
69
+
70
+
71
+ def run(argv: list[str]) -> int:
72
+ parser = _parser()
73
+ args, extras = parser.parse_known_args(_glue_offsets(argv))
74
+ if not args.command:
75
+ parser.print_help()
76
+ return 2
77
+
78
+ config = Config.from_env(endpoint=args.endpoint, org=args.org, timeout=args.timeout)
79
+ with Client(config) as client:
80
+ if args.command == "spec":
81
+ return _run_spec(client, args, extras)
82
+ if args.command == "search":
83
+ return _run_search(client, args, extras)
84
+ if args.command == "api":
85
+ return _run_api(client, args, extras)
86
+ return _run_verb(client, args, extras)
87
+
88
+
89
+ #: Options whose value starts with a minus (--from -30m), which argparse reads as an option.
90
+ _OFFSET_OPTIONS = ("--from", "--to")
91
+
92
+
93
+ def _glue_offsets(argv: list[str]) -> list[str]:
94
+ glued: list[str] = []
95
+ index = 0
96
+ while index < len(argv):
97
+ token = argv[index]
98
+ following = argv[index + 1] if index + 1 < len(argv) else None
99
+ if token in _OFFSET_OPTIONS and following is not None and following.startswith("-"):
100
+ glued.append(f"{token}={following}")
101
+ index += 2
102
+ continue
103
+ glued.append(token)
104
+ index += 1
105
+ return glued
106
+
107
+
108
+ def _parser() -> argparse.ArgumentParser:
109
+ parser = argparse.ArgumentParser(
110
+ prog="oo",
111
+ description="Talk to the OpenObserve API.",
112
+ epilog=EPILOG,
113
+ formatter_class=argparse.RawDescriptionHelpFormatter,
114
+ allow_abbrev=False,
115
+ )
116
+ parser.add_argument("--version", action="version", version=f"oo {__version__}")
117
+ parser.add_argument("--endpoint", help="OpenObserve base URL (env OO_ENDPOINT)")
118
+ parser.add_argument("--org", help="organization (env OO_ORG)")
119
+ parser.add_argument("--timeout", type=float, help="request timeout in seconds")
120
+ parser.add_argument("--raw", action="store_true", help="print the response body unformatted")
121
+
122
+ sub = parser.add_subparsers(dest="command")
123
+
124
+ for verb in VERBS:
125
+ p = sub.add_parser(
126
+ verb,
127
+ help=f"{verb.upper()} a resource, e.g. oo {verb} dashboards",
128
+ allow_abbrev=False,
129
+ )
130
+ p.add_argument(
131
+ "resource", help='resource path under the org, e.g. "alerts" or "alerts/<id>"'
132
+ )
133
+ _add_body_arguments(p)
134
+
135
+ p = sub.add_parser("api", help="call any path verbatim", allow_abbrev=False)
136
+ p.add_argument("method", help="HTTP method")
137
+ p.add_argument("path", help="full path, e.g. /api/default/streams")
138
+ _add_body_arguments(p)
139
+
140
+ p = sub.add_parser("search", help="run a SQL search", allow_abbrev=False)
141
+ p.add_argument("--sql", required=True, help="SQL query, e.g. select * from cloudwatch_logs")
142
+ p.add_argument("--from", dest="start", default="-1h", help="start time, default -1h")
143
+ p.add_argument("--to", dest="end", default="now", help="end time, default now")
144
+ p.add_argument("--size", type=int, default=100, help="number of rows, default 100")
145
+ p.add_argument("--offset", type=int, default=0, help="row offset, default 0")
146
+ p.add_argument("--type", default="logs", help="stream type: logs, metrics or traces")
147
+
148
+ p = sub.add_parser("spec", help="inspect the instance's OpenAPI document", allow_abbrev=False)
149
+ p.add_argument(
150
+ "action", choices=("paths", "refresh"), help="list endpoints or refetch the spec"
151
+ )
152
+ p.add_argument("needle", nargs="?", help="substring filter for paths")
153
+
154
+ return parser
155
+
156
+
157
+ def _add_body_arguments(parser: argparse.ArgumentParser) -> None:
158
+ parser.add_argument("-d", "--data", help="request body, @file or @- for stdin")
159
+ parser.add_argument("-f", "--file", help="request body read from a file")
160
+
161
+
162
+ def _run_verb(client: Client, args: argparse.Namespace, extras: list[str]) -> int:
163
+ segments = [s for s in args.resource.strip("/").split("/") if s]
164
+ if not segments:
165
+ raise UsageError("a resource is required, e.g. oo get dashboards")
166
+
167
+ spec = spec_module.load(client)
168
+ resolution = spec.resolve(client.config.org, segments, args.command)
169
+ if spec and not resolution.matched:
170
+ print(
171
+ f"oo: {args.command.upper()} {resolution.path} is not in the spec, sending it anyway",
172
+ file=sys.stderr,
173
+ )
174
+ response = client.request(
175
+ args.command, resolution.path, params=_params(extras), body=_body(args)
176
+ )
177
+ _emit(response, args.raw)
178
+ return 0
179
+
180
+
181
+ def _run_api(client: Client, args: argparse.Namespace, extras: list[str]) -> int:
182
+ path = args.path if args.path.startswith("/") else "/" + args.path
183
+ response = client.request(args.method, path, params=_params(extras), body=_body(args))
184
+ _emit(response, args.raw)
185
+ return 0
186
+
187
+
188
+ def _run_search(client: Client, args: argparse.Namespace, extras: list[str]) -> int:
189
+ body = {
190
+ "query": {
191
+ "sql": args.sql,
192
+ "start_time": to_micros(args.start),
193
+ "end_time": to_micros(args.end),
194
+ "from": args.offset,
195
+ "size": args.size,
196
+ }
197
+ }
198
+ params = [("type", args.type), *_params(extras)]
199
+ response = client.request(
200
+ "POST", f"/api/{client.config.org}/_search", params=params, body=json.dumps(body).encode()
201
+ )
202
+ _emit(response, args.raw)
203
+ return 0
204
+
205
+
206
+ def _run_spec(client: Client, args: argparse.Namespace, extras: list[str]) -> int:
207
+ if extras:
208
+ raise UsageError(f"unexpected argument {extras[0]}")
209
+ spec = spec_module.load(client, refresh=args.action == "refresh")
210
+ if not spec:
211
+ raise OOError("could not read the OpenAPI document from the instance")
212
+ for path, methods in spec.paths(args.needle):
213
+ print(f"{' '.join(sorted(methods)).upper():<28} {path}")
214
+ return 0
215
+
216
+
217
+ def _params(extras: list[str]) -> list[tuple[str, str]]:
218
+ """Turn leftover --name value / --name=value pairs into query parameters."""
219
+ params: list[tuple[str, str]] = []
220
+ index = 0
221
+ while index < len(extras):
222
+ token = extras[index]
223
+ if not token.startswith("--"):
224
+ raise UsageError(f"unexpected argument {token}")
225
+ name, _, value = token[2:].partition("=")
226
+ if not name:
227
+ raise UsageError(f"unexpected argument {token}")
228
+ if not _:
229
+ following = extras[index + 1] if index + 1 < len(extras) else None
230
+ if following is not None and not following.startswith("--"):
231
+ value = following
232
+ index += 1
233
+ else:
234
+ value = "true"
235
+ params.append((name, value))
236
+ index += 1
237
+ return params
238
+
239
+
240
+ def _body(args: argparse.Namespace) -> bytes | None:
241
+ data: str | None = getattr(args, "data", None)
242
+ file: str | None = getattr(args, "file", None)
243
+ if data and file:
244
+ raise UsageError("use either -d or -f, not both")
245
+ if file:
246
+ data = "@" + file
247
+ if not data:
248
+ return None
249
+ if not data.startswith("@"):
250
+ return data.encode()
251
+ source = data[1:]
252
+ if source == "-":
253
+ return sys.stdin.buffer.read()
254
+ try:
255
+ with open(source, "rb") as handle:
256
+ return handle.read()
257
+ except OSError as exc:
258
+ raise UsageError(f"cannot read {source}: {exc}") from exc
259
+
260
+
261
+ def _emit(response: httpx.Response, raw: bool, stream: TextIO = sys.stdout) -> None:
262
+ text = response.text
263
+ if not text.strip():
264
+ return
265
+ if not raw and "json" in response.headers.get("content-type", ""):
266
+ try:
267
+ text = json.dumps(response.json(), indent=2, ensure_ascii=False)
268
+ except ValueError:
269
+ pass
270
+ print(text, file=stream)
oo_cli/client.py ADDED
@@ -0,0 +1,95 @@
1
+ """Thin HTTP client for the OpenObserve API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from oo_cli.config import Config
10
+
11
+
12
+ class OOError(Exception):
13
+ """Anything that should end the process with a message and a non-zero code."""
14
+
15
+
16
+ class HTTPError(OOError):
17
+ def __init__(self, response: httpx.Response) -> None:
18
+ self.response = response
19
+ super().__init__(f"{response.status_code} {response.reason_phrase} {response.request.url}")
20
+
21
+
22
+ class Client:
23
+ def __init__(self, config: Config) -> None:
24
+ self.config = config
25
+ headers = {"Accept": "application/json"}
26
+ if config.authorization:
27
+ headers["Authorization"] = config.authorization
28
+ if config.cookie:
29
+ headers["Cookie"] = config.cookie
30
+ self._http = httpx.Client(
31
+ base_url=config.endpoint,
32
+ headers=headers,
33
+ timeout=config.timeout,
34
+ follow_redirects=False,
35
+ )
36
+
37
+ def __enter__(self) -> Client:
38
+ return self
39
+
40
+ def __exit__(self, *exc: object) -> None:
41
+ self._http.close()
42
+
43
+ def request(
44
+ self,
45
+ method: str,
46
+ path: str,
47
+ params: list[tuple[str, str]] | None = None,
48
+ body: bytes | None = None,
49
+ ) -> httpx.Response:
50
+ headers = {"Content-Type": "application/json"} if body is not None else None
51
+ try:
52
+ response = self._http.request(
53
+ method.upper(),
54
+ path,
55
+ # A tuple, because httpx types its list of pairs invariantly.
56
+ params=tuple(params) if params is not None else None,
57
+ content=body,
58
+ headers=headers,
59
+ )
60
+ except httpx.ConnectError as exc:
61
+ raise OOError(
62
+ f"{self.config.endpoint} is unreachable: {exc}\n{_UNREACHABLE_HINT}"
63
+ ) from exc
64
+ except httpx.HTTPError as exc:
65
+ raise OOError(f"request to {self.config.endpoint}{path} failed: {exc}") from exc
66
+
67
+ _reject_gateway(response)
68
+ if response.status_code >= 400:
69
+ raise HTTPError(response)
70
+ return response
71
+
72
+ def json(self, method: str, path: str, **kwargs: Any) -> Any:
73
+ return self.request(method, path, **kwargs).json()
74
+
75
+
76
+ _UNREACHABLE_HINT = "Set OO_ENDPOINT to a reachable OpenObserve, or start your port-forward."
77
+
78
+ _GATEWAY_HINT = (
79
+ "Something in front of OpenObserve is authenticating users itself and only takes its\n"
80
+ "own session cookie, which no API token replaces. Copy the Cookie header out of the\n"
81
+ "browser that is signed in (devtools, Network tab) and pass it along:\n"
82
+ ' export OO_COOKIE="AWSELBAuthSessionCookie-0=...; AWSELBAuthSessionCookie-1=..."'
83
+ )
84
+
85
+
86
+ def _reject_gateway(response: httpx.Response) -> None:
87
+ """Explain a redirect to an identity provider instead of leaving a parse error behind."""
88
+ location = response.headers.get("location", "")
89
+ if not response.is_redirect or not location.startswith(("http://", "https://")):
90
+ return
91
+ if httpx.URL(location).host == response.request.url.host:
92
+ return
93
+ raise OOError(
94
+ f"{response.request.url} redirected to {httpx.URL(location).host}\n{_GATEWAY_HINT}"
95
+ )
oo_cli/config.py ADDED
@@ -0,0 +1,62 @@
1
+ """Endpoint, organization and credentials, all from the environment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import os
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+
10
+ DEFAULT_ENDPOINT = "http://localhost:5080"
11
+ DEFAULT_ORG = "default"
12
+ DEFAULT_TIMEOUT = 60.0
13
+
14
+
15
+ class ConfigError(Exception):
16
+ pass
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Config:
21
+ endpoint: str
22
+ org: str
23
+ authorization: str | None
24
+ cookie: str | None
25
+ timeout: float
26
+
27
+ @classmethod
28
+ def from_env(
29
+ cls,
30
+ env: Mapping[str, str] | None = None,
31
+ endpoint: str | None = None,
32
+ org: str | None = None,
33
+ timeout: float | None = None,
34
+ ) -> Config:
35
+ values: Mapping[str, str] = os.environ if env is None else env
36
+ return cls(
37
+ endpoint=(endpoint or values.get("OO_ENDPOINT") or DEFAULT_ENDPOINT).rstrip("/"),
38
+ org=org or values.get("OO_ORG") or DEFAULT_ORG,
39
+ authorization=_authorization(values),
40
+ cookie=values.get("OO_COOKIE") or None,
41
+ timeout=timeout or float(values.get("OO_TIMEOUT") or DEFAULT_TIMEOUT),
42
+ )
43
+
44
+
45
+ def _authorization(env: Mapping[str, str]) -> str | None:
46
+ """Build the Authorization header value.
47
+
48
+ OO_TOKEN is the credential OpenObserve hands out for a service account (base64
49
+ of "email:token"); it is sent as-is when it already names its scheme.
50
+ OO_USER + OO_PASSWORD is the same thing spelled out.
51
+ """
52
+ token = env.get("OO_TOKEN")
53
+ if token:
54
+ token = token.strip()
55
+ return token if " " in token else f"Basic {token}"
56
+
57
+ user, password = env.get("OO_USER"), env.get("OO_PASSWORD")
58
+ if user and password:
59
+ return "Basic " + base64.b64encode(f"{user}:{password}".encode()).decode()
60
+ if user or password:
61
+ raise ConfigError("OO_USER and OO_PASSWORD have to be set together")
62
+ return None
oo_cli/py.typed ADDED
File without changes
oo_cli/spec.py ADDED
@@ -0,0 +1,129 @@
1
+ """The instance's own OpenAPI document, used to route a command to v1 or v2.
2
+
3
+ OpenObserve serves its spec at /api-doc/openapi.json, so the command surface follows
4
+ whatever version is deployed instead of a table we would have to maintain by hand.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import os
12
+ import time
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ from oo_cli.client import Client, OOError
17
+
18
+ SPEC_PATH = "/api-doc/openapi.json"
19
+ MAX_AGE_SECONDS = 7 * 24 * 3600
20
+
21
+ #: Resources that exist in both versions; used when the spec cannot be fetched.
22
+ V2_RESOURCES = frozenset({"alerts", "folders", "reports"})
23
+
24
+ _METHODS = ("get", "post", "put", "patch", "delete", "head", "options")
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Resolution:
29
+ path: str
30
+ template: str | None
31
+
32
+ @property
33
+ def matched(self) -> bool:
34
+ return self.template is not None
35
+
36
+
37
+ class Spec:
38
+ def __init__(self, templates: dict[str, list[str]]) -> None:
39
+ self.templates = templates
40
+
41
+ def __bool__(self) -> bool:
42
+ return bool(self.templates)
43
+
44
+ def resolve(self, org: str, segments: list[str], method: str) -> Resolution:
45
+ """Map command segments onto a concrete API path, preferring v2.
46
+
47
+ A few endpoints (organizations, clusters) sit outside an organization, so an
48
+ org-less path is the last candidate.
49
+ """
50
+ tail = "/".join(segments)
51
+ v2, v1, global_ = f"/api/v2/{org}/{tail}", f"/api/{org}/{tail}", f"/api/{tail}"
52
+
53
+ if not self.templates:
54
+ return Resolution(v2 if segments and segments[0] in V2_RESOURCES else v1, None)
55
+
56
+ for candidate in (v2, v1, global_):
57
+ template = self._match(candidate, method)
58
+ if template:
59
+ return Resolution(candidate, template)
60
+ return Resolution(v1, None)
61
+
62
+ def _match(self, path: str, method: str) -> str | None:
63
+ parts = path.strip("/").split("/")
64
+ for template, methods in self.templates.items():
65
+ if method.lower() not in methods:
66
+ continue
67
+ template_parts = template.strip("/").split("/")
68
+ if len(template_parts) != len(parts):
69
+ continue
70
+ if all(
71
+ t.startswith("{") and t.endswith("}") or t == p
72
+ for t, p in zip(template_parts, parts, strict=True)
73
+ ):
74
+ return template
75
+ return None
76
+
77
+ def paths(self, needle: str | None = None) -> list[tuple[str, list[str]]]:
78
+ items = sorted(self.templates.items())
79
+ if needle:
80
+ items = [(p, m) for p, m in items if needle.lower() in p.lower()]
81
+ return items
82
+
83
+
84
+ def load(client: Client, refresh: bool = False) -> Spec:
85
+ """Return the cached spec, fetching it when missing, stale or forced."""
86
+ cache = _cache_file(client.config.endpoint)
87
+ if not refresh:
88
+ cached = _read_cache(cache)
89
+ if cached is not None:
90
+ return Spec(cached)
91
+
92
+ try:
93
+ document = client.json("GET", SPEC_PATH)
94
+ except OOError:
95
+ cached = _read_cache(cache, ignore_age=True)
96
+ return Spec(cached if cached is not None else {})
97
+
98
+ templates = {
99
+ path: [m for m in operations if m in _METHODS]
100
+ for path, operations in document.get("paths", {}).items()
101
+ }
102
+ _write_cache(cache, client.config.endpoint, templates)
103
+ return Spec(templates)
104
+
105
+
106
+ def _cache_file(endpoint: str) -> Path:
107
+ base = os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache"
108
+ digest = hashlib.sha1(endpoint.encode(), usedforsecurity=False).hexdigest()[:12]
109
+ return Path(base) / "oo-cli" / f"spec-{digest}.json"
110
+
111
+
112
+ def _read_cache(path: Path, ignore_age: bool = False) -> dict[str, list[str]] | None:
113
+ try:
114
+ payload = json.loads(path.read_text())
115
+ except (OSError, ValueError):
116
+ return None
117
+ if not ignore_age and time.time() - payload.get("fetched_at", 0) > MAX_AGE_SECONDS:
118
+ return None
119
+ templates = payload.get("templates")
120
+ return templates if isinstance(templates, dict) else None
121
+
122
+
123
+ def _write_cache(path: Path, endpoint: str, templates: dict[str, list[str]]) -> None:
124
+ payload = {"endpoint": endpoint, "fetched_at": time.time(), "templates": templates}
125
+ try:
126
+ path.parent.mkdir(parents=True, exist_ok=True)
127
+ path.write_text(json.dumps(payload))
128
+ except OSError:
129
+ pass
oo_cli/timeutil.py ADDED
@@ -0,0 +1,51 @@
1
+ """Time arguments. OpenObserve takes epoch microseconds everywhere."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from datetime import UTC, datetime, timedelta
7
+
8
+ _OFFSET = re.compile(r"^([+-])(\d+)([smhdw])$")
9
+ _UNITS = {"s": "seconds", "m": "minutes", "h": "hours", "d": "days", "w": "weeks"}
10
+
11
+
12
+ class TimeError(ValueError):
13
+ pass
14
+
15
+
16
+ def to_micros(value: str, now: datetime | None = None) -> int:
17
+ """Parse "now", an offset like -15m, epoch seconds/millis/micros, or ISO 8601.
18
+
19
+ A timestamp without a zone is read as local time.
20
+ """
21
+ now = now or datetime.now(UTC)
22
+ value = value.strip()
23
+
24
+ if value == "now":
25
+ return int(now.timestamp() * 1_000_000)
26
+
27
+ offset = _OFFSET.match(value)
28
+ if offset:
29
+ sign, amount, unit = offset.groups()
30
+ delta = timedelta(**{_UNITS[unit]: int(amount)})
31
+ moment = now - delta if sign == "-" else now + delta
32
+ return int(moment.timestamp() * 1_000_000)
33
+
34
+ if value.isdigit():
35
+ digits = len(value)
36
+ if digits <= 11:
37
+ return int(value) * 1_000_000
38
+ if digits <= 14:
39
+ return int(value) * 1_000
40
+ return int(value)
41
+
42
+ try:
43
+ moment = datetime.fromisoformat(value)
44
+ except ValueError as exc:
45
+ raise TimeError(
46
+ f"cannot read {value!r} as a time: use now, an offset (-15m, -2h, -7d), "
47
+ "an epoch timestamp or an ISO 8601 datetime"
48
+ ) from exc
49
+ if moment.tzinfo is None:
50
+ moment = moment.astimezone()
51
+ return int(moment.timestamp() * 1_000_000)
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.5
2
+ Name: openobserve-cli
3
+ Version: 0.1.0
4
+ Summary: CLI for the OpenObserve HTTP API
5
+ Project-URL: Homepage, https://github.com/paveldedik/oo-cli
6
+ Project-URL: Repository, https://github.com/paveldedik/oo-cli
7
+ Project-URL: Issues, https://github.com/paveldedik/oo-cli/issues
8
+ Author-email: Pavel Dedík <dedikx@gmail.com>
9
+ Maintainer-email: Pavel Dedík <dedikx@gmail.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: cli,logs,metrics,observability,openobserve,traces
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Intended Audience :: System Administrators
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Topic :: System :: Monitoring
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: httpx>=0.27
22
+ Description-Content-Type: text/markdown
23
+
24
+ # oo
25
+
26
+ A small CLI for the OpenObserve HTTP API. Commands are a transcription of the API rather
27
+ than a model of it: `oo get dashboards` is `GET /api/{org}/dashboards`, and anything the
28
+ CLI does not know about is still reachable with `oo api`.
29
+
30
+ ```bash
31
+ oo get dashboards
32
+ oo get alerts --folder default # GET /api/v2/{org}/alerts?folder=default
33
+ oo get streams/app_logs
34
+ oo post alerts -f alert.json
35
+ oo put dashboards/0194f0e1 -d @- < dashboard.json
36
+ oo delete alerts/7
37
+
38
+ oo search --sql "select * from default limit 10" --from -30m
39
+ oo api GET /api/default/prometheus/api/v1/query --query "up"
40
+ ```
41
+
42
+ Any `--name value` or `--name=value` the CLI does not define becomes a query parameter.
43
+ `-d` takes a literal body, `@file` or `@-` for stdin; `-f file` is shorthand for `-d @file`.
44
+ Output is pretty-printed JSON on stdout, errors on stderr, exit code 1 on HTTP >= 400 and
45
+ 2 on a bad command.
46
+
47
+ Times accept `now`, an offset (`-15m`, `-2h`, `-7d`), an epoch timestamp in seconds,
48
+ milliseconds or microseconds, or an ISO 8601 datetime (local time when it carries no zone).
49
+
50
+ ## Install
51
+
52
+ ```bash
53
+ uv tool install openobserve-cli # installs the `oo` command
54
+ uvx openobserve-cli get streams # or run it without installing anything
55
+ ```
56
+
57
+ The command is `oo`; `openobserve-cli` is the same command under the distribution's
58
+ name, which is what makes the `uvx` one-liner work. From a checkout: `uv sync` and
59
+ then `uv run oo --help`.
60
+
61
+ ## Configuration
62
+
63
+ Everything comes from the environment. Nothing is required: with no variables set the
64
+ CLI talks to `http://localhost:5080` as an anonymous user, which is what a port-forward
65
+ to a local OpenObserve looks like.
66
+
67
+ | Variable | Required | Default | Meaning |
68
+ |---------------------------|---------------------------------|-------------------------|-----------------------------------------------|
69
+ | `OO_ENDPOINT` | optional | `http://localhost:5080` | base URL |
70
+ | `OO_ORG` | optional | `default` | organization |
71
+ | `OO_TOKEN` | to reach anything but `/healthz`| — | base64 of `email:token`, sent as basic auth |
72
+ | `OO_USER` / `OO_PASSWORD` | instead of `OO_TOKEN` | — | the same credential spelled out |
73
+ | `OO_COOKIE` | when a gateway guards the host | — | `Cookie` header, sent verbatim |
74
+ | `OO_TIMEOUT` | optional | `60` | request timeout in seconds |
75
+
76
+ `--endpoint`, `--org` and `--timeout` override the corresponding variable.
77
+
78
+ ## When something authenticates in front of OpenObserve
79
+
80
+ Plenty of deployments put OpenObserve behind a gateway that authenticates users itself:
81
+ an AWS ALB with an `authenticate-oidc` action, oauth2-proxy, an identity-aware proxy.
82
+ Such a gateway takes nothing but its own session cookie, which it issues to a browser
83
+ at the end of an interactive login — no API token gets past it, and a CLI cannot run
84
+ that flow on its own.
85
+
86
+ So hand it the cookie your browser already has. In devtools, Network tab, take the
87
+ `Cookie` header off any request to that host (or Application, Cookies) and:
88
+
89
+ ```bash
90
+ export OO_COOKIE="AWSELBAuthSessionCookie-0=...; AWSELBAuthSessionCookie-1=..."
91
+ ```
92
+
93
+ It is sent verbatim, so any gateway's cookie works, whatever it calls it. A request
94
+ that hits the gateway without one says so instead of failing on a page of HTML. The
95
+ cookie expires on the gateway's schedule — an ALB session lasts 7 days by default.
96
+
97
+ The gateway is orthogonal to OpenObserve's own authentication: the cookie gets you
98
+ through the door, `OO_TOKEN` still identifies you to OpenObserve.
99
+
100
+ ## v1 and v2
101
+
102
+ Where an endpoint exists in both versions, v2 is used; there is no version switch. The CLI
103
+ reads the instance's own OpenAPI document (`/api-doc/openapi.json`) to decide, so it follows
104
+ whatever is deployed. The document is cached per endpoint under `~/.cache/oo-cli` for a week.
105
+
106
+ ```bash
107
+ oo spec paths alerts # list endpoints and their methods
108
+ oo spec refresh # refetch after an OpenObserve upgrade
109
+ ```
110
+
111
+ Without a reachable spec the CLI falls back to v1, except for `alerts`, `folders` and
112
+ `reports`, the three resources that have a v2 in OpenObserve 0.91.
113
+
114
+ ## Development
115
+
116
+ ```bash
117
+ uv sync
118
+ uv run pytest
119
+ pre-commit install # ruff, mypy and conventional commit messages
120
+ ```
@@ -0,0 +1,13 @@
1
+ oo_cli/__init__.py,sha256=YcD1_uucpne7GDvgTXRgAQiVsAUedeDhQzwCgKFB5K0,63
2
+ oo_cli/__main__.py,sha256=o8s9A9y8gskRoF0tCDFf7FnZk2DpuiIMLJywOILMcdc,85
3
+ oo_cli/cli.py,sha256=XkZu_9M0olJUZHfT4cqphOyozG8zDUucabLHbOS9wys,9655
4
+ oo_cli/client.py,sha256=mtASFEn3FpBSdPgvSWElQHjWJy8gJVi0X6u_eqNBGyo,3246
5
+ oo_cli/config.py,sha256=IJqEBdtOGJwkDHgKgqY7bdKTAsxpG0UD3a6Cc706HKM,1892
6
+ oo_cli/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ oo_cli/spec.py,sha256=ZnB1ycDF-GZzpi5Cy5nlom__ZM3oaiNvcfM72pPh3r4,4369
8
+ oo_cli/timeutil.py,sha256=OF3RgKioTxSk4HBv_vM6fahrtsdkbziwHqxq-AlcuFM,1541
9
+ openobserve_cli-0.1.0.dist-info/METADATA,sha256=W97OBNEciUPPodnDaVLixWkx2j00rAL0_fFbvXNg4Zw,5536
10
+ openobserve_cli-0.1.0.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
11
+ openobserve_cli-0.1.0.dist-info/entry_points.txt,sha256=llEuSqDhLj39jPaxdEV8T6bZ7buc_ARx7fI0AyVfKec,73
12
+ openobserve_cli-0.1.0.dist-info/licenses/LICENSE,sha256=sSY9MDyhGmQeG4gJx-wbK8XRLnNyE54hRoEz4jjDEwI,1072
13
+ openobserve_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ oo = oo_cli.cli:main
3
+ openobserve-cli = oo_cli.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Skip Pay s.r.o.
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.