phlo-clickstack 0.2.3__tar.gz → 0.3.1__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.
Files changed (26) hide show
  1. {phlo_clickstack-0.2.3 → phlo_clickstack-0.3.1}/PKG-INFO +1 -1
  2. {phlo_clickstack-0.2.3 → phlo_clickstack-0.3.1}/README.md +4 -10
  3. {phlo_clickstack-0.2.3 → phlo_clickstack-0.3.1}/pyproject.toml +4 -1
  4. phlo_clickstack-0.3.1/src/phlo_clickstack/__init__.py +21 -0
  5. phlo_clickstack-0.3.1/src/phlo_clickstack/authorization.py +166 -0
  6. phlo_clickstack-0.3.1/src/phlo_clickstack/cli.py +167 -0
  7. phlo_clickstack-0.3.1/src/phlo_clickstack/cli_plugin.py +49 -0
  8. phlo_clickstack-0.3.1/src/phlo_clickstack/observability_backend.py +148 -0
  9. phlo_clickstack-0.3.1/src/phlo_clickstack/plugin.py +44 -0
  10. phlo_clickstack-0.3.1/src/phlo_clickstack/resource_provider.py +26 -0
  11. {phlo_clickstack-0.2.3 → phlo_clickstack-0.3.1}/src/phlo_clickstack/service.yaml +11 -13
  12. {phlo_clickstack-0.2.3 → phlo_clickstack-0.3.1}/src/phlo_clickstack.egg-info/PKG-INFO +1 -1
  13. {phlo_clickstack-0.2.3 → phlo_clickstack-0.3.1}/src/phlo_clickstack.egg-info/SOURCES.txt +6 -1
  14. {phlo_clickstack-0.2.3 → phlo_clickstack-0.3.1}/src/phlo_clickstack.egg-info/entry_points.txt +3 -0
  15. phlo_clickstack-0.3.1/tests/test_authorization.py +188 -0
  16. {phlo_clickstack-0.2.3 → phlo_clickstack-0.3.1}/tests/test_clickstack_cli.py +61 -5
  17. {phlo_clickstack-0.2.3 → phlo_clickstack-0.3.1}/tests/test_clickstack_plugin.py +6 -0
  18. phlo_clickstack-0.3.1/tests/test_observability_backend.py +100 -0
  19. phlo_clickstack-0.2.3/src/phlo_clickstack/__init__.py +0 -1
  20. phlo_clickstack-0.2.3/src/phlo_clickstack/cli.py +0 -113
  21. phlo_clickstack-0.2.3/src/phlo_clickstack/cli_plugin.py +0 -26
  22. phlo_clickstack-0.2.3/src/phlo_clickstack/plugin.py +0 -31
  23. {phlo_clickstack-0.2.3 → phlo_clickstack-0.3.1}/setup.cfg +0 -0
  24. {phlo_clickstack-0.2.3 → phlo_clickstack-0.3.1}/src/phlo_clickstack.egg-info/dependency_links.txt +0 -0
  25. {phlo_clickstack-0.2.3 → phlo_clickstack-0.3.1}/src/phlo_clickstack.egg-info/requires.txt +0 -0
  26. {phlo_clickstack-0.2.3 → phlo_clickstack-0.3.1}/src/phlo_clickstack.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: phlo-clickstack
3
- Version: 0.2.3
3
+ Version: 0.3.1
4
4
  Summary: clickstack service plugin for Phlo
5
5
  Author-email: Phlo Team <team@phlo.dev>
6
6
  License: MIT
@@ -5,8 +5,7 @@ ClickStack observability service for Phlo.
5
5
  ## Overview
6
6
 
7
7
  `phlo-clickstack` packages the official ClickStack all-in-one image so Phlo can
8
- ship a single OpenTelemetry-native observability target with logs, metrics, and
9
- traces in one UI.
8
+ query ClickHouse-backed observability data and link to the HyperDX UI.
10
9
 
11
10
  ## Installation
12
11
 
@@ -27,11 +26,6 @@ phlo services start --service clickstack
27
26
  phlo clickstack query "SELECT count() FROM default.otel_logs"
