phlo-clickstack 0.2.3__tar.gz → 0.3.0__tar.gz
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.
- {phlo_clickstack-0.2.3 → phlo_clickstack-0.3.0}/PKG-INFO +1 -1
- {phlo_clickstack-0.2.3 → phlo_clickstack-0.3.0}/pyproject.toml +4 -1
- phlo_clickstack-0.3.0/src/phlo_clickstack/__init__.py +21 -0
- phlo_clickstack-0.3.0/src/phlo_clickstack/authorization.py +166 -0
- {phlo_clickstack-0.2.3 → phlo_clickstack-0.3.0}/src/phlo_clickstack/cli.py +70 -23
- phlo_clickstack-0.3.0/src/phlo_clickstack/cli_plugin.py +49 -0
- phlo_clickstack-0.3.0/src/phlo_clickstack/observability_backend.py +148 -0
- phlo_clickstack-0.3.0/src/phlo_clickstack/plugin.py +44 -0
- phlo_clickstack-0.3.0/src/phlo_clickstack/resource_provider.py +26 -0
- {phlo_clickstack-0.2.3 → phlo_clickstack-0.3.0}/src/phlo_clickstack/service.yaml +6 -0
- {phlo_clickstack-0.2.3 → phlo_clickstack-0.3.0}/src/phlo_clickstack.egg-info/PKG-INFO +1 -1
- {phlo_clickstack-0.2.3 → phlo_clickstack-0.3.0}/src/phlo_clickstack.egg-info/SOURCES.txt +6 -1
- {phlo_clickstack-0.2.3 → phlo_clickstack-0.3.0}/src/phlo_clickstack.egg-info/entry_points.txt +3 -0
- phlo_clickstack-0.3.0/tests/test_authorization.py +188 -0
- {phlo_clickstack-0.2.3 → phlo_clickstack-0.3.0}/tests/test_clickstack_cli.py +58 -4
- phlo_clickstack-0.3.0/tests/test_observability_backend.py +100 -0
- phlo_clickstack-0.2.3/src/phlo_clickstack/__init__.py +0 -1
- phlo_clickstack-0.2.3/src/phlo_clickstack/cli_plugin.py +0 -26
- phlo_clickstack-0.2.3/src/phlo_clickstack/plugin.py +0 -31
- {phlo_clickstack-0.2.3 → phlo_clickstack-0.3.0}/README.md +0 -0
- {phlo_clickstack-0.2.3 → phlo_clickstack-0.3.0}/setup.cfg +0 -0
- {phlo_clickstack-0.2.3 → phlo_clickstack-0.3.0}/src/phlo_clickstack.egg-info/dependency_links.txt +0 -0
- {phlo_clickstack-0.2.3 → phlo_clickstack-0.3.0}/src/phlo_clickstack.egg-info/requires.txt +0 -0
- {phlo_clickstack-0.2.3 → phlo_clickstack-0.3.0}/src/phlo_clickstack.egg-info/top_level.txt +0 -0
- {phlo_clickstack-0.2.3 → phlo_clickstack-0.3.0}/tests/test_clickstack_plugin.py +0 -0
|
@@ -13,7 +13,7 @@ dependencies = [
|
|
|
13
13
|
description = "clickstack service plugin for Phlo"
|
|
14
14
|
name = "phlo-clickstack"
|
|
15
15
|
requires-python = ">=3.11"
|
|
16
|
-
version = "0.
|
|
16
|
+
version = "0.3.0"
|
|
17
17
|
|
|
18
18
|
[[project.authors]]
|
|
19
19
|
email = "team@phlo.dev"
|
|
@@ -22,6 +22,9 @@ name = "Phlo Team"
|
|
|
22
22
|
[project.entry-points."phlo.plugins.cli"]
|
|
23
23
|
clickstack = "phlo_clickstack.cli_plugin:ClickStackCliPlugin"
|
|
24
24
|
|
|
25
|
+
[project.entry-points."phlo.plugins.resources"]
|
|
26
|
+
clickstack = "phlo_clickstack.resource_provider:ClickStackResourceProvider"
|
|
27
|
+
|
|
25
28
|
[project.entry-points."phlo.plugins.services"]
|
|
26
29
|
clickstack = "phlo_clickstack.plugin:ClickStackServicePlugin"
|
|
27
30
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Phlo ClickStack package for observability backend.
|
|
2
|
+
|
|
3
|
+
This package provides a ClickStack service plugin for Phlo, bundling
|
|
4
|
+
ClickHouse for observability data storage and querying.
|
|
5
|
+
|
|
6
|
+
Exports:
|
|
7
|
+
ClickStackServicePlugin: Service plugin for ClickStack.
|
|
8
|
+
ClickStackCliPlugin: CLI plugin for ClickStack commands.
|
|
9
|
+
ClickStackSurfaceAdapter: Regulated surface adapter for ClickStack CLI.
|
|
10
|
+
get_clickstack_adapter: Get the ClickStack surface adapter singleton.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from phlo_clickstack.authorization import (
|
|
14
|
+
ClickStackSurfaceAdapter,
|
|
15
|
+
get_adapter as get_clickstack_adapter,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"ClickStackSurfaceAdapter",
|
|
20
|
+
"get_clickstack_adapter",
|
|
21
|
+
]
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Regulated surface adapter for ClickStack CLI.
|
|
2
|
+
|
|
3
|
+
This module provides the regulated surface adapter for the ClickStack CLI,
|
|
4
|
+
declaring mutation commands that require authorization and enforcing
|
|
5
|
+
through core enforcement.
|
|
6
|
+
|
|
7
|
+
Mutation commands (require authorization):
|
|
8
|
+
- clickstack.query
|
|
9
|
+
|
|
10
|
+
Read commands (no authorization):
|
|
11
|
+
- (none currently)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import threading
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from phlo.cli.authorization import CliPrincipalResolver
|
|
21
|
+
from phlo.logging import get_logger
|
|
22
|
+
from phlo.security.adapters import (
|
|
23
|
+
EnforcementResult,
|
|
24
|
+
SurfaceOperation,
|
|
25
|
+
)
|
|
26
|
+
from phlo.security.enforcement import enforce
|
|
27
|
+
|
|
28
|
+
logger = get_logger(__name__)
|
|
29
|
+
|
|
30
|
+
SURFACE_NAME = "phlo-clickstack"
|
|
31
|
+
FRAMEWORK_TYPE = "cli"
|
|
32
|
+
|
|
33
|
+
MUTATION_COMMANDS: frozenset[str] = frozenset(
|
|
34
|
+
{
|
|
35
|
+
"clickstack.query",
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
READ_COMMANDS: frozenset[str] = frozenset()
|
|
40
|
+
|
|
41
|
+
COMMAND_RESOURCE_MAP: dict[str, str] = {
|
|
42
|
+
"clickstack.query": "dataset",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
COMMAND_ACTION_MAP: dict[str, str] = {
|
|
46
|
+
"clickstack.query": "dataset.write",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ClickStackSurfaceAdapter:
|
|
51
|
+
"""Regulated surface adapter for ClickStack CLI.
|
|
52
|
+
|
|
53
|
+
Declares mutation commands and enforces authorization through core
|
|
54
|
+
enforcement for privileged operations.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
_instance: ClickStackSurfaceAdapter | None = None
|
|
58
|
+
_lock = threading.Lock()
|
|
59
|
+
|
|
60
|
+
def __init__(self) -> None:
|
|
61
|
+
self._resolver = CliPrincipalResolver()
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def get_instance(cls) -> ClickStackSurfaceAdapter:
|
|
65
|
+
if cls._instance is None:
|
|
66
|
+
with cls._lock:
|
|
67
|
+
if cls._instance is None:
|
|
68
|
+
cls._instance = cls()
|
|
69
|
+
return cls._instance
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def surface_name(self) -> str:
|
|
73
|
+
return SURFACE_NAME
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def framework_type(self) -> str:
|
|
77
|
+
return FRAMEWORK_TYPE
|
|
78
|
+
|
|
79
|
+
def list_operations(self) -> list[SurfaceOperation]:
|
|
80
|
+
operations: list[SurfaceOperation] = []
|
|
81
|
+
for command in MUTATION_COMMANDS:
|
|
82
|
+
resource_type = COMMAND_RESOURCE_MAP.get(command, "dataset")
|
|
83
|
+
action = COMMAND_ACTION_MAP.get(command, f"clickstack.{command}")
|
|
84
|
+
operations.append(
|
|
85
|
+
SurfaceOperation(
|
|
86
|
+
action=action,
|
|
87
|
+
resource_type=resource_type,
|
|
88
|
+
operation_name=command,
|
|
89
|
+
resource_id_strategy=None,
|
|
90
|
+
framework_metadata={"command": command},
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
return operations
|
|
94
|
+
|
|
95
|
+
def is_active(self, runtime: Any) -> bool:
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
def install(self, runtime: Any) -> None:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
def enforce_mutation(self, command: str, resource_id: str | None = None) -> EnforcementResult:
|
|
102
|
+
"""Enforce authorization for a mutation command."""
|
|
103
|
+
if command not in MUTATION_COMMANDS:
|
|
104
|
+
return EnforcementResult.allow()
|
|
105
|
+
|
|
106
|
+
action = COMMAND_ACTION_MAP.get(command, f"clickstack.{command}")
|
|
107
|
+
resource_type = COMMAND_RESOURCE_MAP.get(command, "dataset")
|
|
108
|
+
resource_id_final = resource_id or f"clickstack:{command}"
|
|
109
|
+
|
|
110
|
+
principal = self._resolver.resolve()
|
|
111
|
+
|
|
112
|
+
from phlo.capabilities.interfaces import ResourceRef
|
|
113
|
+
|
|
114
|
+
resource = ResourceRef(
|
|
115
|
+
resource_type=resource_type,
|
|
116
|
+
resource_id=resource_id_final,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
request_id = os.environ.get("PHLO_REQUEST_ID")
|
|
120
|
+
|
|
121
|
+
result = enforce(
|
|
122
|
+
principal=principal,
|
|
123
|
+
action=action,
|
|
124
|
+
resource=resource,
|
|
125
|
+
context=None,
|
|
126
|
+
request_id=request_id,
|
|
127
|
+
surface=SURFACE_NAME,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
logger.debug(
|
|
131
|
+
"clickstack_mutation_enforcement_result",
|
|
132
|
+
command=command,
|
|
133
|
+
action=action,
|
|
134
|
+
result=result.variant,
|
|
135
|
+
subject=principal.subject,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
def check_command_authorization(self, command_path: str) -> EnforcementResult:
|
|
141
|
+
"""Check if a command is authorized to run."""
|
|
142
|
+
if command_path in READ_COMMANDS:
|
|
143
|
+
return EnforcementResult.allow()
|
|
144
|
+
|
|
145
|
+
if command_path in MUTATION_COMMANDS:
|
|
146
|
+
return self.enforce_mutation(command_path)
|
|
147
|
+
|
|
148
|
+
logger.warning(
|
|
149
|
+
"clickstack_unknown_command_classification",
|
|
150
|
+
command=command_path,
|
|
151
|
+
)
|
|
152
|
+
return EnforcementResult.deny(
|
|
153
|
+
reason_code="unknown_command",
|
|
154
|
+
explanation=f"Command '{command_path}' is not classified as read or mutation",
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
_adapter: ClickStackSurfaceAdapter | None = None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def get_adapter() -> ClickStackSurfaceAdapter:
|
|
162
|
+
"""Get the singleton ClickStack surface adapter."""
|
|
163
|
+
global _adapter
|
|
164
|
+
if _adapter is None:
|
|
165
|
+
_adapter = ClickStackSurfaceAdapter()
|
|
166
|
+
return _adapter
|
|
@@ -1,13 +1,19 @@
|
|
|
1
|
-
"""CLI commands for querying ClickStack's bundled ClickHouse service.
|
|
1
|
+
"""CLI commands for querying ClickStack's bundled ClickHouse service.
|
|
2
|
+
|
|
3
|
+
This module provides Click CLI commands for executing SQL queries against
|
|
4
|
+
the running ClickStack ClickHouse container. It includes utilities for
|
|
5
|
+
validating container backend availability, reading SQL from files or inline arguments,
|
|
6
|
+
and executing queries with configurable formatting.
|
|
7
|
+
"""
|
|
2
8
|
|
|
3
9
|
from __future__ import annotations
|
|
4
10
|
|
|
5
11
|
from pathlib import Path
|
|
6
|
-
from shutil import which
|
|
7
12
|
from subprocess import TimeoutExpired
|
|
8
13
|
|
|
9
14
|
import click
|
|
10
15
|
|
|
16
|
+
from phlo.cli.commands.services import utils as services_utils
|
|
11
17
|
from phlo.cli.infrastructure.command import CommandError, run_command
|
|
12
18
|
from phlo.cli.infrastructure.compose import compose_base_cmd
|
|
13
19
|
from phlo.cli.infrastructure.utils import get_project_name
|
|
@@ -17,7 +23,26 @@ logger = get_logger(__name__)
|
|
|
17
23
|
|
|
18
24
|
|
|
19
25
|
def _read_query(*, query: str | None, file: Path | None) -> str:
|
|
20
|
-
"""
|
|
26
|
+
"""Read and validate SQL query from inline argument or file.
|
|
27
|
+
|
|
28
|
+
Accepts either an inline SQL query string or a path to a SQL file.
|
|
29
|
+
Validates that exactly one input source is provided and that the
|
|
30
|
+
content is non-empty.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
query: Inline SQL query string, or None if reading from file.
|
|
34
|
+
file: Path to SQL file, or None if using inline query.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
str: The SQL query text to execute.
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
click.ClickException: If both query and file are provided.
|
|
41
|
+
click.ClickException: If file cannot be read.
|
|
42
|
+
click.ClickException: If file is empty or contains only whitespace.
|
|
43
|
+
click.ClickException: If neither query nor file is provided.
|
|
44
|
+
|
|
45
|
+
"""
|
|
21
46
|
if query and file:
|
|
22
47
|
raise click.ClickException("Use either an inline query or --file, not both.")
|
|
23
48
|
if file is not None:
|
|
@@ -34,34 +59,36 @@ def _read_query(*, query: str | None, file: Path | None) -> str:
|
|
|
34
59
|
|
|
35
60
|
|
|
36
61
|
def _ensure_phlo_dir() -> Path:
|
|
37
|
-
"""
|
|
62
|
+
"""Locate and return the local .phlo directory.
|
|
63
|
+
|
|
64
|
+
Searches for a .phlo directory in the current working directory.
|
|
65
|
+
This directory is required for Docker Compose project configuration.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Path: Path to the .phlo directory.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
click.ClickException: If .phlo directory does not exist.
|
|
72
|
+
|
|
73
|
+
"""
|
|
38
74
|
phlo_dir = Path.cwd() / ".phlo"
|
|
39
75
|
if phlo_dir.exists():
|
|
40
76
|
return phlo_dir
|
|
41
77
|
raise click.ClickException(".phlo directory not found. Run 'phlo services init' first.")
|
|
42
78
|
|
|
43
79
|
|
|
44
|
-
def
|
|
45
|
-
"""Validate that
|
|
46
|
-
|
|
47
|
-
raise click.ClickException("docker command not found.")
|
|
48
|
-
try:
|
|
49
|
-
result = run_command(
|
|
50
|
-
["docker", "info"],
|
|
51
|
-
timeout_seconds=10,
|
|
52
|
-
capture_output=True,
|
|
53
|
-
check=False,
|
|
54
|
-
)
|
|
55
|
-
except TimeoutExpired as exc:
|
|
56
|
-
raise click.ClickException("docker info timed out.") from exc
|
|
57
|
-
if result.returncode == 0:
|
|
58
|
-
return
|
|
59
|
-
raise click.ClickException("Docker is not running.")
|
|
80
|
+
def _require_container_backend() -> None:
|
|
81
|
+
"""Validate that the selected container backend is available."""
|
|
82
|
+
services_utils.require_container_backend()
|
|
60
83
|
|
|
61
84
|
|
|
62
85
|
@click.group(name="clickstack")
|
|
63
86
|
def clickstack_group() -> None:
|
|
64
|
-
"""Query and inspect the ClickStack service.
|
|
87
|
+
"""Query and inspect the ClickStack service.
|
|
88
|
+
|
|
89
|
+
Provides commands for interacting with the ClickStack ClickHouse
|
|
90
|
+
container, including executing SQL queries.
|
|
91
|
+
"""
|
|
65
92
|
|
|
66
93
|
|
|
67
94
|
@clickstack_group.command(name="query")
|
|
@@ -75,8 +102,28 @@ def clickstack_query(
|
|
|
75
102
|
output_format: str,
|
|
76
103
|
timeout_seconds: int,
|
|
77
104
|
) -> None:
|
|
78
|
-
"""Execute a ClickHouse query against the running ClickStack service.
|
|
79
|
-
|
|
105
|
+
"""Execute a ClickHouse query against the running ClickStack service.
|
|
106
|
+
|
|
107
|
+
Runs a SQL query via clickhouse-client inside the ClickStack container.
|
|
108
|
+
Accepts SQL either as a command argument or from a file. Requires Docker
|
|
109
|
+
and a running phlo services stack with ClickStack enabled.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
query: SQL query string to execute.
|
|
113
|
+
query_file: Path to file containing SQL query.
|
|
114
|
+
output_format: ClickHouse output format (default: TabSeparatedRaw).
|
|
115
|
+
timeout_seconds: Query execution timeout in seconds.
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
click.ClickException: If Docker is unavailable.
|
|
119
|
+
click.ClickException: If .phlo directory is missing.
|
|
120
|
+
click.ClickException: If both query and file options provided.
|
|
121
|
+
click.ClickException: If query file cannot be read.
|
|
122
|
+
click.ClickException: If query execution fails.
|
|
123
|
+
click.ClickException: If query times out.
|
|
124
|
+
|
|
125
|
+
"""
|
|
126
|
+
_require_container_backend()
|
|
80
127
|
phlo_dir = _ensure_phlo_dir()
|
|
81
128
|
project_name = get_project_name()
|
|
82
129
|
sql = _read_query(query=query, file=query_file)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""CLI plugin for ClickStack commands.
|
|
2
|
+
|
|
3
|
+
This module defines the ClickStackCliPlugin class which registers CLI commands
|
|
4
|
+
for interacting with the ClickStack ClickHouse service.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from phlo.plugins.base import CliCommandPlugin, PluginMetadata
|
|
12
|
+
|
|
13
|
+
from phlo_clickstack.cli import clickstack_group
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ClickStackCliPlugin(CliCommandPlugin):
|
|
17
|
+
"""Register ClickStack CLI commands.
|
|
18
|
+
|
|
19
|
+
This plugin provides CLI commands for querying and managing the
|
|
20
|
+
ClickStack ClickHouse service instance.
|
|
21
|
+
|
|
22
|
+
Example:
|
|
23
|
+
Registered automatically when phlo_clickstack is installed.
|
|
24
|
+
Use `phlo clickstack --help` to see available commands.
|
|
25
|
+
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def metadata(self) -> PluginMetadata:
|
|
30
|
+
"""Return plugin metadata for ClickStack CLI registration.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
PluginMetadata: Metadata including name, version, and description.
|
|
34
|
+
|
|
35
|
+
"""
|
|
36
|
+
return PluginMetadata(
|
|
37
|
+
name="clickstack",
|
|
38
|
+
version="0.1.0",
|
|
39
|
+
description="CLI commands for ClickStack query access",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def get_cli_commands(self) -> list[click.Command]:
|
|
43
|
+
"""Return ClickStack CLI commands.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
list[click.Command]: List of Click commands provided by this plugin.
|
|
47
|
+
|
|
48
|
+
"""
|
|
49
|
+
return [clickstack_group]
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""ClickStack-backed observability capability provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from phlo.capabilities import (
|
|
11
|
+
CapabilitySupport,
|
|
12
|
+
DefaultObservabilityBackend,
|
|
13
|
+
ObservabilityBackendSpec,
|
|
14
|
+
TraceSpan,
|
|
15
|
+
TraceSpanFilter,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
_CLICKSTACK_QUERY_URL_ENV = "CLICKSTACK_QUERY_URL"
|
|
19
|
+
_CLICKSTACK_QUERY_USER_ENV = "CLICKSTACK_QUERY_USER"
|
|
20
|
+
_CLICKSTACK_QUERY_PASSWORD_ENV = "CLICKSTACK_QUERY_PASSWORD"
|
|
21
|
+
_CLICKSTACK_HTTP_PORT_ENV = "CLICKSTACK_HTTP_PORT"
|
|
22
|
+
_DEFAULT_CLICKSTACK_HTTP_PORT = "8123"
|
|
23
|
+
_CONTAINER_CLICKSTACK_QUERY_URL = f"http://clickstack:{_DEFAULT_CLICKSTACK_HTTP_PORT}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ClickStackObservabilityBackend(DefaultObservabilityBackend):
|
|
27
|
+
"""Observability backend with OTEL span queries backed by ClickStack."""
|
|
28
|
+
|
|
29
|
+
def run_trace_spans(self, run_id: str, limit: int = 500) -> list[TraceSpan]:
|
|
30
|
+
return self.trace_spans(TraceSpanFilter(run_id=run_id, limit=limit))
|
|
31
|
+
|
|
32
|
+
def trace_spans(self, filters: TraceSpanFilter) -> list[TraceSpan]:
|
|
33
|
+
query_url = self._resolve_clickstack_query_url()
|
|
34
|
+
query = _build_trace_spans_query(filters)
|
|
35
|
+
response = requests.post(
|
|
36
|
+
query_url,
|
|
37
|
+
data=query.encode("utf-8"),
|
|
38
|
+
auth=self._resolve_clickstack_query_auth(),
|
|
39
|
+
timeout=10,
|
|
40
|
+
)
|
|
41
|
+
response.raise_for_status()
|
|
42
|
+
spans: list[TraceSpan] = []
|
|
43
|
+
for line in response.text.splitlines():
|
|
44
|
+
if not line.strip():
|
|
45
|
+
continue
|
|
46
|
+
spans.append(TraceSpan(**json.loads(line)))
|
|
47
|
+
return spans
|
|
48
|
+
|
|
49
|
+
def _resolve_clickstack_query_url(self) -> str:
|
|
50
|
+
query_url = os.environ.get(_CLICKSTACK_QUERY_URL_ENV)
|
|
51
|
+
if query_url:
|
|
52
|
+
return query_url.rstrip("/")
|
|
53
|
+
if _running_in_container():
|
|
54
|
+
return _CONTAINER_CLICKSTACK_QUERY_URL
|
|
55
|
+
port = os.environ.get(_CLICKSTACK_HTTP_PORT_ENV, _DEFAULT_CLICKSTACK_HTTP_PORT)
|
|
56
|
+
return f"http://127.0.0.1:{port}"
|
|
57
|
+
|
|
58
|
+
def _resolve_clickstack_query_auth(self) -> tuple[str, str] | None:
|
|
59
|
+
user = os.environ.get(_CLICKSTACK_QUERY_USER_ENV)
|
|
60
|
+
password = os.environ.get(_CLICKSTACK_QUERY_PASSWORD_ENV)
|
|
61
|
+
if user is None:
|
|
62
|
+
return None
|
|
63
|
+
return (user, password or "")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _build_trace_spans_query(filters: TraceSpanFilter) -> str:
|
|
67
|
+
where_clauses = _trace_where_clauses(filters)
|
|
68
|
+
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
|
|
69
|
+
limit = _bounded_limit(filters.limit)
|
|
70
|
+
return f"""
|
|
71
|
+
SELECT
|
|
72
|
+
toString(Timestamp) AS timestamp,
|
|
73
|
+
TraceId AS trace_id,
|
|
74
|
+
SpanId AS span_id,
|
|
75
|
+
nullIf(ParentSpanId, '') AS parent_span_id,
|
|
76
|
+
SpanName AS span_name,
|
|
77
|
+
ServiceName AS service_name,
|
|
78
|
+
SpanKind AS span_kind,
|
|
79
|
+
round(Duration / 1000000, 3) AS duration_ms,
|
|
80
|
+
StatusCode AS status_code,
|
|
81
|
+
SpanAttributes AS span_attributes,
|
|
82
|
+
ResourceAttributes AS resource_attributes
|
|
83
|
+
FROM default.otel_traces
|
|
84
|
+
{where_sql}
|
|
85
|
+
ORDER BY Timestamp ASC
|
|
86
|
+
LIMIT {limit}
|
|
87
|
+
FORMAT JSONEachRow
|
|
88
|
+
""".strip()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _trace_where_clauses(filters: TraceSpanFilter) -> list[str]:
|
|
92
|
+
clauses: list[str] = []
|
|
93
|
+
_append_span_attribute_clause(clauses, "phlo.run_id", filters.run_id)
|
|
94
|
+
_append_span_attribute_clause(clauses, "phlo.asset_key", filters.asset_key)
|
|
95
|
+
_append_span_attribute_clause(clauses, "phlo.job_name", filters.job_name)
|
|
96
|
+
_append_exact_clause(clauses, "ServiceName", filters.service_name)
|
|
97
|
+
_append_exact_clause(clauses, "SpanName", filters.span_name)
|
|
98
|
+
_append_exact_clause(clauses, "StatusCode", filters.status_code)
|
|
99
|
+
if filters.start_time:
|
|
100
|
+
clauses.append(
|
|
101
|
+
f"Timestamp >= parseDateTimeBestEffort('{_escape_clickhouse_string(filters.start_time)}')"
|
|
102
|
+
)
|
|
103
|
+
if filters.end_time:
|
|
104
|
+
clauses.append(
|
|
105
|
+
f"Timestamp <= parseDateTimeBestEffort('{_escape_clickhouse_string(filters.end_time)}')"
|
|
106
|
+
)
|
|
107
|
+
return clauses
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _append_span_attribute_clause(clauses: list[str], key: str, value: str | None) -> None:
|
|
111
|
+
if value:
|
|
112
|
+
clauses.append(f"SpanAttributes['{key}'] = '{_escape_clickhouse_string(value)}'")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _append_exact_clause(clauses: list[str], column: str, value: str | None) -> None:
|
|
116
|
+
if value:
|
|
117
|
+
clauses.append(f"{column} = '{_escape_clickhouse_string(value)}'")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _bounded_limit(limit: int) -> int:
|
|
121
|
+
return min(max(limit, 1), 5000)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def build_clickstack_observability_spec() -> ObservabilityBackendSpec:
|
|
125
|
+
"""Build the ClickStack observability capability spec."""
|
|
126
|
+
return ObservabilityBackendSpec(
|
|
127
|
+
name="default",
|
|
128
|
+
provider=ClickStackObservabilityBackend(),
|
|
129
|
+
metadata={
|
|
130
|
+
"default_stack": ["phlo-otel", "phlo-clickstack"],
|
|
131
|
+
"service_dependencies": ["clickstack"],
|
|
132
|
+
"backend": "clickstack",
|
|
133
|
+
},
|
|
134
|
+
support=CapabilitySupport(
|
|
135
|
+
supports_metrics=True,
|
|
136
|
+
supports_logs=True,
|
|
137
|
+
supports_dashboards=True,
|
|
138
|
+
supports_alerts=True,
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _escape_clickhouse_string(value: str) -> str:
|
|
144
|
+
return value.replace("\\", "\\\\").replace("'", "\\'")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _running_in_container() -> bool:
|
|
148
|
+
return os.path.exists("/.dockerenv")
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""ClickStack service plugin.
|
|
2
|
+
|
|
3
|
+
This module defines the ClickStackServicePlugin class which provides
|
|
4
|
+
service registration and definition for the ClickStack observability
|
|
5
|
+
backend service.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from phlo.plugins import PackageYamlServicePlugin, PluginMetadata
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ClickStackServicePlugin(PackageYamlServicePlugin):
|
|
14
|
+
"""Service plugin for ClickStack.
|
|
15
|
+
|
|
16
|
+
Provides ClickStack (ClickHouse-based observability backend) as a
|
|
17
|
+
managed service within the Phlo services framework. The service
|
|
18
|
+
definition is loaded from the bundled service.yaml file.
|
|
19
|
+
|
|
20
|
+
Example:
|
|
21
|
+
Plugin is automatically discovered via entry points.
|
|
22
|
+
Service is started with `phlo services start clickstack`.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
service_definition: ClickStack Docker Compose configuration.
|
|
26
|
+
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def metadata(self) -> PluginMetadata:
|
|
31
|
+
"""Return plugin metadata for ClickStack service registration.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
PluginMetadata: Metadata including name, version, description,
|
|
35
|
+
author, and tags for discovery.
|
|
36
|
+
|
|
37
|
+
"""
|
|
38
|
+
return PluginMetadata(
|
|
39
|
+
name="clickstack",
|
|
40
|
+
version="0.1.0",
|
|
41
|
+
description="ClickStack all-in-one observability backend",
|
|
42
|
+
author="Phlo Team",
|
|
43
|
+
tags=["observability", "logs", "metrics", "traces"],
|
|
44
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Resource provider for ClickStack observability capabilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from phlo.plugins import PluginMetadata, ResourceProviderPlugin
|
|
6
|
+
|
|
7
|
+
from phlo_clickstack.observability_backend import build_clickstack_observability_spec
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ClickStackResourceProvider(ResourceProviderPlugin):
|
|
11
|
+
"""Expose ClickStack as the default observability backend capability."""
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def metadata(self) -> PluginMetadata:
|
|
15
|
+
return PluginMetadata(
|
|
16
|
+
name="clickstack",
|
|
17
|
+
version="0.1.0",
|
|
18
|
+
description="ClickStack observability capability provider",
|
|
19
|
+
tags=["observability", "logs", "metrics", "traces"],
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
def get_resources(self) -> list:
|
|
23
|
+
return []
|
|
24
|
+
|
|
25
|
+
def get_observability_backends(self):
|
|
26
|
+
return [build_clickstack_observability_spec()]
|
|
@@ -9,8 +9,11 @@ image: ${CLICKSTACK_IMAGE:-docker.hyperdx.io/hyperdx/hyperdx-all-in-one}
|
|
|
9
9
|
compose:
|
|
10
10
|
restart: unless-stopped
|
|
11
11
|
user: "0"
|
|
12
|
+
environment:
|
|
13
|
+
FRONTEND_URL: ${CLICKSTACK_PUBLIC_URL:-}
|
|
12
14
|
ports:
|
|
13
15
|
- "${CLICKSTACK_PORT:-8080}:8080"
|
|
16
|
+
- "${CLICKSTACK_HTTP_PORT:-8123}:8123"
|
|
14
17
|
- "${CLICKSTACK_OTLP_GRPC_PORT:-4317}:4317"
|
|
15
18
|
- "${CLICKSTACK_OTLP_HTTP_PORT:-4318}:4318"
|
|
16
19
|
- "${CLICKSTACK_NATIVE_PORT:-9000}:9000"
|
|
@@ -37,6 +40,9 @@ env_vars:
|
|
|
37
40
|
CLICKSTACK_PORT:
|
|
38
41
|
default: 8080
|
|
39
42
|
description: ClickStack UI port
|
|
43
|
+
CLICKSTACK_HTTP_PORT:
|
|
44
|
+
default: 8123
|
|
45
|
+
description: ClickHouse HTTP query port used by phlo-api trace endpoints
|
|
40
46
|
CLICKSTACK_OTLP_GRPC_PORT:
|
|
41
47
|
default: 4317
|
|
42
48
|
description: ClickStack OTLP gRPC ingest port
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
README.md
|
|
2
2
|
pyproject.toml
|
|
3
3
|
src/phlo_clickstack/__init__.py
|
|
4
|
+
src/phlo_clickstack/authorization.py
|
|
4
5
|
src/phlo_clickstack/cli.py
|
|
5
6
|
src/phlo_clickstack/cli_plugin.py
|
|
7
|
+
src/phlo_clickstack/observability_backend.py
|
|
6
8
|
src/phlo_clickstack/plugin.py
|
|
9
|
+
src/phlo_clickstack/resource_provider.py
|
|
7
10
|
src/phlo_clickstack/service.yaml
|
|
8
11
|
src/phlo_clickstack.egg-info/PKG-INFO
|
|
9
12
|
src/phlo_clickstack.egg-info/SOURCES.txt
|
|
@@ -11,5 +14,7 @@ src/phlo_clickstack.egg-info/dependency_links.txt
|
|
|
11
14
|
src/phlo_clickstack.egg-info/entry_points.txt
|
|
12
15
|
src/phlo_clickstack.egg-info/requires.txt
|
|
13
16
|
src/phlo_clickstack.egg-info/top_level.txt
|
|
17
|
+
tests/test_authorization.py
|
|
14
18
|
tests/test_clickstack_cli.py
|
|
15
|
-
tests/test_clickstack_plugin.py
|
|
19
|
+
tests/test_clickstack_plugin.py
|
|
20
|
+
tests/test_observability_backend.py
|
{phlo_clickstack-0.2.3 → phlo_clickstack-0.3.0}/src/phlo_clickstack.egg-info/entry_points.txt
RENAMED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
[phlo.plugins.cli]
|
|
2
2
|
clickstack = phlo_clickstack.cli_plugin:ClickStackCliPlugin
|
|
3
3
|
|
|
4
|
+
[phlo.plugins.resources]
|
|
5
|
+
clickstack = phlo_clickstack.resource_provider:ClickStackResourceProvider
|
|
6
|
+
|
|
4
7
|
[phlo.plugins.services]
|
|
5
8
|
clickstack = phlo_clickstack.plugin:ClickStackServicePlugin
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Tests for ClickStack authorization module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from unittest.mock import patch
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from phlo.cli.authorization import CliPrincipalResolver
|
|
11
|
+
from phlo_clickstack.authorization import (
|
|
12
|
+
COMMAND_ACTION_MAP,
|
|
13
|
+
COMMAND_RESOURCE_MAP,
|
|
14
|
+
MUTATION_COMMANDS,
|
|
15
|
+
READ_COMMANDS,
|
|
16
|
+
SURFACE_NAME,
|
|
17
|
+
ClickStackSurfaceAdapter,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
pytestmark = pytest.mark.core_regression
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TestClickStackPrincipalResolver:
|
|
24
|
+
"""Tests for the shared CLI principal resolver."""
|
|
25
|
+
|
|
26
|
+
def test_resolve_service_account(self):
|
|
27
|
+
"""PHLO_SERVICE_ACCOUNT creates service principal."""
|
|
28
|
+
env = {"PHLO_SERVICE_ACCOUNT": "ci-bot@phlo.svc"}
|
|
29
|
+
with patch.dict(os.environ, env, clear=True):
|
|
30
|
+
resolver = CliPrincipalResolver()
|
|
31
|
+
principal = resolver.resolve()
|
|
32
|
+
assert principal.subject == "ci-bot@phlo.svc"
|
|
33
|
+
assert principal.principal_type == "service"
|
|
34
|
+
assert "operators" in principal.groups
|
|
35
|
+
|
|
36
|
+
def test_resolve_human_principal(self):
|
|
37
|
+
"""PHLO_AUTH_SUBJECT creates human principal."""
|
|
38
|
+
env = {
|
|
39
|
+
"PHLO_AUTH_SUBJECT": "user@example.com",
|
|
40
|
+
"PHLO_AUTH_TYPE": "user",
|
|
41
|
+
"PHLO_AUTH_GROUPS": "admin,developers",
|
|
42
|
+
}
|
|
43
|
+
with patch.dict(os.environ, env, clear=True):
|
|
44
|
+
resolver = CliPrincipalResolver()
|
|
45
|
+
principal = resolver.resolve()
|
|
46
|
+
assert principal.subject == "user@example.com"
|
|
47
|
+
assert principal.principal_type == "user"
|
|
48
|
+
assert "admin" in principal.groups
|
|
49
|
+
assert "developers" in principal.groups
|
|
50
|
+
|
|
51
|
+
def test_resolve_human_principal_empty_groups(self):
|
|
52
|
+
"""PHLO_AUTH_SUBJECT with no groups."""
|
|
53
|
+
env = {
|
|
54
|
+
"PHLO_AUTH_SUBJECT": "user@example.com",
|
|
55
|
+
"PHLO_AUTH_TYPE": "user",
|
|
56
|
+
}
|
|
57
|
+
with patch.dict(os.environ, env, clear=True):
|
|
58
|
+
resolver = CliPrincipalResolver()
|
|
59
|
+
principal = resolver.resolve()
|
|
60
|
+
assert principal.subject == "user@example.com"
|
|
61
|
+
assert principal.groups == ()
|
|
62
|
+
|
|
63
|
+
def test_resolve_dev_mode_fallback(self):
|
|
64
|
+
"""PHLO_DEV_MODE creates admin fallback with warning."""
|
|
65
|
+
env = {"PHLO_DEV_MODE": "1"}
|
|
66
|
+
with patch.dict(os.environ, env, clear=True):
|
|
67
|
+
resolver = CliPrincipalResolver()
|
|
68
|
+
principal = resolver.resolve()
|
|
69
|
+
assert principal.subject == "local:root"
|
|
70
|
+
assert principal.principal_type == "user"
|
|
71
|
+
assert "admin" in principal.groups
|
|
72
|
+
|
|
73
|
+
def test_resolve_anonymous_default(self):
|
|
74
|
+
"""No env vars creates anonymous principal."""
|
|
75
|
+
env = {}
|
|
76
|
+
with patch.dict(os.environ, env, clear=True):
|
|
77
|
+
resolver = CliPrincipalResolver()
|
|
78
|
+
principal = resolver.resolve()
|
|
79
|
+
assert principal.subject == "anonymous"
|
|
80
|
+
assert principal.principal_type == "user"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class TestClickStackSurfaceAdapter:
|
|
84
|
+
"""Tests for ClickStackSurfaceAdapter."""
|
|
85
|
+
|
|
86
|
+
def setup_method(self):
|
|
87
|
+
ClickStackSurfaceAdapter._instance = None
|
|
88
|
+
|
|
89
|
+
def teardown_method(self):
|
|
90
|
+
ClickStackSurfaceAdapter._instance = None
|
|
91
|
+
|
|
92
|
+
def test_surface_name(self):
|
|
93
|
+
adapter = ClickStackSurfaceAdapter()
|
|
94
|
+
assert adapter.surface_name == SURFACE_NAME
|
|
95
|
+
|
|
96
|
+
def test_framework_type(self):
|
|
97
|
+
adapter = ClickStackSurfaceAdapter()
|
|
98
|
+
assert adapter.framework_type == "cli"
|
|
99
|
+
|
|
100
|
+
def test_list_operations(self):
|
|
101
|
+
adapter = ClickStackSurfaceAdapter()
|
|
102
|
+
operations = adapter.list_operations()
|
|
103
|
+
assert len(operations) == len(MUTATION_COMMANDS)
|
|
104
|
+
operation_names = {op["operation_name"] for op in operations}
|
|
105
|
+
for cmd in MUTATION_COMMANDS:
|
|
106
|
+
assert cmd in operation_names
|
|
107
|
+
|
|
108
|
+
def test_list_operations_has_required_fields(self):
|
|
109
|
+
adapter = ClickStackSurfaceAdapter()
|
|
110
|
+
for op in adapter.list_operations():
|
|
111
|
+
assert "action" in op
|
|
112
|
+
assert "resource_type" in op
|
|
113
|
+
assert "operation_name" in op
|
|
114
|
+
|
|
115
|
+
def test_is_active(self):
|
|
116
|
+
adapter = ClickStackSurfaceAdapter()
|
|
117
|
+
assert adapter.is_active(None) is True
|
|
118
|
+
|
|
119
|
+
def test_get_instance_singleton(self):
|
|
120
|
+
adapter1 = ClickStackSurfaceAdapter.get_instance()
|
|
121
|
+
adapter2 = ClickStackSurfaceAdapter.get_instance()
|
|
122
|
+
assert adapter1 is adapter2
|
|
123
|
+
|
|
124
|
+
def test_command_mapped_correctly(self):
|
|
125
|
+
"""All mutation commands have resource and action mappings."""
|
|
126
|
+
for cmd in MUTATION_COMMANDS:
|
|
127
|
+
assert cmd in COMMAND_RESOURCE_MAP, f"Missing resource mapping for {cmd}"
|
|
128
|
+
assert cmd in COMMAND_ACTION_MAP, f"Missing action mapping for {cmd}"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class TestEnforcement:
|
|
132
|
+
"""Tests for ClickStack mutation enforcement."""
|
|
133
|
+
|
|
134
|
+
def setup_method(self):
|
|
135
|
+
ClickStackSurfaceAdapter._instance = None
|
|
136
|
+
|
|
137
|
+
def teardown_method(self):
|
|
138
|
+
ClickStackSurfaceAdapter._instance = None
|
|
139
|
+
|
|
140
|
+
def test_check_read_command_allowed(self):
|
|
141
|
+
"""Read commands are always allowed."""
|
|
142
|
+
adapter = ClickStackSurfaceAdapter()
|
|
143
|
+
for cmd in READ_COMMANDS:
|
|
144
|
+
result = adapter.check_command_authorization(cmd)
|
|
145
|
+
assert result.allowed, f"Read command {cmd} should be allowed"
|
|
146
|
+
|
|
147
|
+
def test_check_unknown_command_denied(self):
|
|
148
|
+
"""Unknown commands are denied."""
|
|
149
|
+
adapter = ClickStackSurfaceAdapter()
|
|
150
|
+
result = adapter.check_command_authorization("unknown.command")
|
|
151
|
+
assert not result.allowed
|
|
152
|
+
assert result.reason_code == "unknown_command"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class TestMutationCommandLists:
|
|
156
|
+
"""Tests for command classification lists."""
|
|
157
|
+
|
|
158
|
+
def test_no_overlap_between_read_and_mutation(self):
|
|
159
|
+
"""Read and mutation command sets are disjoint."""
|
|
160
|
+
overlap = READ_COMMANDS & MUTATION_COMMANDS
|
|
161
|
+
assert overlap == set(), f"Commands in both sets: {overlap}"
|
|
162
|
+
|
|
163
|
+
def test_all_clickstack_commands_covered(self):
|
|
164
|
+
"""All clickstack subcommands are classified."""
|
|
165
|
+
all_clickstack = {"clickstack.query"}
|
|
166
|
+
classified = READ_COMMANDS | MUTATION_COMMANDS
|
|
167
|
+
unclassified = all_clickstack - classified
|
|
168
|
+
assert unclassified == set(), f"Unclassified clickstack commands: {unclassified}"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class TestResourceActionMapping:
|
|
172
|
+
"""Tests for command to resource/action mapping."""
|
|
173
|
+
|
|
174
|
+
def test_query_command_resource_mapping(self):
|
|
175
|
+
"""clickstack.query maps to dataset resource."""
|
|
176
|
+
assert COMMAND_RESOURCE_MAP["clickstack.query"] == "dataset"
|
|
177
|
+
|
|
178
|
+
def test_query_command_action_mapping(self):
|
|
179
|
+
"""clickstack.query maps to dataset.write action."""
|
|
180
|
+
assert COMMAND_ACTION_MAP["clickstack.query"] == "dataset.write"
|
|
181
|
+
|
|
182
|
+
def test_action_format(self):
|
|
183
|
+
"""All actions follow resource.action pattern."""
|
|
184
|
+
for cmd, action in COMMAND_ACTION_MAP.items():
|
|
185
|
+
parts = action.split(".")
|
|
186
|
+
assert len(parts) >= 2, (
|
|
187
|
+
f"Action {action} for {cmd} should have at least resource.action"
|
|
188
|
+
)
|
|
@@ -37,8 +37,8 @@ def test_clickstack_query_runs_clickhouse_client(monkeypatch) -> None:
|
|
|
37
37
|
return CompletedProcess(cmd, 0, stdout='{"1":1}\n', stderr="")
|
|
38
38
|
|
|
39
39
|
monkeypatch.setattr("phlo_clickstack.cli._ensure_phlo_dir", lambda: Path("/tmp/project/.phlo"))
|
|
40
|
+
monkeypatch.setattr("phlo_clickstack.cli._require_container_backend", lambda: None)
|
|
40
41
|
monkeypatch.setattr("phlo_clickstack.cli.get_project_name", lambda: "demo")
|
|
41
|
-
monkeypatch.setattr("phlo_clickstack.cli.which", lambda _name: "/usr/bin/docker")
|
|
42
42
|
monkeypatch.setattr(
|
|
43
43
|
"phlo_clickstack.cli.compose_base_cmd",
|
|
44
44
|
lambda **_kwargs: ["docker", "compose", "-p", "demo"],
|
|
@@ -51,6 +51,60 @@ def test_clickstack_query_runs_clickhouse_client(monkeypatch) -> None:
|
|
|
51
51
|
assert result.output == '{"1":1}\n'
|
|
52
52
|
|
|
53
53
|
|
|
54
|
+
def test_clickstack_query_uses_selected_container_backend(monkeypatch, tmp_path) -> None:
|
|
55
|
+
phlo_dir = tmp_path / ".phlo"
|
|
56
|
+
phlo_dir.mkdir()
|
|
57
|
+
(phlo_dir / "docker-compose.yml").write_text("services:\n clickstack: {}\n")
|
|
58
|
+
(phlo_dir / ".env").write_text("")
|
|
59
|
+
monkeypatch.chdir(tmp_path)
|
|
60
|
+
monkeypatch.setenv("PHLO_CONTAINER_BACKEND", "podman")
|
|
61
|
+
monkeypatch.setattr("phlo_clickstack.cli.get_project_name", lambda: "demo")
|
|
62
|
+
|
|
63
|
+
calls: list[list[str]] = []
|
|
64
|
+
monkeypatch.setattr(
|
|
65
|
+
"phlo.cli.commands.services.utils.require_container_backend",
|
|
66
|
+
lambda *_args, **_kwargs: None,
|
|
67
|
+
)
|
|
68
|
+
monkeypatch.setattr(
|
|
69
|
+
"phlo_clickstack.cli.run_command",
|
|
70
|
+
lambda cmd, **_kwargs: calls.append(cmd) or CompletedProcess(cmd, 0, stdout="", stderr=""),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
result = CliRunner().invoke(clickstack_group, ["query", "SELECT 1"])
|
|
74
|
+
|
|
75
|
+
assert result.exit_code == 0, result.output
|
|
76
|
+
assert calls
|
|
77
|
+
assert calls[0][:2] == ["podman", "compose"]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_clickstack_query_uses_project_container_backend_config(monkeypatch, tmp_path) -> None:
|
|
81
|
+
phlo_dir = tmp_path / ".phlo"
|
|
82
|
+
phlo_dir.mkdir()
|
|
83
|
+
(phlo_dir / "docker-compose.yml").write_text("services:\n clickstack: {}\n")
|
|
84
|
+
(phlo_dir / ".env").write_text("")
|
|
85
|
+
(tmp_path / "phlo.yaml").write_text(
|
|
86
|
+
"name: demo\ninfrastructure:\n container_backend: podman\n"
|
|
87
|
+
)
|
|
88
|
+
monkeypatch.chdir(tmp_path)
|
|
89
|
+
monkeypatch.delenv("PHLO_CONTAINER_BACKEND", raising=False)
|
|
90
|
+
monkeypatch.setattr("phlo_clickstack.cli.get_project_name", lambda: "demo")
|
|
91
|
+
monkeypatch.setattr(
|
|
92
|
+
"phlo.cli.commands.services.utils.require_container_backend",
|
|
93
|
+
lambda *_args, **_kwargs: None,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
calls: list[list[str]] = []
|
|
97
|
+
monkeypatch.setattr(
|
|
98
|
+
"phlo_clickstack.cli.run_command",
|
|
99
|
+
lambda cmd, **_kwargs: calls.append(cmd) or CompletedProcess(cmd, 0, stdout="", stderr=""),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
result = CliRunner().invoke(clickstack_group, ["query", "SELECT 1"])
|
|
103
|
+
|
|
104
|
+
assert result.exit_code == 0, result.output
|
|
105
|
+
assert calls[0][:2] == ["podman", "compose"]
|
|
106
|
+
|
|
107
|
+
|
|
54
108
|
def test_clickstack_query_supports_file(monkeypatch, tmp_path) -> None:
|
|
55
109
|
"""Query command should read SQL from a file."""
|
|
56
110
|
sql_file = tmp_path / "query.sql"
|
|
@@ -63,8 +117,8 @@ def test_clickstack_query_supports_file(monkeypatch, tmp_path) -> None:
|
|
|
63
117
|
return CompletedProcess(cmd, 0, stdout="42\n", stderr="")
|
|
64
118
|
|
|
65
119
|
monkeypatch.setattr("phlo_clickstack.cli._ensure_phlo_dir", lambda: Path("/tmp/project/.phlo"))
|
|
120
|
+
monkeypatch.setattr("phlo_clickstack.cli._require_container_backend", lambda: None)
|
|
66
121
|
monkeypatch.setattr("phlo_clickstack.cli.get_project_name", lambda: "demo")
|
|
67
|
-
monkeypatch.setattr("phlo_clickstack.cli.which", lambda _name: "/usr/bin/docker")
|
|
68
122
|
monkeypatch.setattr(
|
|
69
123
|
"phlo_clickstack.cli.compose_base_cmd",
|
|
70
124
|
lambda **_kwargs: ["docker", "compose", "-p", "demo"],
|
|
@@ -80,8 +134,8 @@ def test_clickstack_query_supports_file(monkeypatch, tmp_path) -> None:
|
|
|
80
134
|
def test_clickstack_query_rejects_missing_input(monkeypatch) -> None:
|
|
81
135
|
"""Query command should fail clearly when no SQL is provided."""
|
|
82
136
|
monkeypatch.setattr("phlo_clickstack.cli._ensure_phlo_dir", lambda: Path("/tmp/project/.phlo"))
|
|
137
|
+
monkeypatch.setattr("phlo_clickstack.cli._require_container_backend", lambda: None)
|
|
83
138
|
monkeypatch.setattr("phlo_clickstack.cli.get_project_name", lambda: "demo")
|
|
84
|
-
monkeypatch.setattr("phlo_clickstack.cli.which", lambda _name: "/usr/bin/docker")
|
|
85
139
|
monkeypatch.setattr(
|
|
86
140
|
"phlo_clickstack.cli.run_command",
|
|
87
141
|
lambda cmd, **_kwargs: (
|
|
@@ -106,8 +160,8 @@ def test_clickstack_query_surfaces_timeout(monkeypatch) -> None:
|
|
|
106
160
|
raise TimeoutExpired(cmd=cmd, timeout=30)
|
|
107
161
|
|
|
108
162
|
monkeypatch.setattr("phlo_clickstack.cli._ensure_phlo_dir", lambda: Path("/tmp/project/.phlo"))
|
|
163
|
+
monkeypatch.setattr("phlo_clickstack.cli._require_container_backend", lambda: None)
|
|
109
164
|
monkeypatch.setattr("phlo_clickstack.cli.get_project_name", lambda: "demo")
|
|
110
|
-
monkeypatch.setattr("phlo_clickstack.cli.which", lambda _name: "/usr/bin/docker")
|
|
111
165
|
monkeypatch.setattr(
|
|
112
166
|
"phlo_clickstack.cli.compose_base_cmd",
|
|
113
167
|
lambda **_kwargs: ["docker", "compose", "-p", "demo"],
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Tests for ClickStack observability backend capability."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from phlo_clickstack.observability_backend import (
|
|
6
|
+
ClickStackObservabilityBackend,
|
|
7
|
+
_build_trace_spans_query,
|
|
8
|
+
build_clickstack_observability_spec,
|
|
9
|
+
)
|
|
10
|
+
from phlo.capabilities import TraceSpanFilter
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _FakeResponse:
|
|
14
|
+
def __init__(self, text: str) -> None:
|
|
15
|
+
self.text = text
|
|
16
|
+
|
|
17
|
+
def raise_for_status(self) -> None:
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_build_clickstack_observability_spec_uses_default_name() -> None:
|
|
22
|
+
spec = build_clickstack_observability_spec()
|
|
23
|
+
|
|
24
|
+
assert spec.name == "default"
|
|
25
|
+
assert spec.metadata["backend"] == "clickstack"
|
|
26
|
+
assert spec.support.supports_logs is True
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_clickstack_backend_defaults_to_host_http_port(monkeypatch) -> None:
|
|
30
|
+
monkeypatch.delenv("CLICKSTACK_QUERY_URL", raising=False)
|
|
31
|
+
monkeypatch.delenv("CLICKSTACK_HTTP_PORT", raising=False)
|
|
32
|
+
monkeypatch.setattr(
|
|
33
|
+
"phlo_clickstack.observability_backend._running_in_container", lambda: False
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
backend = ClickStackObservabilityBackend()
|
|
37
|
+
|
|
38
|
+
assert backend._resolve_clickstack_query_url() == "http://127.0.0.1:8123"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_clickstack_backend_uses_container_url_in_container(monkeypatch) -> None:
|
|
42
|
+
monkeypatch.delenv("CLICKSTACK_QUERY_URL", raising=False)
|
|
43
|
+
monkeypatch.setattr("phlo_clickstack.observability_backend._running_in_container", lambda: True)
|
|
44
|
+
|
|
45
|
+
backend = ClickStackObservabilityBackend()
|
|
46
|
+
|
|
47
|
+
assert backend._resolve_clickstack_query_url() == "http://clickstack:8123"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_clickstack_backend_queries_trace_spans(monkeypatch) -> None:
|
|
51
|
+
captured: dict[str, object] = {}
|
|
52
|
+
|
|
53
|
+
def fake_post(url: str, data: bytes, auth: tuple[str, str] | None, timeout: int): # noqa: ANN001
|
|
54
|
+
captured["url"] = url
|
|
55
|
+
captured["query"] = data.decode("utf-8")
|
|
56
|
+
captured["auth"] = auth
|
|
57
|
+
return _FakeResponse(
|
|
58
|
+
'{"timestamp":"2026-01-01T00:00:00Z","trace_id":"abc123","span_id":"span-1","parent_span_id":null,"span_name":"materialize_orders","service_name":"dagster","span_kind":"INTERNAL","duration_ms":12.5,"status_code":"STATUS_CODE_OK","span_attributes":{"phlo.run_id":"run-123"},"resource_attributes":{"service.name":"dagster"}}\n'
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
monkeypatch.setattr("phlo_clickstack.observability_backend.requests.post", fake_post)
|
|
62
|
+
monkeypatch.setenv("CLICKSTACK_QUERY_URL", "http://clickstack.test:8123")
|
|
63
|
+
monkeypatch.setenv("CLICKSTACK_QUERY_USER", "api")
|
|
64
|
+
monkeypatch.setenv("CLICKSTACK_QUERY_PASSWORD", "api")
|
|
65
|
+
|
|
66
|
+
backend = ClickStackObservabilityBackend()
|
|
67
|
+
spans = backend.run_trace_spans("run-123", limit=25)
|
|
68
|
+
|
|
69
|
+
assert captured["url"] == "http://clickstack.test:8123"
|
|
70
|
+
assert "otel_traces" in captured["query"]
|
|
71
|
+
assert "run-123" in captured["query"]
|
|
72
|
+
assert "LIMIT 25" in captured["query"]
|
|
73
|
+
assert captured["auth"] == ("api", "api")
|
|
74
|
+
assert spans[0].trace_id == "abc123"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_build_trace_spans_query_includes_filters() -> None:
|
|
78
|
+
query = _build_trace_spans_query(
|
|
79
|
+
TraceSpanFilter(
|
|
80
|
+
run_id="run-123",
|
|
81
|
+
asset_key="silver/orders",
|
|
82
|
+
job_name="daily_orders",
|
|
83
|
+
service_name="dagster",
|
|
84
|
+
span_name="materialize_orders",
|
|
85
|
+
status_code="STATUS_CODE_ERROR",
|
|
86
|
+
start_time="2026-04-26T00:00:00Z",
|
|
87
|
+
end_time="2026-04-26T01:00:00Z",
|
|
88
|
+
limit=25,
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
assert "SpanAttributes['phlo.run_id'] = 'run-123'" in query
|
|
93
|
+
assert "SpanAttributes['phlo.asset_key'] = 'silver/orders'" in query
|
|
94
|
+
assert "SpanAttributes['phlo.job_name'] = 'daily_orders'" in query
|
|
95
|
+
assert "ServiceName = 'dagster'" in query
|
|
96
|
+
assert "SpanName = 'materialize_orders'" in query
|
|
97
|
+
assert "StatusCode = 'STATUS_CODE_ERROR'" in query
|
|
98
|
+
assert "Timestamp >= parseDateTimeBestEffort('2026-04-26T00:00:00Z')" in query
|
|
99
|
+
assert "Timestamp <= parseDateTimeBestEffort('2026-04-26T01:00:00Z')" in query
|
|
100
|
+
assert "LIMIT 25" in query
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
"""ClickStack service package for Phlo."""
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
"""CLI plugin for ClickStack commands."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import click
|
|
6
|
-
|
|
7
|
-
from phlo.plugins.base import CliCommandPlugin, PluginMetadata
|
|
8
|
-
|
|
9
|
-
from phlo_clickstack.cli import clickstack_group
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class ClickStackCliPlugin(CliCommandPlugin):
|
|
13
|
-
"""Register ClickStack CLI commands."""
|
|
14
|
-
|
|
15
|
-
@property
|
|
16
|
-
def metadata(self) -> PluginMetadata:
|
|
17
|
-
"""Return plugin metadata for ClickStack CLI registration."""
|
|
18
|
-
return PluginMetadata(
|
|
19
|
-
name="clickstack",
|
|
20
|
-
version="0.1.0",
|
|
21
|
-
description="CLI commands for ClickStack query access",
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
def get_cli_commands(self) -> list[click.Command]:
|
|
25
|
-
"""Return ClickStack CLI commands."""
|
|
26
|
-
return [clickstack_group]
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
"""ClickStack service plugin."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from importlib import resources
|
|
6
|
-
from typing import Any
|
|
7
|
-
|
|
8
|
-
import yaml
|
|
9
|
-
|
|
10
|
-
from phlo.plugins import PluginMetadata, ServicePlugin
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class ClickStackServicePlugin(ServicePlugin):
|
|
14
|
-
"""Service plugin for ClickStack."""
|
|
15
|
-
|
|
16
|
-
@property
|
|
17
|
-
def metadata(self) -> PluginMetadata:
|
|
18
|
-
"""Return plugin metadata for ClickStack service registration."""
|
|
19
|
-
return PluginMetadata(
|
|
20
|
-
name="clickstack",
|
|
21
|
-
version="0.1.0",
|
|
22
|
-
description="ClickStack all-in-one observability backend",
|
|
23
|
-
author="Phlo Team",
|
|
24
|
-
tags=["observability", "logs", "metrics", "traces"],
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
@property
|
|
28
|
-
def service_definition(self) -> dict[str, Any]:
|
|
29
|
-
"""Load and return the ClickStack service definition."""
|
|
30
|
-
service_path = resources.files("phlo_clickstack").joinpath("service.yaml")
|
|
31
|
-
return yaml.safe_load(service_path.read_text(encoding="utf-8"))
|
|
File without changes
|
|
File without changes
|
{phlo_clickstack-0.2.3 → phlo_clickstack-0.3.0}/src/phlo_clickstack.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|