licos-dev-sdk 0.2.2__tar.gz → 0.2.3__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.
- {licos_dev_sdk-0.2.2 → licos_dev_sdk-0.2.3}/PKG-INFO +1 -1
- {licos_dev_sdk-0.2.2 → licos_dev_sdk-0.2.3}/pyproject.toml +1 -1
- {licos_dev_sdk-0.2.2 → licos_dev_sdk-0.2.3}/src/licos_dev_sdk/__init__.py +15 -0
- licos_dev_sdk-0.2.3/src/licos_dev_sdk/observability.py +527 -0
- licos_dev_sdk-0.2.3/tests/test_observability.py +150 -0
- {licos_dev_sdk-0.2.2 → licos_dev_sdk-0.2.3}/.gitignore +0 -0
- {licos_dev_sdk-0.2.2 → licos_dev_sdk-0.2.3}/src/licos_dev_sdk/_utils.py +0 -0
- {licos_dev_sdk-0.2.2 → licos_dev_sdk-0.2.3}/src/licos_dev_sdk/archive.py +0 -0
- {licos_dev_sdk-0.2.2 → licos_dev_sdk-0.2.3}/src/licos_dev_sdk/chart.py +0 -0
- {licos_dev_sdk-0.2.2 → licos_dev_sdk-0.2.3}/src/licos_dev_sdk/data.py +0 -0
- {licos_dev_sdk-0.2.2 → licos_dev_sdk-0.2.3}/src/licos_dev_sdk/diagram.py +0 -0
- {licos_dev_sdk-0.2.2 → licos_dev_sdk-0.2.3}/src/licos_dev_sdk/document.py +0 -0
- {licos_dev_sdk-0.2.2 → licos_dev_sdk-0.2.3}/src/licos_dev_sdk/image.py +0 -0
- {licos_dev_sdk-0.2.2 → licos_dev_sdk-0.2.3}/src/licos_dev_sdk/model.py +0 -0
- {licos_dev_sdk-0.2.2 → licos_dev_sdk-0.2.3}/src/licos_dev_sdk/presentation.py +0 -0
- {licos_dev_sdk-0.2.2 → licos_dev_sdk-0.2.3}/src/licos_dev_sdk/spreadsheet.py +0 -0
- {licos_dev_sdk-0.2.2 → licos_dev_sdk-0.2.3}/src/licos_dev_sdk/web.py +0 -0
- {licos_dev_sdk-0.2.2 → licos_dev_sdk-0.2.3}/tests/test_model.py +0 -0
|
@@ -59,6 +59,17 @@ def __getattr__(name: str):
|
|
|
59
59
|
"generate_video": ("model", "generate_video"),
|
|
60
60
|
"recognize_speech": ("model", "recognize_speech"),
|
|
61
61
|
"understand_image": ("model", "understand_image"),
|
|
62
|
+
# observability
|
|
63
|
+
"ObservabilityClient": ("observability", "ObservabilityClient"),
|
|
64
|
+
"ObservabilityRuntime": ("observability", "ObservabilityRuntime"),
|
|
65
|
+
"ensure_observability_database": ("observability", "ensure_observability_database"),
|
|
66
|
+
"log": ("observability", "log"),
|
|
67
|
+
"log_info": ("observability", "log_info"),
|
|
68
|
+
"log_warning": ("observability", "log_warning"),
|
|
69
|
+
"log_error": ("observability", "log_error"),
|
|
70
|
+
"record_trace": ("observability", "record_trace"),
|
|
71
|
+
"record_metric": ("observability", "record_metric"),
|
|
72
|
+
"record_error": ("observability", "record_error"),
|
|
62
73
|
}
|
|
63
74
|
if name in _map:
|
|
64
75
|
mod_name, attr = _map[name]
|
|
@@ -86,4 +97,8 @@ __all__ = [
|
|
|
86
97
|
"resolve_image_generation_endpoint", "resolve_video_generation_endpoint",
|
|
87
98
|
"resolve_speech_recognition_endpoint",
|
|
88
99
|
"invoke_llm", "generate_image", "generate_video", "recognize_speech", "understand_image",
|
|
100
|
+
"ObservabilityClient", "ObservabilityRuntime",
|
|
101
|
+
"ensure_observability_database",
|
|
102
|
+
"log", "log_info", "log_warning", "log_error",
|
|
103
|
+
"record_trace", "record_metric", "record_error",
|
|
89
104
|
]
|
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass, replace
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib import error as urlerror
|
|
11
|
+
from urllib import parse, request
|
|
12
|
+
|
|
13
|
+
from licos_platform_sdk._runtime import (
|
|
14
|
+
ApiError,
|
|
15
|
+
ConfigurationError,
|
|
16
|
+
auth_headers,
|
|
17
|
+
env,
|
|
18
|
+
normalize_base_url,
|
|
19
|
+
platform_base_url,
|
|
20
|
+
resolve_user_token,
|
|
21
|
+
should_refresh_user_token,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
OBSERVABILITY_PATH = "/api/v1/studio/observability"
|
|
26
|
+
DEFAULT_TIMEOUT_SECS = 15
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ObservabilityRuntime:
|
|
31
|
+
base_url: str
|
|
32
|
+
project_id: str
|
|
33
|
+
token: str
|
|
34
|
+
workspace_id: str | None = None
|
|
35
|
+
user_id: str | None = None
|
|
36
|
+
project_type: str | None = None
|
|
37
|
+
deployment_version: str = "dev"
|
|
38
|
+
hash_version: str = "dev"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ObservabilityClient:
|
|
42
|
+
"""Client for project observability records stored by the LICOS platform."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
base_url: str | None = None,
|
|
48
|
+
user_token: str | None = None,
|
|
49
|
+
user_id: str | None = None,
|
|
50
|
+
project_id: str | None = None,
|
|
51
|
+
workspace_id: str | None = None,
|
|
52
|
+
project_type: str | None = None,
|
|
53
|
+
deployment_version: str | None = None,
|
|
54
|
+
hash_version: str | None = None,
|
|
55
|
+
timeout: int | float = DEFAULT_TIMEOUT_SECS,
|
|
56
|
+
) -> None:
|
|
57
|
+
self.runtime = _observability_runtime(
|
|
58
|
+
base_url=base_url,
|
|
59
|
+
user_token=user_token,
|
|
60
|
+
user_id=user_id,
|
|
61
|
+
project_id=project_id,
|
|
62
|
+
workspace_id=workspace_id,
|
|
63
|
+
project_type=project_type,
|
|
64
|
+
deployment_version=deployment_version,
|
|
65
|
+
hash_version=hash_version,
|
|
66
|
+
)
|
|
67
|
+
self.timeout = timeout
|
|
68
|
+
|
|
69
|
+
def ensure_database(self) -> Any:
|
|
70
|
+
"""Create or confirm the platform observability database for this project."""
|
|
71
|
+
return _request_data(
|
|
72
|
+
"POST",
|
|
73
|
+
self.runtime,
|
|
74
|
+
"/database",
|
|
75
|
+
query={"projectId": self.runtime.project_id},
|
|
76
|
+
timeout=self.timeout,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def log(
|
|
80
|
+
self,
|
|
81
|
+
level: str,
|
|
82
|
+
content: Any,
|
|
83
|
+
*,
|
|
84
|
+
log_time: datetime | str | None = None,
|
|
85
|
+
deployment_version: str | None = None,
|
|
86
|
+
) -> Any:
|
|
87
|
+
body = {
|
|
88
|
+
"logTime": _iso_time(log_time),
|
|
89
|
+
"deploymentVersion": deployment_version or self.runtime.deployment_version,
|
|
90
|
+
"level": _normalize_level(level),
|
|
91
|
+
"logContent": _to_text(content),
|
|
92
|
+
}
|
|
93
|
+
return _request_data(
|
|
94
|
+
"POST",
|
|
95
|
+
self.runtime,
|
|
96
|
+
"/logs",
|
|
97
|
+
query={"projectId": self.runtime.project_id},
|
|
98
|
+
body=body,
|
|
99
|
+
timeout=self.timeout,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def log_info(self, content: Any, **kwargs: Any) -> Any:
|
|
103
|
+
return self.log("info", content, **kwargs)
|
|
104
|
+
|
|
105
|
+
def log_warning(self, content: Any, **kwargs: Any) -> Any:
|
|
106
|
+
return self.log("warning", content, **kwargs)
|
|
107
|
+
|
|
108
|
+
def log_error(self, content: Any, *, error_code: str | None = None, **kwargs: Any) -> Any:
|
|
109
|
+
message = _error_content(content, error_code)
|
|
110
|
+
return self.log("error", message, **kwargs)
|
|
111
|
+
|
|
112
|
+
def record_trace(
|
|
113
|
+
self,
|
|
114
|
+
*,
|
|
115
|
+
trace_id: str,
|
|
116
|
+
log_id: str | None = None,
|
|
117
|
+
hash_version: str | None = None,
|
|
118
|
+
input: Any = None,
|
|
119
|
+
output: Any = None,
|
|
120
|
+
input_tokens: int | None = None,
|
|
121
|
+
output_tokens: int | None = None,
|
|
122
|
+
latency_seconds: int | float | None = None,
|
|
123
|
+
latency_first_resp_ms: int | float | None = None,
|
|
124
|
+
) -> Any:
|
|
125
|
+
body = {
|
|
126
|
+
"traceId": _required(trace_id, "trace_id"),
|
|
127
|
+
"logId": log_id,
|
|
128
|
+
"hashVersion": hash_version or self.runtime.hash_version,
|
|
129
|
+
"input": _to_text(input),
|
|
130
|
+
"output": _to_text(output),
|
|
131
|
+
"inputTokens": _int_or_zero(input_tokens),
|
|
132
|
+
"outputTokens": _int_or_zero(output_tokens),
|
|
133
|
+
"latencySeconds": _float_or_zero(latency_seconds),
|
|
134
|
+
"latencyFirstRespMs": _int_or_zero(latency_first_resp_ms),
|
|
135
|
+
}
|
|
136
|
+
return _request_data(
|
|
137
|
+
"POST",
|
|
138
|
+
self.runtime,
|
|
139
|
+
"/traces",
|
|
140
|
+
query={"projectId": self.runtime.project_id},
|
|
141
|
+
body=_compact(body),
|
|
142
|
+
timeout=self.timeout,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def record_metric(
|
|
146
|
+
self,
|
|
147
|
+
*,
|
|
148
|
+
route: str,
|
|
149
|
+
method: str,
|
|
150
|
+
latency_ms: int | float | None = None,
|
|
151
|
+
status_code: int | None = None,
|
|
152
|
+
window_start: datetime | str | None = None,
|
|
153
|
+
window_seconds: int = 60,
|
|
154
|
+
request_count: int = 1,
|
|
155
|
+
error_count: int | None = None,
|
|
156
|
+
latency_total_ms: int | float | None = None,
|
|
157
|
+
latency_count: int | None = None,
|
|
158
|
+
latency_min_ms: int | float | None = None,
|
|
159
|
+
latency_max_ms: int | float | None = None,
|
|
160
|
+
status2xx_count: int | None = None,
|
|
161
|
+
status3xx_count: int | None = None,
|
|
162
|
+
status4xx_count: int | None = None,
|
|
163
|
+
status5xx_count: int | None = None,
|
|
164
|
+
latency_buckets: dict[str, int] | None = None,
|
|
165
|
+
) -> Any:
|
|
166
|
+
status = status_code or 200
|
|
167
|
+
latency = _number_or_none(latency_ms)
|
|
168
|
+
body = {
|
|
169
|
+
"projectId": self.runtime.project_id,
|
|
170
|
+
"workspaceId": self.runtime.workspace_id,
|
|
171
|
+
"userId": self.runtime.user_id,
|
|
172
|
+
"projectType": self.runtime.project_type,
|
|
173
|
+
"route": _required(route, "route"),
|
|
174
|
+
"method": _required(method, "method").upper(),
|
|
175
|
+
"windowStart": _window_start(window_start),
|
|
176
|
+
"windowSeconds": int(window_seconds),
|
|
177
|
+
"requestCount": int(request_count),
|
|
178
|
+
"errorCount": int(error_count) if error_count is not None else (1 if status >= 400 else 0),
|
|
179
|
+
"latencyTotalMs": _number_or_zero(latency_total_ms if latency_total_ms is not None else latency),
|
|
180
|
+
"latencyCount": int(latency_count) if latency_count is not None else (1 if latency is not None else 0),
|
|
181
|
+
"latencyMinMs": _number_or_zero(latency_min_ms if latency_min_ms is not None else latency),
|
|
182
|
+
"latencyMaxMs": _number_or_zero(latency_max_ms if latency_max_ms is not None else latency),
|
|
183
|
+
"status2xxCount": int(status2xx_count) if status2xx_count is not None else (1 if 200 <= status < 300 else 0),
|
|
184
|
+
"status3xxCount": int(status3xx_count) if status3xx_count is not None else (1 if 300 <= status < 400 else 0),
|
|
185
|
+
"status4xxCount": int(status4xx_count) if status4xx_count is not None else (1 if 400 <= status < 500 else 0),
|
|
186
|
+
"status5xxCount": int(status5xx_count) if status5xx_count is not None else (1 if status >= 500 else 0),
|
|
187
|
+
"latencyBuckets": latency_buckets or _latency_bucket(latency),
|
|
188
|
+
}
|
|
189
|
+
return _request_data(
|
|
190
|
+
"POST",
|
|
191
|
+
self.runtime,
|
|
192
|
+
"/analysis/metrics",
|
|
193
|
+
query={"projectId": self.runtime.project_id},
|
|
194
|
+
body=_compact(body),
|
|
195
|
+
timeout=self.timeout,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def record_error(
|
|
199
|
+
self,
|
|
200
|
+
*,
|
|
201
|
+
route: str,
|
|
202
|
+
method: str,
|
|
203
|
+
status_code: int = 500,
|
|
204
|
+
error_code: str | None = None,
|
|
205
|
+
error_message: Any = "",
|
|
206
|
+
trace_id: str | None = None,
|
|
207
|
+
occurred_at: datetime | str | None = None,
|
|
208
|
+
) -> Any:
|
|
209
|
+
body = {
|
|
210
|
+
"projectId": self.runtime.project_id,
|
|
211
|
+
"workspaceId": self.runtime.workspace_id,
|
|
212
|
+
"userId": self.runtime.user_id,
|
|
213
|
+
"route": _required(route, "route"),
|
|
214
|
+
"method": _required(method, "method").upper(),
|
|
215
|
+
"statusCode": int(status_code),
|
|
216
|
+
"errorCode": error_code,
|
|
217
|
+
"errorMessage": _to_text(error_message),
|
|
218
|
+
"traceId": trace_id,
|
|
219
|
+
"occurredAt": _iso_time(occurred_at),
|
|
220
|
+
}
|
|
221
|
+
return _request_data(
|
|
222
|
+
"POST",
|
|
223
|
+
self.runtime,
|
|
224
|
+
"/analysis/errors",
|
|
225
|
+
query={"projectId": self.runtime.project_id},
|
|
226
|
+
body=_compact(body),
|
|
227
|
+
timeout=self.timeout,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def ensure_observability_database(**kwargs: Any) -> Any:
|
|
232
|
+
return ObservabilityClient(**kwargs).ensure_database()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def log(level: str, content: Any, **kwargs: Any) -> Any:
|
|
236
|
+
client_kwargs, call_kwargs = _split_client_kwargs(kwargs)
|
|
237
|
+
return ObservabilityClient(**client_kwargs).log(level, content, **call_kwargs)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def log_info(content: Any, **kwargs: Any) -> Any:
|
|
241
|
+
client_kwargs, call_kwargs = _split_client_kwargs(kwargs)
|
|
242
|
+
return ObservabilityClient(**client_kwargs).log_info(content, **call_kwargs)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def log_warning(content: Any, **kwargs: Any) -> Any:
|
|
246
|
+
client_kwargs, call_kwargs = _split_client_kwargs(kwargs)
|
|
247
|
+
return ObservabilityClient(**client_kwargs).log_warning(content, **call_kwargs)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def log_error(content: Any, **kwargs: Any) -> Any:
|
|
251
|
+
client_kwargs, call_kwargs = _split_client_kwargs(kwargs)
|
|
252
|
+
return ObservabilityClient(**client_kwargs).log_error(content, **call_kwargs)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def record_trace(**kwargs: Any) -> Any:
|
|
256
|
+
client_kwargs, call_kwargs = _split_client_kwargs(kwargs)
|
|
257
|
+
return ObservabilityClient(**client_kwargs).record_trace(**call_kwargs)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def record_metric(**kwargs: Any) -> Any:
|
|
261
|
+
client_kwargs, call_kwargs = _split_client_kwargs(kwargs)
|
|
262
|
+
return ObservabilityClient(**client_kwargs).record_metric(**call_kwargs)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def record_error(**kwargs: Any) -> Any:
|
|
266
|
+
client_kwargs, call_kwargs = _split_client_kwargs(kwargs)
|
|
267
|
+
return ObservabilityClient(**client_kwargs).record_error(**call_kwargs)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _observability_runtime(
|
|
271
|
+
*,
|
|
272
|
+
base_url: str | None = None,
|
|
273
|
+
user_token: str | None = None,
|
|
274
|
+
user_id: str | None = None,
|
|
275
|
+
project_id: str | None = None,
|
|
276
|
+
workspace_id: str | None = None,
|
|
277
|
+
project_type: str | None = None,
|
|
278
|
+
deployment_version: str | None = None,
|
|
279
|
+
hash_version: str | None = None,
|
|
280
|
+
) -> ObservabilityRuntime:
|
|
281
|
+
resolved_base_url = normalize_base_url(base_url) if base_url else platform_base_url()
|
|
282
|
+
owner_user_id = user_id or env("LICOS_USER_ID") or env("AGENT_USER_ID")
|
|
283
|
+
token = (user_token or "").strip() or resolve_user_token(resolved_base_url, owner_user_id)
|
|
284
|
+
resolved_project_id = project_id or env("LICOS_PROJECT_ID") or env("AGENT_PROJECT_ID")
|
|
285
|
+
if not resolved_project_id:
|
|
286
|
+
raise ConfigurationError("LICOS_PROJECT_ID or AGENT_PROJECT_ID is not configured")
|
|
287
|
+
resolved_hash = hash_version or _hash_version()
|
|
288
|
+
return ObservabilityRuntime(
|
|
289
|
+
base_url=resolved_base_url,
|
|
290
|
+
project_id=resolved_project_id,
|
|
291
|
+
token=token,
|
|
292
|
+
workspace_id=workspace_id or env("LICOS_WORKSPACE_ID") or env("AGENT_WORKSPACE_ID"),
|
|
293
|
+
user_id=owner_user_id,
|
|
294
|
+
project_type=project_type or env("LICOS_PROJECT_TYPE") or env("AGENT_PROJECT_TYPE"),
|
|
295
|
+
deployment_version=deployment_version or _deployment_version(resolved_hash),
|
|
296
|
+
hash_version=resolved_hash,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _refresh_runtime(runtime: ObservabilityRuntime) -> ObservabilityRuntime:
|
|
301
|
+
token = resolve_user_token(runtime.base_url, runtime.user_id, force_refresh=True)
|
|
302
|
+
return replace(runtime, token=token)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _request_data(
|
|
306
|
+
method: str,
|
|
307
|
+
runtime: ObservabilityRuntime,
|
|
308
|
+
path: str,
|
|
309
|
+
*,
|
|
310
|
+
query: dict[str, Any] | None = None,
|
|
311
|
+
body: dict[str, Any] | None = None,
|
|
312
|
+
timeout: int | float = DEFAULT_TIMEOUT_SECS,
|
|
313
|
+
refresh: bool = False,
|
|
314
|
+
) -> Any:
|
|
315
|
+
url = f"{runtime.base_url}{OBSERVABILITY_PATH}{path}"
|
|
316
|
+
if query:
|
|
317
|
+
url = f"{url}?{parse.urlencode(_compact(query))}"
|
|
318
|
+
data = json.dumps(body or {}).encode("utf-8") if body is not None else None
|
|
319
|
+
req = request.Request(url, method=method, data=data, headers=auth_headers(runtime)) # type: ignore[arg-type]
|
|
320
|
+
try:
|
|
321
|
+
with request.urlopen(req, timeout=timeout) as response:
|
|
322
|
+
return _decode_response(response.read(), status=getattr(response, "status", None))
|
|
323
|
+
except urlerror.HTTPError as exc:
|
|
324
|
+
api_error = _api_error_from_http_error(exc)
|
|
325
|
+
if not refresh and should_refresh_user_token(api_error):
|
|
326
|
+
return _request_data(
|
|
327
|
+
method,
|
|
328
|
+
_refresh_runtime(runtime),
|
|
329
|
+
path,
|
|
330
|
+
query=query,
|
|
331
|
+
body=body,
|
|
332
|
+
timeout=timeout,
|
|
333
|
+
refresh=True,
|
|
334
|
+
)
|
|
335
|
+
raise api_error from exc
|
|
336
|
+
except urlerror.URLError as exc:
|
|
337
|
+
raise ApiError(f"observability API request failed: {exc}") from exc
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _decode_response(raw: bytes, *, status: int | None = None) -> Any:
|
|
341
|
+
if not raw:
|
|
342
|
+
return None
|
|
343
|
+
try:
|
|
344
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
345
|
+
except json.JSONDecodeError as exc:
|
|
346
|
+
raise ApiError("parse observability API response failed", status=status) from exc
|
|
347
|
+
if not isinstance(payload, dict):
|
|
348
|
+
raise ApiError("observability API response is not an object", status=status, details=payload)
|
|
349
|
+
code = payload.get("code")
|
|
350
|
+
if code not in (None, 0) or payload.get("success") is False:
|
|
351
|
+
raise ApiError(
|
|
352
|
+
str(payload.get("message") or "observability API failed"),
|
|
353
|
+
status=status,
|
|
354
|
+
code=code if isinstance(code, int) else None,
|
|
355
|
+
details=payload,
|
|
356
|
+
)
|
|
357
|
+
return payload.get("data")
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _api_error_from_http_error(exc: urlerror.HTTPError) -> ApiError:
|
|
361
|
+
detail = exc.read()
|
|
362
|
+
if detail:
|
|
363
|
+
try:
|
|
364
|
+
payload = json.loads(detail.decode("utf-8"))
|
|
365
|
+
except json.JSONDecodeError:
|
|
366
|
+
payload = None
|
|
367
|
+
if isinstance(payload, dict):
|
|
368
|
+
code = payload.get("code")
|
|
369
|
+
return ApiError(
|
|
370
|
+
str(payload.get("message") or f"observability API returned {exc.code}"),
|
|
371
|
+
status=exc.code,
|
|
372
|
+
code=code if isinstance(code, int) else None,
|
|
373
|
+
details=payload,
|
|
374
|
+
)
|
|
375
|
+
return ApiError(detail.decode("utf-8", errors="replace"), status=exc.code)
|
|
376
|
+
return ApiError(f"observability API returned {exc.code}", status=exc.code)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _split_client_kwargs(kwargs: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
380
|
+
client_keys = {
|
|
381
|
+
"base_url",
|
|
382
|
+
"user_token",
|
|
383
|
+
"user_id",
|
|
384
|
+
"project_id",
|
|
385
|
+
"workspace_id",
|
|
386
|
+
"project_type",
|
|
387
|
+
"deployment_version",
|
|
388
|
+
"hash_version",
|
|
389
|
+
"timeout",
|
|
390
|
+
}
|
|
391
|
+
client_kwargs: dict[str, Any] = {}
|
|
392
|
+
call_kwargs: dict[str, Any] = {}
|
|
393
|
+
for key, value in kwargs.items():
|
|
394
|
+
if key in client_keys:
|
|
395
|
+
client_kwargs[key] = value
|
|
396
|
+
else:
|
|
397
|
+
call_kwargs[key] = value
|
|
398
|
+
return client_kwargs, call_kwargs
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _required(value: str | None, name: str) -> str:
|
|
402
|
+
text = (value or "").strip()
|
|
403
|
+
if not text:
|
|
404
|
+
raise ValueError(f"{name} is required")
|
|
405
|
+
return text
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _compact(value: dict[str, Any]) -> dict[str, Any]:
|
|
409
|
+
return {key: item for key, item in value.items() if item is not None}
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _iso_time(value: datetime | str | None) -> str:
|
|
413
|
+
if value is None:
|
|
414
|
+
value = datetime.now(timezone.utc)
|
|
415
|
+
if isinstance(value, str):
|
|
416
|
+
return value
|
|
417
|
+
if value.tzinfo is None:
|
|
418
|
+
value = value.replace(tzinfo=timezone.utc)
|
|
419
|
+
return value.isoformat().replace("+00:00", "Z")
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _window_start(value: datetime | str | None) -> str:
|
|
423
|
+
if value is not None:
|
|
424
|
+
return _iso_time(value)
|
|
425
|
+
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
|
|
426
|
+
return _iso_time(now)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _normalize_level(level: str) -> str:
|
|
430
|
+
text = (level or "info").strip().lower()
|
|
431
|
+
if text in {"warn", "warning"}:
|
|
432
|
+
return "warning"
|
|
433
|
+
if text in {"err", "error", "fatal"}:
|
|
434
|
+
return "error"
|
|
435
|
+
return "info"
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _to_text(value: Any) -> str:
|
|
439
|
+
if value is None:
|
|
440
|
+
return ""
|
|
441
|
+
if isinstance(value, str):
|
|
442
|
+
return value
|
|
443
|
+
try:
|
|
444
|
+
return json.dumps(value, ensure_ascii=False, default=str)
|
|
445
|
+
except TypeError:
|
|
446
|
+
return str(value)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _error_content(content: Any, error_code: str | None) -> str:
|
|
450
|
+
text = _to_text(content)
|
|
451
|
+
code = (error_code or "").strip()
|
|
452
|
+
return f"[{code}] {text}" if code else text
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _int_or_zero(value: int | float | None) -> int:
|
|
456
|
+
if value is None:
|
|
457
|
+
return 0
|
|
458
|
+
return int(value)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _float_or_zero(value: int | float | None) -> float:
|
|
462
|
+
if value is None:
|
|
463
|
+
return 0.0
|
|
464
|
+
return float(value)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _number_or_none(value: int | float | None) -> int | float | None:
|
|
468
|
+
if value is None:
|
|
469
|
+
return None
|
|
470
|
+
return value
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _number_or_zero(value: int | float | None) -> int | float:
|
|
474
|
+
if value is None:
|
|
475
|
+
return 0
|
|
476
|
+
return value
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _latency_bucket(latency_ms: int | float | None) -> dict[str, int]:
|
|
480
|
+
if latency_ms is None:
|
|
481
|
+
return {}
|
|
482
|
+
latency = float(latency_ms)
|
|
483
|
+
if latency < 100:
|
|
484
|
+
return {"0-100": 1}
|
|
485
|
+
if latency < 500:
|
|
486
|
+
return {"100-500": 1}
|
|
487
|
+
return {"500+": 1}
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _deployment_version(hash_version: str) -> str:
|
|
491
|
+
return (
|
|
492
|
+
env("LICOS_DEPLOYMENT_VERSION")
|
|
493
|
+
or env("DEPLOY_VERSION")
|
|
494
|
+
or env("LICOS_RELEASE_VERSION")
|
|
495
|
+
or hash_version
|
|
496
|
+
or "dev"
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _hash_version() -> str:
|
|
501
|
+
return (
|
|
502
|
+
env("LICOS_DEPLOYMENT_HASH")
|
|
503
|
+
or env("LICOS_DEPLOY_HASH")
|
|
504
|
+
or env("SOURCE_COMMIT")
|
|
505
|
+
or env("GIT_COMMIT")
|
|
506
|
+
or _git_commit_short()
|
|
507
|
+
or env("AGENT_PROJECT_ID")
|
|
508
|
+
or "dev"
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _git_commit_short() -> str | None:
|
|
513
|
+
cwd = os.environ.get("LICOS_PROJECT_PATH") or os.getcwd()
|
|
514
|
+
try:
|
|
515
|
+
result = subprocess.run(
|
|
516
|
+
["git", "rev-parse", "--short", "HEAD"],
|
|
517
|
+
cwd=cwd,
|
|
518
|
+
capture_output=True,
|
|
519
|
+
check=False,
|
|
520
|
+
text=True,
|
|
521
|
+
timeout=1,
|
|
522
|
+
)
|
|
523
|
+
except (OSError, subprocess.SubprocessError):
|
|
524
|
+
return None
|
|
525
|
+
text = result.stdout.strip()
|
|
526
|
+
return text if result.returncode == 0 and text else None
|
|
527
|
+
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import unittest
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
from unittest import mock
|
|
10
|
+
from urllib import error as urlerror
|
|
11
|
+
|
|
12
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
13
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "licos-platform-sdk" / "src"))
|
|
14
|
+
|
|
15
|
+
from licos_dev_sdk import observability
|
|
16
|
+
from licos_platform_sdk import _runtime
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _FakeResponse:
|
|
20
|
+
status = 200
|
|
21
|
+
|
|
22
|
+
def __init__(self, payload: dict[str, Any]) -> None:
|
|
23
|
+
self._payload = payload
|
|
24
|
+
|
|
25
|
+
def __enter__(self) -> "_FakeResponse":
|
|
26
|
+
return self
|
|
27
|
+
|
|
28
|
+
def __exit__(self, *_args: Any) -> None:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
def read(self) -> bytes:
|
|
32
|
+
return json.dumps(self._payload).encode("utf-8")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _FakeErrorBody:
|
|
36
|
+
def __init__(self, payload: dict[str, Any]) -> None:
|
|
37
|
+
self._payload = payload
|
|
38
|
+
|
|
39
|
+
def read(self) -> bytes:
|
|
40
|
+
return json.dumps(self._payload).encode("utf-8")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ObservabilitySdkTests(unittest.TestCase):
|
|
44
|
+
def setUp(self) -> None:
|
|
45
|
+
self.env = mock.patch.dict(
|
|
46
|
+
os.environ,
|
|
47
|
+
{
|
|
48
|
+
"LICOS_PLATFORM_API_BASE_URL": "http://platform.example/api/v1",
|
|
49
|
+
"AGENT_USER_ID": "user-1",
|
|
50
|
+
"AGENT_WORKSPACE_ID": "workspace-1",
|
|
51
|
+
"AGENT_PROJECT_ID": "project-1",
|
|
52
|
+
"AGENT_PROJECT_TYPE": "AGENT",
|
|
53
|
+
"LICOS_AI_AGENT_TOKEN": "ai-agent-token",
|
|
54
|
+
"LICOS_DEPLOYMENT_VERSION": "v1",
|
|
55
|
+
"LICOS_DEPLOYMENT_HASH": "hash-1",
|
|
56
|
+
},
|
|
57
|
+
clear=True,
|
|
58
|
+
)
|
|
59
|
+
self.env.start()
|
|
60
|
+
self.addCleanup(self.env.stop)
|
|
61
|
+
_runtime._clear_token_cache_for_tests()
|
|
62
|
+
|
|
63
|
+
def test_log_uses_runtime_context_and_owner_token(self) -> None:
|
|
64
|
+
captured: dict[str, Any] = {}
|
|
65
|
+
|
|
66
|
+
def fake_urlopen(req: Any, timeout: int = 0) -> _FakeResponse:
|
|
67
|
+
if req.full_url == "http://platform.example/api/v1/internal/auth/ai-user-token":
|
|
68
|
+
captured["exchange_headers"] = dict(req.header_items())
|
|
69
|
+
captured["exchange_body"] = json.loads(req.data.decode("utf-8"))
|
|
70
|
+
return _FakeResponse({"code": 0, "success": True, "data": {"accessToken": "user-token"}})
|
|
71
|
+
if req.full_url == "http://platform.example/api/v1/studio/observability/logs?projectId=project-1":
|
|
72
|
+
captured["log_headers"] = dict(req.header_items())
|
|
73
|
+
captured["log_body"] = json.loads(req.data.decode("utf-8"))
|
|
74
|
+
return _FakeResponse({"code": 0, "success": True, "data": {"id": 1}})
|
|
75
|
+
raise AssertionError(req.full_url)
|
|
76
|
+
|
|
77
|
+
with mock.patch.object(observability.request, "urlopen", fake_urlopen):
|
|
78
|
+
result = observability.log_info("started", log_time="2026-05-22T10:00:00+08:00")
|
|
79
|
+
|
|
80
|
+
self.assertEqual(result, {"id": 1})
|
|
81
|
+
self.assertEqual(captured["exchange_headers"]["Authorization"], "Bearer ai-agent-token")
|
|
82
|
+
self.assertEqual(captured["exchange_body"], {"userId": "user-1"})
|
|
83
|
+
self.assertEqual(captured["log_headers"]["Authorization"], "Bearer user-token")
|
|
84
|
+
log_headers = {key.lower(): value for key, value in captured["log_headers"].items()}
|
|
85
|
+
self.assertEqual(log_headers["x-workspace-id"], "workspace-1")
|
|
86
|
+
self.assertEqual(captured["log_body"]["deploymentVersion"], "v1")
|
|
87
|
+
self.assertEqual(captured["log_body"]["level"], "info")
|
|
88
|
+
self.assertEqual(captured["log_body"]["logContent"], "started")
|
|
89
|
+
|
|
90
|
+
def test_trace_metric_and_error_payloads(self) -> None:
|
|
91
|
+
calls: list[tuple[str, dict[str, Any]]] = []
|
|
92
|
+
|
|
93
|
+
def fake_urlopen(req: Any, timeout: int = 0) -> _FakeResponse:
|
|
94
|
+
if req.full_url == "http://platform.example/api/v1/internal/auth/ai-user-token":
|
|
95
|
+
return _FakeResponse({"code": 0, "success": True, "data": {"accessToken": "user-token"}})
|
|
96
|
+
calls.append((req.full_url, json.loads(req.data.decode("utf-8"))))
|
|
97
|
+
return _FakeResponse({"code": 0, "success": True, "data": {"ok": True}})
|
|
98
|
+
|
|
99
|
+
with mock.patch.object(observability.request, "urlopen", fake_urlopen):
|
|
100
|
+
client = observability.ObservabilityClient()
|
|
101
|
+
client.record_trace(trace_id="trace-1", input={"q": "hi"}, output="ok", input_tokens=3, output_tokens=4, latency_seconds=1.2)
|
|
102
|
+
client.record_metric(route="/stream_run", method="post", latency_ms=640, status_code=500)
|
|
103
|
+
client.record_error(route="/stream_run", method="post", error_code="MODEL_TIMEOUT", error_message="timeout", trace_id="trace-1")
|
|
104
|
+
|
|
105
|
+
self.assertEqual(calls[0][0], "http://platform.example/api/v1/studio/observability/traces?projectId=project-1")
|
|
106
|
+
self.assertEqual(calls[0][1]["traceId"], "trace-1")
|
|
107
|
+
self.assertEqual(calls[0][1]["hashVersion"], "hash-1")
|
|
108
|
+
self.assertEqual(calls[0][1]["input"], '{"q": "hi"}')
|
|
109
|
+
self.assertEqual(calls[0][1]["inputTokens"], 3)
|
|
110
|
+
|
|
111
|
+
self.assertEqual(calls[1][0], "http://platform.example/api/v1/studio/observability/analysis/metrics?projectId=project-1")
|
|
112
|
+
self.assertEqual(calls[1][1]["workspaceId"], "workspace-1")
|
|
113
|
+
self.assertEqual(calls[1][1]["projectType"], "AGENT")
|
|
114
|
+
self.assertEqual(calls[1][1]["method"], "POST")
|
|
115
|
+
self.assertEqual(calls[1][1]["errorCount"], 1)
|
|
116
|
+
self.assertEqual(calls[1][1]["status5xxCount"], 1)
|
|
117
|
+
self.assertEqual(calls[1][1]["latencyBuckets"], {"500+": 1})
|
|
118
|
+
|
|
119
|
+
self.assertEqual(calls[2][0], "http://platform.example/api/v1/studio/observability/analysis/errors?projectId=project-1")
|
|
120
|
+
self.assertEqual(calls[2][1]["errorCode"], "MODEL_TIMEOUT")
|
|
121
|
+
self.assertEqual(calls[2][1]["traceId"], "trace-1")
|
|
122
|
+
|
|
123
|
+
def test_refreshes_owner_token_once_after_unauthorized(self) -> None:
|
|
124
|
+
tokens = iter(["old-token", "new-token"])
|
|
125
|
+
log_tokens: list[str] = []
|
|
126
|
+
|
|
127
|
+
def fake_urlopen(req: Any, timeout: int = 0) -> _FakeResponse:
|
|
128
|
+
if req.full_url == "http://platform.example/api/v1/internal/auth/ai-user-token":
|
|
129
|
+
return _FakeResponse({"code": 0, "success": True, "data": {"accessToken": next(tokens)}})
|
|
130
|
+
if req.full_url == "http://platform.example/api/v1/studio/observability/logs?projectId=project-1":
|
|
131
|
+
log_tokens.append(dict(req.header_items())["Authorization"])
|
|
132
|
+
if len(log_tokens) == 1:
|
|
133
|
+
raise urlerror.HTTPError(
|
|
134
|
+
req.full_url,
|
|
135
|
+
401,
|
|
136
|
+
"Unauthorized",
|
|
137
|
+
hdrs=None,
|
|
138
|
+
fp=_FakeErrorBody({"code": 10002, "message": "token invalid or expired", "success": False}),
|
|
139
|
+
)
|
|
140
|
+
return _FakeResponse({"code": 0, "success": True, "data": {"id": 1}})
|
|
141
|
+
raise AssertionError(req.full_url)
|
|
142
|
+
|
|
143
|
+
with mock.patch.object(observability.request, "urlopen", fake_urlopen):
|
|
144
|
+
observability.log_error("failed")
|
|
145
|
+
|
|
146
|
+
self.assertEqual(log_tokens, ["Bearer old-token", "Bearer new-token"])
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
if __name__ == "__main__":
|
|
150
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|