28
27
  ```
29
28
 
30
- Point `phlo-otel` at ClickStack:
31
-
32
- ```bash
33
- export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
34
- export OTEL_TRACES_EXPORTER=otlp
35
- export OTEL_METRICS_EXPORTER=otlp
36
- export OTEL_LOGS_EXPORTER=otlp
37
- ```
29
+ The bundled all-in-one image exposes the HyperDX UI and ClickHouse query ports.
30
+ Use Alloy or another collector for OTLP ingest; this service does not publish
31
+ OTLP ports by default because the image does not listen on those ports.
@@ -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.2.3"
16
+ version = "0.3.1"
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
@@ -0,0 +1,167 @@
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
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from subprocess import TimeoutExpired
13
+
14
+ import click
15
+
16
+ from phlo.cli.commands.services import utils as services_utils
17
+ from phlo.cli.infrastructure.command import CommandError, run_command
18
+ from phlo.cli.infrastructure.compose import compose_base_cmd
19
+ from phlo.cli.infrastructure.utils import get_project_name
20
+ from phlo.cli.output import (
21
+ empty_file_error,
22
+ exclusive_options_error,
23
+ file_read_error,
24
+ missing_phlo_project_error,
25
+ missing_query_error,
26
+ )
27
+ from phlo.logging import get_logger
28
+
29
+ logger = get_logger(__name__)
30
+
31
+
32
+ def _read_query(*, query: str | None, file: Path | None) -> str:
33
+ """Read and validate SQL query from inline argument or file.
34
+
35
+ Accepts either an inline SQL query string or a path to a SQL file.
36
+ Validates that exactly one input source is provided and that the
37
+ content is non-empty.
38
+
39
+ Args:
40
+ query: Inline SQL query string, or None if reading from file.
41
+ file: Path to SQL file, or None if using inline query.
42
+
43
+ Returns:
44
+ str: The SQL query text to execute.
45
+
46
+ Raises:
47
+ click.ClickException: If both query and file are provided.
48
+ click.ClickException: If file cannot be read.
49
+ click.ClickException: If file is empty or contains only whitespace.
50
+ click.ClickException: If neither query nor file is provided.
51
+
52
+ """
53
+ if query and file:
54
+ raise exclusive_options_error("an inline query", "--file")
55
+ if file is not None:
56
+ try:
57
+ sql = file.read_text(encoding="utf-8")
58
+ except OSError as exc:
59
+ raise file_read_error(file) from exc
60
+ if sql.strip():
61
+ return sql
62
+ raise empty_file_error(file)
63
+ if query and query.strip():
64
+ return query
65
+ raise missing_query_error(command_hint='phlo clickstack query "SELECT 1"')
66
+
67
+
68
+ def _ensure_phlo_dir() -> Path:
69
+ """Locate and return the local .phlo directory.
70
+
71
+ Searches for a .phlo directory in the current working directory.
72
+ This directory is required for Docker Compose project configuration.
73
+
74
+ Returns:
75
+ Path: Path to the .phlo directory.
76
+
77
+ Raises:
78
+ click.ClickException: If .phlo directory does not exist.
79
+
80
+ """
81
+ phlo_dir = Path.cwd() / ".phlo"
82
+ if phlo_dir.exists():
83
+ return phlo_dir
84
+ raise missing_phlo_project_error()
85
+
86
+
87
+ def _require_container_backend() -> None:
88
+ """Validate that the selected container backend is available."""
89
+ services_utils.require_container_backend()
90
+
91
+
92
+ @click.group(name="clickstack")
93
+ def clickstack_group() -> None:
94
+ """Query and inspect the ClickStack service.
95
+
96
+ Provides commands for interacting with the ClickStack ClickHouse
97
+ container, including executing SQL queries.
98
+ """
99
+
100
+
101
+ @clickstack_group.command(name="query")
102
+ @click.argument("query", required=False)
103
+ @click.option("--file", "query_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
104
+ @click.option("--format", "output_format", default="TabSeparatedRaw", show_default=True)
105
+ @click.option("--timeout", "timeout_seconds", default=30, show_default=True, type=int)
106
+ def clickstack_query(
107
+ query: str | None,
108
+ query_file: Path | None,
109
+ output_format: str,
110
+ timeout_seconds: int,
111
+ ) -> None:
112
+ """Execute a ClickHouse query against the running ClickStack service.
113
+
114
+ Runs a SQL query via clickhouse-client inside the ClickStack container.
115
+ Accepts SQL either as a command argument or from a file. Requires Docker
116
+ and a running phlo services stack with ClickStack enabled.
117
+
118
+ Args:
119
+ query: SQL query string to execute.
120
+ query_file: Path to file containing SQL query.
121
+ output_format: ClickHouse output format (default: TabSeparatedRaw).
122
+ timeout_seconds: Query execution timeout in seconds.
123
+
124
+ Raises:
125
+ click.ClickException: If Docker is unavailable.
126
+ click.ClickException: If .phlo directory is missing.
127
+ click.ClickException: If both query and file options provided.
128
+ click.ClickException: If query file cannot be read.
129
+ click.ClickException: If query execution fails.
130
+ click.ClickException: If query times out.
131
+
132
+ """
133
+ _require_container_backend()
134
+ phlo_dir = _ensure_phlo_dir()
135
+ project_name = get_project_name()
136
+ sql = _read_query(query=query, file=query_file)
137
+
138
+ cmd = compose_base_cmd(phlo_dir=phlo_dir, project_name=project_name)
139
+ cmd.extend(
140
+ [
141
+ "exec",
142
+ "-T",
143
+ "clickstack",
144
+ "clickhouse-client",
145
+ "--multiquery",
146
+ "--format",
147
+ output_format,
148
+ "--query",
149
+ sql,
150
+ ]
151
+ )
152
+
153
+ try:
154
+ result = run_command(
155
+ cmd,
156
+ timeout_seconds=timeout_seconds,
157
+ capture_output=True,
158
+ check=True,
159
+ )
160
+ except CommandError as exc:
161
+ stderr = exc.stderr.strip()
162
+ raise click.ClickException(stderr or str(exc)) from exc
163
+ except TimeoutExpired as exc:
164
+ raise click.ClickException(f"Query timed out after {timeout_seconds} seconds.") from exc
165
+
166
+ if result.stdout:
167
+ click.echo(result.stdout, nl=False)
@@ -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()]