ddeutil-workflow 0.0.78__py3-none-any.whl → 0.0.79__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ddeutil/workflow/__about__.py +1 -1
- ddeutil/workflow/__init__.py +1 -5
- ddeutil/workflow/api/routes/job.py +2 -2
- ddeutil/workflow/audits.py +554 -112
- ddeutil/workflow/cli.py +19 -1
- ddeutil/workflow/conf.py +9 -21
- ddeutil/workflow/event.py +15 -6
- ddeutil/workflow/job.py +147 -73
- ddeutil/workflow/params.py +172 -58
- ddeutil/workflow/plugins/__init__.py +0 -0
- ddeutil/workflow/plugins/providers/__init__.py +0 -0
- ddeutil/workflow/plugins/providers/aws.py +908 -0
- ddeutil/workflow/plugins/providers/az.py +1003 -0
- ddeutil/workflow/plugins/providers/container.py +703 -0
- ddeutil/workflow/plugins/providers/gcs.py +826 -0
- ddeutil/workflow/result.py +6 -4
- ddeutil/workflow/reusables.py +151 -95
- ddeutil/workflow/stages.py +28 -28
- ddeutil/workflow/traces.py +1678 -540
- ddeutil/workflow/utils.py +109 -67
- ddeutil/workflow/workflow.py +20 -11
- {ddeutil_workflow-0.0.78.dist-info → ddeutil_workflow-0.0.79.dist-info}/METADATA +52 -19
- ddeutil_workflow-0.0.79.dist-info/RECORD +36 -0
- ddeutil_workflow-0.0.78.dist-info/RECORD +0 -30
- {ddeutil_workflow-0.0.78.dist-info → ddeutil_workflow-0.0.79.dist-info}/WHEEL +0 -0
- {ddeutil_workflow-0.0.78.dist-info → ddeutil_workflow-0.0.79.dist-info}/entry_points.txt +0 -0
- {ddeutil_workflow-0.0.78.dist-info → ddeutil_workflow-0.0.79.dist-info}/licenses/LICENSE +0 -0
- {ddeutil_workflow-0.0.78.dist-info → ddeutil_workflow-0.0.79.dist-info}/top_level.txt +0 -0
ddeutil/workflow/traces.py
CHANGED
@@ -13,45 +13,26 @@ The tracing system captures detailed execution metadata including process IDs,
|
|
13
13
|
thread identifiers, timestamps, and contextual information for debugging and
|
14
14
|
monitoring workflow executions.
|
15
15
|
|
16
|
-
Classes:
|
17
|
-
Message: Log message model with prefix parsing
|
18
|
-
TraceMeta: Metadata model for execution context
|
19
|
-
TraceData: Container for trace information
|
20
|
-
BaseTrace: Abstract base class for trace implementations
|
21
|
-
ConsoleTrace: Console-based trace output
|
22
|
-
FileTrace: File-based trace storage
|
23
|
-
SQLiteTrace: Database-based trace storage
|
24
|
-
|
25
16
|
Functions:
|
26
|
-
set_logging: Configure logger with custom formatting
|
27
|
-
get_trace: Factory function for trace instances
|
28
|
-
|
29
|
-
Example:
|
30
|
-
>>> from ddeutil.workflow.traces import get_trace
|
31
|
-
>>> # Create file-based trace
|
32
|
-
>>> trace = get_trace("running-id-101", parent_run_id="workflow-001")
|
33
|
-
>>> trace.info("Workflow execution started")
|
34
|
-
>>> trace.debug("Processing stage 1")
|
35
|
-
|
17
|
+
set_logging: Configure logger with custom formatting.
|
18
|
+
get_trace: Factory function for trace instances.
|
36
19
|
"""
|
37
|
-
from __future__ import annotations
|
38
|
-
|
39
20
|
import json
|
40
21
|
import logging
|
41
22
|
import os
|
42
23
|
import re
|
43
24
|
from abc import ABC, abstractmethod
|
44
25
|
from collections.abc import Iterator
|
26
|
+
from datetime import datetime
|
45
27
|
from functools import lru_cache
|
46
28
|
from inspect import Traceback, currentframe, getframeinfo
|
47
29
|
from pathlib import Path
|
48
|
-
from threading import get_ident
|
30
|
+
from threading import Lock, get_ident
|
49
31
|
from types import FrameType
|
50
|
-
from typing import ClassVar, Final, Literal, Optional, Union
|
51
|
-
from
|
32
|
+
from typing import Annotated, Any, ClassVar, Final, Literal, Optional, Union
|
33
|
+
from zoneinfo import ZoneInfo
|
52
34
|
|
53
|
-
from pydantic import BaseModel,
|
54
|
-
from pydantic.functional_serializers import field_serializer
|
35
|
+
from pydantic import BaseModel, Field, PrivateAttr
|
55
36
|
from pydantic.functional_validators import field_validator
|
56
37
|
from typing_extensions import Self
|
57
38
|
|
@@ -59,8 +40,8 @@ from .__types import DictData
|
|
59
40
|
from .conf import config, dynamic
|
60
41
|
from .utils import cut_id, get_dt_now, prepare_newline
|
61
42
|
|
62
|
-
METADATA: str = "metadata.json"
|
63
43
|
logger = logging.getLogger("ddeutil.workflow")
|
44
|
+
Level = Literal["debug", "info", "warning", "error", "exception"]
|
64
45
|
|
65
46
|
|
66
47
|
@lru_cache
|
@@ -72,16 +53,14 @@ def set_logging(name: str) -> logging.Logger:
|
|
72
53
|
console output and proper formatting for workflow execution tracking.
|
73
54
|
|
74
55
|
Args:
|
75
|
-
name: Module name to create logger for
|
56
|
+
name: Module name to create logger for.
|
76
57
|
|
77
58
|
Returns:
|
78
|
-
logging.Logger: Configured logger instance with custom formatting
|
59
|
+
logging.Logger: Configured logger instance with custom formatting.
|
79
60
|
|
80
61
|
Example:
|
81
|
-
|
82
|
-
|
83
|
-
logger.info("Stage execution started")
|
84
|
-
```
|
62
|
+
>>> log = set_logging("ddeutil.workflow.stages")
|
63
|
+
>>> log.info("Stage execution started")
|
85
64
|
"""
|
86
65
|
_logger = logging.getLogger(name)
|
87
66
|
|
@@ -104,25 +83,28 @@ def set_logging(name: str) -> logging.Logger:
|
|
104
83
|
|
105
84
|
PREFIX_LOGS: Final[dict[str, dict]] = {
|
106
85
|
"CALLER": {
|
107
|
-
"emoji": "
|
86
|
+
"emoji": "⚙️",
|
108
87
|
"desc": "logs from any usage from custom caller function.",
|
109
88
|
},
|
110
|
-
"STAGE": {"emoji": "
|
89
|
+
"STAGE": {"emoji": "🔗", "desc": "logs from stages module."},
|
111
90
|
"JOB": {"emoji": "⛓️", "desc": "logs from job module."},
|
112
91
|
"WORKFLOW": {"emoji": "🏃", "desc": "logs from workflow module."},
|
113
92
|
"RELEASE": {"emoji": "📅", "desc": "logs from release workflow method."},
|
114
93
|
"POKING": {"emoji": "⏰", "desc": "logs from poke workflow method."},
|
94
|
+
"AUDIT": {"emoji": "📌", "desc": "logs from audit model."},
|
115
95
|
} # pragma: no cov
|
116
96
|
PREFIX_DEFAULT: Final[str] = "CALLER"
|
117
|
-
PREFIX_LOGS_REGEX: re.Pattern[str] = re.compile(
|
97
|
+
PREFIX_LOGS_REGEX: Final[re.Pattern[str]] = re.compile(
|
118
98
|
rf"(^\[(?P<name>{'|'.join(PREFIX_LOGS)})]:\s?)?(?P<message>.*)",
|
119
99
|
re.MULTILINE | re.DOTALL | re.ASCII | re.VERBOSE,
|
120
100
|
) # pragma: no cov
|
121
101
|
|
122
102
|
|
123
103
|
class Message(BaseModel):
|
124
|
-
"""Prefix Message model for receive grouping dict from searching prefix data
|
125
|
-
|
104
|
+
"""Prefix Message model for receive grouping dict from searching prefix data.
|
105
|
+
|
106
|
+
This model handles prefix parsing and message formatting for logging
|
107
|
+
with emoji support and categorization.
|
126
108
|
"""
|
127
109
|
|
128
110
|
name: Optional[str] = Field(default=None, description="A prefix name.")
|
@@ -133,10 +115,10 @@ class Message(BaseModel):
|
|
133
115
|
"""Extract message prefix from an input message.
|
134
116
|
|
135
117
|
Args:
|
136
|
-
msg
|
118
|
+
msg: A message that want to extract.
|
137
119
|
|
138
120
|
Returns:
|
139
|
-
Message:
|
121
|
+
Message: The validated model from a string message.
|
140
122
|
"""
|
141
123
|
return Message.model_validate(
|
142
124
|
obj=PREFIX_LOGS_REGEX.search(msg).groupdict()
|
@@ -161,13 +143,16 @@ class Message(BaseModel):
|
|
161
143
|
return f"{emoji}[{name}]: {self.message}"
|
162
144
|
|
163
145
|
|
164
|
-
class
|
165
|
-
"""Trace Metadata model for making the current metadata of this CPU, Memory
|
166
|
-
|
146
|
+
class Metadata(BaseModel): # pragma: no cov
|
147
|
+
"""Trace Metadata model for making the current metadata of this CPU, Memory.
|
148
|
+
|
149
|
+
This model captures comprehensive execution context including process IDs,
|
150
|
+
thread identifiers, timestamps, and contextual information for debugging
|
151
|
+
and monitoring workflow executions.
|
167
152
|
"""
|
168
153
|
|
169
|
-
|
170
|
-
level:
|
154
|
+
error_flag: bool = Field(default=False, description="A meta error flag.")
|
155
|
+
level: Level = Field(description="A log level.")
|
171
156
|
datetime: str = Field(
|
172
157
|
description="A datetime string with the specific config format."
|
173
158
|
)
|
@@ -177,15 +162,98 @@ class TraceMeta(BaseModel): # pragma: no cov
|
|
177
162
|
cut_id: Optional[str] = Field(
|
178
163
|
default=None, description="A cutting of running ID."
|
179
164
|
)
|
165
|
+
run_id: str
|
166
|
+
parent_run_id: Optional[str] = None
|
180
167
|
filename: str = Field(description="A filename of this log.")
|
181
168
|
lineno: int = Field(description="A line number of this log.")
|
182
169
|
|
170
|
+
# Enhanced observability fields
|
171
|
+
workflow_name: Optional[str] = Field(
|
172
|
+
default=None, description="Name of the workflow being executed."
|
173
|
+
)
|
174
|
+
stage_name: Optional[str] = Field(
|
175
|
+
default=None, description="Name of the current stage being executed."
|
176
|
+
)
|
177
|
+
job_name: Optional[str] = Field(
|
178
|
+
default=None, description="Name of the current job being executed."
|
179
|
+
)
|
180
|
+
|
181
|
+
# Performance metrics
|
182
|
+
duration_ms: Optional[float] = Field(
|
183
|
+
default=None, description="Execution duration in milliseconds."
|
184
|
+
)
|
185
|
+
memory_usage_mb: Optional[float] = Field(
|
186
|
+
default=None, description="Memory usage in MB at log time."
|
187
|
+
)
|
188
|
+
cpu_usage_percent: Optional[float] = Field(
|
189
|
+
default=None, description="CPU usage percentage at log time."
|
190
|
+
)
|
191
|
+
|
192
|
+
# Distributed tracing support
|
193
|
+
trace_id: Optional[str] = Field(
|
194
|
+
default=None,
|
195
|
+
description="OpenTelemetry trace ID for distributed tracing.",
|
196
|
+
)
|
197
|
+
span_id: Optional[str] = Field(
|
198
|
+
default=None,
|
199
|
+
description="OpenTelemetry span ID for distributed tracing.",
|
200
|
+
)
|
201
|
+
parent_span_id: Optional[str] = Field(
|
202
|
+
default=None, description="Parent span ID for correlation."
|
203
|
+
)
|
204
|
+
|
205
|
+
# Error context
|
206
|
+
exception_type: Optional[str] = Field(
|
207
|
+
default=None, description="Exception class name if error occurred."
|
208
|
+
)
|
209
|
+
exception_message: Optional[str] = Field(
|
210
|
+
default=None, description="Exception message if error occurred."
|
211
|
+
)
|
212
|
+
stack_trace: Optional[str] = Field(
|
213
|
+
default=None, description="Full stack trace if error occurred."
|
214
|
+
)
|
215
|
+
error_code: Optional[str] = Field(
|
216
|
+
default=None, description="Custom error code for categorization."
|
217
|
+
)
|
218
|
+
|
219
|
+
# Business context
|
220
|
+
user_id: Optional[str] = Field(
|
221
|
+
default=None, description="User ID who triggered the workflow."
|
222
|
+
)
|
223
|
+
tenant_id: Optional[str] = Field(
|
224
|
+
default=None, description="Tenant ID for multi-tenant environments."
|
225
|
+
)
|
226
|
+
environment: Optional[str] = Field(
|
227
|
+
default=None, description="Environment (dev, staging, prod)."
|
228
|
+
)
|
229
|
+
|
230
|
+
# System context
|
231
|
+
hostname: Optional[str] = Field(
|
232
|
+
default=None, description="Hostname where workflow is running."
|
233
|
+
)
|
234
|
+
ip_address: Optional[str] = Field(
|
235
|
+
default=None, description="IP address of the execution host."
|
236
|
+
)
|
237
|
+
python_version: Optional[str] = Field(
|
238
|
+
default=None, description="Python version running the workflow."
|
239
|
+
)
|
240
|
+
package_version: Optional[str] = Field(
|
241
|
+
default=None, description="Workflow package version."
|
242
|
+
)
|
243
|
+
|
244
|
+
# Custom metadata
|
245
|
+
tags: Optional[list[str]] = Field(
|
246
|
+
default_factory=list, description="Custom tags for categorization."
|
247
|
+
)
|
248
|
+
metadata: Optional[DictData] = Field(
|
249
|
+
default_factory=dict, description="Additional custom metadata."
|
250
|
+
)
|
251
|
+
|
183
252
|
@classmethod
|
184
253
|
def dynamic_frame(
|
185
254
|
cls, frame: FrameType, *, extras: Optional[DictData] = None
|
186
255
|
) -> Traceback:
|
187
|
-
"""Dynamic Frame information base on the `logs_trace_frame_layer` config
|
188
|
-
value that was set from the extra parameter.
|
256
|
+
"""Dynamic Frame information base on the `logs_trace_frame_layer` config.
|
189
257
|
|
190
258
|
Args:
|
191
259
|
frame: The current frame that want to dynamic.
|
@@ -195,67 +263,136 @@ class TraceMeta(BaseModel): # pragma: no cov
|
|
195
263
|
Returns:
|
196
264
|
Traceback: The frame information at the specified layer.
|
197
265
|
"""
|
198
|
-
|
199
|
-
layer: int =
|
266
|
+
extras_data: DictData = extras or {}
|
267
|
+
layer: int = extras_data.get("logs_trace_frame_layer", 4)
|
268
|
+
current_frame: FrameType = frame
|
200
269
|
for _ in range(layer):
|
201
|
-
_frame: Optional[FrameType] =
|
270
|
+
_frame: Optional[FrameType] = current_frame.f_back
|
202
271
|
if _frame is None:
|
203
272
|
raise ValueError(
|
204
273
|
f"Layer value does not valid, the maximum frame is: {_ + 1}"
|
205
274
|
)
|
206
|
-
|
207
|
-
return getframeinfo(
|
275
|
+
current_frame = _frame
|
276
|
+
return getframeinfo(current_frame)
|
208
277
|
|
209
278
|
@classmethod
|
210
279
|
def make(
|
211
280
|
cls,
|
212
|
-
|
281
|
+
error_flag: bool,
|
213
282
|
message: str,
|
214
|
-
level:
|
283
|
+
level: Level,
|
215
284
|
cutting_id: str,
|
285
|
+
run_id: str,
|
286
|
+
parent_run_id: Optional[str],
|
216
287
|
*,
|
217
288
|
extras: Optional[DictData] = None,
|
218
289
|
) -> Self:
|
219
|
-
"""Make the current metric for contract this
|
220
|
-
|
290
|
+
"""Make the current metric for contract this Metadata model instance.
|
291
|
+
|
292
|
+
This method captures local states like PID, thread identity, and system
|
293
|
+
information to create a comprehensive trace metadata instance.
|
221
294
|
|
222
295
|
Args:
|
223
|
-
|
296
|
+
error_flag: A metadata mode.
|
224
297
|
message: A message.
|
225
298
|
level: A log level.
|
226
299
|
cutting_id: A cutting ID string.
|
300
|
+
run_id:
|
301
|
+
parent_run_id:
|
227
302
|
extras: An extra parameter that want to override core
|
228
303
|
config values.
|
229
304
|
|
230
305
|
Returns:
|
231
|
-
Self: The constructed
|
306
|
+
Self: The constructed Metadata instance.
|
232
307
|
"""
|
233
|
-
|
308
|
+
import socket
|
309
|
+
import sys
|
310
|
+
|
311
|
+
frame: Optional[FrameType] = currentframe()
|
312
|
+
if frame is None:
|
313
|
+
raise ValueError("Cannot get current frame")
|
314
|
+
|
234
315
|
frame_info: Traceback = cls.dynamic_frame(frame, extras=extras)
|
235
|
-
|
316
|
+
extras_data: DictData = extras or {}
|
317
|
+
|
318
|
+
# NOTE: Get system information
|
319
|
+
hostname = socket.gethostname()
|
320
|
+
ip_address = socket.gethostbyname(hostname)
|
321
|
+
python_version: str = (
|
322
|
+
f"{sys.version_info.major}"
|
323
|
+
f".{sys.version_info.minor}"
|
324
|
+
f".{sys.version_info.micro}"
|
325
|
+
)
|
326
|
+
|
327
|
+
# Get datetime format with fallback
|
328
|
+
datetime_format = (
|
329
|
+
dynamic("log_datetime_format", extras=extras_data)
|
330
|
+
or "%Y-%m-%d %H:%M:%S"
|
331
|
+
)
|
332
|
+
timezone = dynamic("log_tz", extras=extras_data)
|
333
|
+
if timezone is None:
|
334
|
+
timezone = ZoneInfo("UTC")
|
335
|
+
|
236
336
|
return cls(
|
237
|
-
|
337
|
+
error_flag=error_flag,
|
238
338
|
level=level,
|
239
339
|
datetime=(
|
240
|
-
get_dt_now()
|
241
|
-
.astimezone(dynamic("log_tz", extras=extras))
|
242
|
-
.strftime(dynamic("log_datetime_format", extras=extras))
|
340
|
+
get_dt_now().astimezone(timezone).strftime(datetime_format)
|
243
341
|
),
|
244
342
|
process=os.getpid(),
|
245
343
|
thread=get_ident(),
|
246
344
|
message=message,
|
247
345
|
cut_id=cutting_id,
|
346
|
+
run_id=run_id,
|
347
|
+
parent_run_id=parent_run_id,
|
248
348
|
filename=frame_info.filename.split(os.path.sep)[-1],
|
249
349
|
lineno=frame_info.lineno,
|
350
|
+
# NOTE: Enhanced observability fields
|
351
|
+
workflow_name=extras_data.get("workflow_name"),
|
352
|
+
stage_name=extras_data.get("stage_name"),
|
353
|
+
job_name=extras_data.get("job_name"),
|
354
|
+
# NOTE: Performance metrics
|
355
|
+
duration_ms=extras_data.get("duration_ms"),
|
356
|
+
memory_usage_mb=extras_data.get("memory_usage_mb"),
|
357
|
+
cpu_usage_percent=extras_data.get("cpu_usage_percent"),
|
358
|
+
# NOTE: Distributed tracing support
|
359
|
+
trace_id=extras_data.get("trace_id"),
|
360
|
+
span_id=extras_data.get("span_id"),
|
361
|
+
parent_span_id=extras_data.get("parent_span_id"),
|
362
|
+
# NOTE: Error context
|
363
|
+
exception_type=extras_data.get("exception_type"),
|
364
|
+
exception_message=extras_data.get("exception_message"),
|
365
|
+
stack_trace=extras_data.get("stack_trace"),
|
366
|
+
error_code=extras_data.get("error_code"),
|
367
|
+
# NOTE: Business context
|
368
|
+
user_id=extras_data.get("user_id"),
|
369
|
+
tenant_id=extras_data.get("tenant_id"),
|
370
|
+
environment=extras_data.get("environment"),
|
371
|
+
# NOTE: System context
|
372
|
+
hostname=hostname,
|
373
|
+
ip_address=ip_address,
|
374
|
+
python_version=python_version,
|
375
|
+
package_version=extras_data.get("package_version"),
|
376
|
+
# NOTE: Custom metadata
|
377
|
+
tags=extras_data.get("tags", []),
|
378
|
+
metadata=extras_data.get("metadata", {}),
|
250
379
|
)
|
251
380
|
|
381
|
+
@property
|
382
|
+
def pointer_id(self):
|
383
|
+
return self.parent_run_id or self.run_id
|
384
|
+
|
252
385
|
|
253
386
|
class TraceData(BaseModel): # pragma: no cov
|
254
|
-
"""Trace Data model for keeping data for any Trace models.
|
387
|
+
"""Trace Data model for keeping data for any Trace models.
|
388
|
+
|
389
|
+
This model serves as a container for trace information including stdout,
|
390
|
+
stderr, and metadata for comprehensive logging and monitoring.
|
391
|
+
"""
|
255
392
|
|
256
393
|
stdout: str = Field(description="A standard output trace data.")
|
257
394
|
stderr: str = Field(description="A standard error trace data.")
|
258
|
-
meta: list[
|
395
|
+
meta: list[Metadata] = Field(
|
259
396
|
default_factory=list,
|
260
397
|
description=(
|
261
398
|
"A metadata mapping of this output and error before making it to "
|
@@ -263,354 +400,559 @@ class TraceData(BaseModel): # pragma: no cov
|
|
263
400
|
),
|
264
401
|
)
|
265
402
|
|
266
|
-
@classmethod
|
267
|
-
def from_path(cls, file: Path) -> Self:
|
268
|
-
"""Construct this trace data model with a trace path.
|
269
|
-
|
270
|
-
:param file: (Path) A trace path.
|
271
|
-
|
272
|
-
:rtype: Self
|
273
|
-
"""
|
274
|
-
data: DictData = {"stdout": "", "stderr": "", "meta": []}
|
275
|
-
|
276
|
-
for mode in ("stdout", "stderr"):
|
277
|
-
if (file / f"{mode}.txt").exists():
|
278
|
-
data[mode] = (file / f"{mode}.txt").read_text(encoding="utf-8")
|
279
|
-
|
280
|
-
if (file / METADATA).exists():
|
281
|
-
data["meta"] = [
|
282
|
-
json.loads(line)
|
283
|
-
for line in (
|
284
|
-
(file / METADATA).read_text(encoding="utf-8").splitlines()
|
285
|
-
)
|
286
|
-
]
|
287
|
-
|
288
|
-
return cls.model_validate(data)
|
289
403
|
|
290
|
-
|
291
|
-
|
292
|
-
"""Base Trace model with abstraction class property."""
|
293
|
-
|
294
|
-
model_config = ConfigDict(frozen=True)
|
295
|
-
|
296
|
-
extras: DictData = Field(
|
297
|
-
default_factory=dict,
|
298
|
-
description=(
|
299
|
-
"An extra parameter that want to override on the core config "
|
300
|
-
"values."
|
301
|
-
),
|
302
|
-
)
|
303
|
-
run_id: str = Field(description="A running ID")
|
304
|
-
parent_run_id: Optional[str] = Field(
|
305
|
-
default=None,
|
306
|
-
description="A parent running ID",
|
307
|
-
)
|
404
|
+
class BaseHandler(BaseModel, ABC):
|
405
|
+
"""Base Handler model"""
|
308
406
|
|
309
407
|
@abstractmethod
|
310
|
-
def
|
408
|
+
def emit(
|
311
409
|
self,
|
312
|
-
|
313
|
-
|
314
|
-
|
315
|
-
)
|
316
|
-
"""Write a trace message after making to target pointer object. The
|
317
|
-
target can be anything be inherited this class and overwrite this method
|
318
|
-
such as file, console, or database.
|
319
|
-
|
320
|
-
:param message: (str) A message after making.
|
321
|
-
:param level: (str) A log level.
|
322
|
-
:param is_err: (bool) A flag for writing with an error trace or not.
|
323
|
-
(Default be False)
|
324
|
-
"""
|
325
|
-
raise NotImplementedError(
|
326
|
-
"Create writer logic for this trace object before using."
|
327
|
-
)
|
410
|
+
metadata: Metadata,
|
411
|
+
*,
|
412
|
+
extra: Optional[DictData] = None,
|
413
|
+
): ...
|
328
414
|
|
329
415
|
@abstractmethod
|
330
|
-
async def
|
416
|
+
async def amit(
|
331
417
|
self,
|
332
|
-
|
333
|
-
|
334
|
-
|
335
|
-
) -> None:
|
336
|
-
"""Async Write a trace message after making to target pointer object.
|
337
|
-
|
338
|
-
:param message: (str) A message after making.
|
339
|
-
:param level: (str) A log level.
|
340
|
-
:param is_err: (bool) A flag for writing with an error trace or not.
|
341
|
-
(Default be False)
|
342
|
-
"""
|
343
|
-
raise NotImplementedError(
|
344
|
-
"Create async writer logic for this trace object before using."
|
345
|
-
)
|
418
|
+
metadata: Metadata,
|
419
|
+
*,
|
420
|
+
extra: Optional[DictData] = None,
|
421
|
+
) -> None: ...
|
346
422
|
|
347
423
|
@abstractmethod
|
348
|
-
def
|
349
|
-
|
424
|
+
def flush(
|
425
|
+
self, metadata: list[Metadata], *, extra: Optional[DictData] = None
|
426
|
+
) -> None: ...
|
350
427
|
|
351
|
-
:param message: A message that want to prepare and make before.
|
352
428
|
|
353
|
-
|
354
|
-
|
355
|
-
raise NotImplementedError(
|
356
|
-
"Adjust make message method for this trace object before using."
|
357
|
-
)
|
429
|
+
class ConsoleHandler(BaseHandler):
|
430
|
+
"""Console Handler model."""
|
358
431
|
|
359
|
-
|
360
|
-
def emit(
|
361
|
-
self,
|
362
|
-
message: str,
|
363
|
-
mode: str,
|
364
|
-
*,
|
365
|
-
is_err: bool = False,
|
366
|
-
):
|
367
|
-
"""Write trace log with append mode and logging this message with any
|
368
|
-
logging level.
|
432
|
+
type: Literal["console"] = "console"
|
369
433
|
|
370
|
-
|
371
|
-
:
|
372
|
-
|
373
|
-
|
374
|
-
|
375
|
-
|
434
|
+
def emit(
|
435
|
+
self, metadata: Metadata, *, extra: Optional[DictData] = None
|
436
|
+
) -> None:
|
437
|
+
getattr(logger, metadata.level)(
|
438
|
+
metadata.message,
|
439
|
+
stacklevel=3,
|
440
|
+
extra=(extra or {}) | {"cut_id": metadata.cut_id},
|
376
441
|
)
|
377
442
|
|
378
|
-
def
|
379
|
-
|
380
|
-
|
443
|
+
async def amit(
|
444
|
+
self, metadata: Metadata, *, extra: Optional[DictData] = None
|
445
|
+
) -> None:
|
446
|
+
self.emit(metadata, extra=extra)
|
381
447
|
|
382
|
-
|
383
|
-
|
384
|
-
|
448
|
+
def flush(
|
449
|
+
self, metadata: list[Metadata], *, extra: Optional[DictData] = None
|
450
|
+
) -> None:
|
451
|
+
for meta in metadata:
|
452
|
+
self.emit(meta, extra=extra)
|
385
453
|
|
386
|
-
def info(self, message: str) -> None:
|
387
|
-
"""Write trace log with append mode and logging this message with the
|
388
|
-
INFO level.
|
389
454
|
|
390
|
-
|
391
|
-
|
392
|
-
self.emit(message, mode="info")
|
455
|
+
class FileHandler(BaseHandler):
|
456
|
+
"""File Handler model."""
|
393
457
|
|
394
|
-
|
395
|
-
"""Write trace log with append mode and logging this message with the
|
396
|
-
WARNING level.
|
458
|
+
metadata_filename: ClassVar[str] = "metadata.txt"
|
397
459
|
|
398
|
-
|
399
|
-
|
400
|
-
|
460
|
+
type: Literal["file"] = "file"
|
461
|
+
path: str = Field(description="A file path.")
|
462
|
+
format: str = Field(
|
463
|
+
default=(
|
464
|
+
"{datetime} ({process:5d}, {thread:5d}) ({cut_id}) {message:120s} "
|
465
|
+
"({filename}:{lineno})"
|
466
|
+
)
|
467
|
+
)
|
468
|
+
buffer_size: int = 8192
|
401
469
|
|
402
|
-
|
403
|
-
|
404
|
-
ERROR level.
|
470
|
+
# NOTE: Private attrs for the internal process.
|
471
|
+
_lock: Lock = PrivateAttr(default_factory=Lock)
|
405
472
|
|
406
|
-
|
407
|
-
"""
|
408
|
-
|
473
|
+
def pointer(self, run_id: str) -> Path:
|
474
|
+
"""Pointer of the target path that use to writing trace log or searching
|
475
|
+
trace log.
|
409
476
|
|
410
|
-
|
411
|
-
|
412
|
-
|
477
|
+
This running ID folder that use to keeping trace log data will use
|
478
|
+
a parent running ID first. If it does not set, it will use running ID
|
479
|
+
instead.
|
413
480
|
|
414
|
-
:
|
481
|
+
Returns:
|
482
|
+
Path: The target path for trace log operations.
|
415
483
|
"""
|
416
|
-
self.
|
484
|
+
log_file: Path = Path(self.path) / f"run_id={run_id}"
|
485
|
+
if not log_file.exists():
|
486
|
+
log_file.mkdir(parents=True)
|
487
|
+
return log_file
|
417
488
|
|
418
|
-
|
419
|
-
|
489
|
+
def pre(self) -> None: ...
|
490
|
+
|
491
|
+
def emit(
|
420
492
|
self,
|
421
|
-
|
422
|
-
mode: str,
|
493
|
+
metadata: Metadata,
|
423
494
|
*,
|
424
|
-
|
495
|
+
extra: Optional[DictData] = None,
|
425
496
|
) -> None:
|
426
|
-
|
427
|
-
|
428
|
-
|
429
|
-
|
430
|
-
|
431
|
-
|
432
|
-
|
433
|
-
|
434
|
-
|
435
|
-
|
497
|
+
pointer: Path = self.pointer(metadata.pointer_id)
|
498
|
+
std_file = "stderr" if metadata.error_flag else "stdout"
|
499
|
+
with self._lock:
|
500
|
+
with (pointer / f"{std_file}.txt").open(
|
501
|
+
mode="at", encoding="utf-8"
|
502
|
+
) as f:
|
503
|
+
f.write(f"{self.format}\n".format(**metadata.model_dump()))
|
504
|
+
|
505
|
+
with (pointer / self.metadata_filename).open(
|
506
|
+
mode="at", encoding="utf-8"
|
507
|
+
) as f:
|
508
|
+
f.write(metadata.model_dump_json() + "\n")
|
436
509
|
|
437
|
-
async def
|
438
|
-
|
439
|
-
|
510
|
+
async def amit(
|
511
|
+
self,
|
512
|
+
metadata: Metadata,
|
513
|
+
*,
|
514
|
+
extra: Optional[DictData] = None,
|
515
|
+
) -> None: # pragma: no cove
|
516
|
+
try:
|
517
|
+
import aiofiles
|
518
|
+
except ImportError as e:
|
519
|
+
raise ImportError("Async mode need aiofiles package") from e
|
440
520
|
|
441
|
-
|
442
|
-
|
443
|
-
|
521
|
+
with self._lock:
|
522
|
+
pointer: Path = self.pointer(metadata.pointer_id)
|
523
|
+
std_file = "stderr" if metadata.error_flag else "stdout"
|
524
|
+
async with aiofiles.open(
|
525
|
+
pointer / f"{std_file}.txt", mode="at", encoding="utf-8"
|
526
|
+
) as f:
|
527
|
+
await f.write(
|
528
|
+
f"{self.format}\n".format(**metadata.model_dump())
|
529
|
+
)
|
444
530
|
|
445
|
-
|
446
|
-
|
447
|
-
|
531
|
+
async with aiofiles.open(
|
532
|
+
pointer / self.metadata_filename, mode="at", encoding="utf-8"
|
533
|
+
) as f:
|
534
|
+
await f.write(metadata.model_dump_json() + "\n")
|
448
535
|
|
449
|
-
|
450
|
-
|
451
|
-
|
536
|
+
def flush(
|
537
|
+
self, metadata: list[Metadata], *, extra: Optional[DictData] = None
|
538
|
+
) -> None:
|
539
|
+
with self._lock:
|
540
|
+
pointer: Path = self.pointer(metadata[0].pointer_id)
|
541
|
+
stdout_file = open(
|
542
|
+
pointer / "stdout.txt",
|
543
|
+
mode="a",
|
544
|
+
encoding="utf-8",
|
545
|
+
buffering=self.buffer_size,
|
546
|
+
)
|
547
|
+
stderr_file = open(
|
548
|
+
pointer / "stderr.txt",
|
549
|
+
mode="a",
|
550
|
+
encoding="utf-8",
|
551
|
+
buffering=self.buffer_size,
|
552
|
+
)
|
553
|
+
metadata_file = open(
|
554
|
+
pointer / self.metadata_filename,
|
555
|
+
mode="a",
|
556
|
+
encoding="utf-8",
|
557
|
+
buffering=self.buffer_size,
|
558
|
+
)
|
452
559
|
|
453
|
-
|
454
|
-
|
455
|
-
|
560
|
+
for meta in metadata:
|
561
|
+
if meta.error_flag:
|
562
|
+
stderr_file.write(
|
563
|
+
f"{self.format}\n".format(**meta.model_dump())
|
564
|
+
)
|
565
|
+
else:
|
566
|
+
stdout_file.write(
|
567
|
+
f"{self.format}\n".format(**meta.model_dump())
|
568
|
+
)
|
569
|
+
|
570
|
+
metadata_file.write(meta.model_dump_json() + "\n")
|
571
|
+
|
572
|
+
stdout_file.flush()
|
573
|
+
stderr_file.flush()
|
574
|
+
metadata_file.flush()
|
575
|
+
stdout_file.close()
|
576
|
+
stderr_file.close()
|
577
|
+
metadata_file.close()
|
456
578
|
|
457
|
-
|
458
|
-
|
459
|
-
|
579
|
+
@classmethod
|
580
|
+
def from_path(cls, file: Path) -> TraceData: # pragma: no cov
|
581
|
+
"""Construct this trace data model with a trace path.
|
460
582
|
|
461
|
-
|
462
|
-
|
463
|
-
the ERROR level.
|
583
|
+
Args:
|
584
|
+
file: A trace path.
|
464
585
|
|
465
|
-
:
|
586
|
+
Returns:
|
587
|
+
Self: The constructed TraceData instance.
|
466
588
|
"""
|
467
|
-
|
468
|
-
|
469
|
-
async def aexception(self, message: str) -> None: # pragma: no cov
|
470
|
-
"""Async write trace log with append mode and logging this message with
|
471
|
-
the EXCEPTION level.
|
589
|
+
data: DictData = {"stdout": "", "stderr": "", "meta": []}
|
472
590
|
|
473
|
-
|
474
|
-
|
475
|
-
|
591
|
+
for mode in ("stdout", "stderr"):
|
592
|
+
if (file / f"{mode}.txt").exists():
|
593
|
+
data[mode] = (file / f"{mode}.txt").read_text(encoding="utf-8")
|
476
594
|
|
595
|
+
if (file / cls.metadata_filename).exists():
|
596
|
+
data["meta"] = [
|
597
|
+
json.loads(line)
|
598
|
+
for line in (
|
599
|
+
(file / cls.metadata_filename)
|
600
|
+
.read_text(encoding="utf-8")
|
601
|
+
.splitlines()
|
602
|
+
)
|
603
|
+
]
|
477
604
|
|
478
|
-
|
479
|
-
"""Console Trace log model."""
|
605
|
+
return TraceData.model_validate(data)
|
480
606
|
|
481
|
-
def
|
607
|
+
def find_traces(
|
482
608
|
self,
|
483
|
-
|
484
|
-
|
485
|
-
|
486
|
-
|
487
|
-
|
488
|
-
|
489
|
-
such as file, console, or database.
|
490
|
-
|
491
|
-
:param message: (str) A message after making.
|
492
|
-
:param level: (str) A log level.
|
493
|
-
:param is_err: (bool) A flag for writing with an error trace or not.
|
494
|
-
(Default be False)
|
609
|
+
path: Optional[Path] = None,
|
610
|
+
) -> Iterator[TraceData]: # pragma: no cov
|
611
|
+
"""Find trace logs.
|
612
|
+
|
613
|
+
Args:
|
614
|
+
path: A trace path that want to find.
|
495
615
|
"""
|
616
|
+
for file in sorted(
|
617
|
+
(path or Path(self.path)).glob("./run_id=*"),
|
618
|
+
key=lambda f: f.lstat().st_mtime,
|
619
|
+
):
|
620
|
+
yield self.from_path(file)
|
496
621
|
|
497
|
-
|
622
|
+
def find_trace_with_id(
|
498
623
|
self,
|
499
|
-
|
500
|
-
|
501
|
-
|
502
|
-
|
503
|
-
|
624
|
+
run_id: str,
|
625
|
+
*,
|
626
|
+
force_raise: bool = True,
|
627
|
+
path: Optional[Path] = None,
|
628
|
+
) -> TraceData: # pragma: no cov
|
629
|
+
"""Find trace log with an input specific run ID.
|
504
630
|
|
505
|
-
:
|
506
|
-
|
507
|
-
|
508
|
-
|
631
|
+
Args:
|
632
|
+
run_id: A running ID of trace log.
|
633
|
+
force_raise: Whether to raise an exception if not found.
|
634
|
+
path: Optional path override.
|
509
635
|
"""
|
636
|
+
base_path: Path = path or self.path
|
637
|
+
file: Path = base_path / f"run_id={run_id}"
|
638
|
+
if file.exists():
|
639
|
+
return self.from_path(file)
|
640
|
+
elif force_raise:
|
641
|
+
raise FileNotFoundError(
|
642
|
+
f"Trace log on path {base_path}, does not found trace "
|
643
|
+
f"'run_id={run_id}'."
|
644
|
+
)
|
645
|
+
return TraceData(stdout="", stderr="")
|
510
646
|
|
511
|
-
@property
|
512
|
-
def cut_id(self) -> str:
|
513
|
-
"""Combine cutting ID of parent running ID if it set.
|
514
647
|
|
515
|
-
|
516
|
-
|
517
|
-
cut_run_id: str = cut_id(self.run_id)
|
518
|
-
if not self.parent_run_id:
|
519
|
-
return f"{cut_run_id}"
|
648
|
+
class SQLiteHandler(BaseHandler): # pragma: no cov
|
649
|
+
"""High-performance SQLite logging handler for workflow traces.
|
520
650
|
|
521
|
-
|
522
|
-
|
651
|
+
This handler provides optimized SQLite-based logging with connection pooling,
|
652
|
+
thread safety, and structured metadata storage. It replaces the placeholder
|
653
|
+
SQLiteTrace implementation with a fully functional database-backed system.
|
654
|
+
"""
|
523
655
|
|
524
|
-
|
525
|
-
|
656
|
+
type: Literal["sqlite"] = "sqlite"
|
657
|
+
path: str
|
658
|
+
table_name: str = Field(default="traces")
|
526
659
|
|
527
|
-
|
660
|
+
# NOTE: Private attrs for the internal process.
|
661
|
+
_lock: Lock = PrivateAttr(default_factory=Lock)
|
528
662
|
|
529
|
-
|
530
|
-
|
531
|
-
return prepare_newline(Message.from_str(message).prepare(self.extras))
|
663
|
+
def pre(self) -> None:
|
664
|
+
import sqlite3
|
532
665
|
|
533
|
-
|
534
|
-
|
535
|
-
|
666
|
+
try:
|
667
|
+
with sqlite3.connect(self.path) as conn:
|
668
|
+
cursor = conn.cursor()
|
669
|
+
|
670
|
+
# Create traces table if it doesn't exist
|
671
|
+
cursor.execute(
|
672
|
+
f"""
|
673
|
+
CREATE TABLE IF NOT EXISTS {self.table_name} (
|
674
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
675
|
+
run_id TEXT NOT NULL,
|
676
|
+
parent_run_id TEXT,
|
677
|
+
level TEXT NOT NULL,
|
678
|
+
message TEXT NOT NULL,
|
679
|
+
error_flag BOOLEAN NOT NULL,
|
680
|
+
datetime TEXT NOT NULL,
|
681
|
+
process INTEGER NOT NULL,
|
682
|
+
thread INTEGER NOT NULL,
|
683
|
+
filename TEXT NOT NULL,
|
684
|
+
lineno INTEGER NOT NULL,
|
685
|
+
cut_id TEXT,
|
686
|
+
workflow_name TEXT,
|
687
|
+
stage_name TEXT,
|
688
|
+
job_name TEXT,
|
689
|
+
duration_ms REAL,
|
690
|
+
memory_usage_mb REAL,
|
691
|
+
cpu_usage_percent REAL,
|
692
|
+
trace_id TEXT,
|
693
|
+
span_id TEXT,
|
694
|
+
parent_span_id TEXT,
|
695
|
+
exception_type TEXT,
|
696
|
+
exception_message TEXT,
|
697
|
+
stack_trace TEXT,
|
698
|
+
error_code TEXT,
|
699
|
+
user_id TEXT,
|
700
|
+
tenant_id TEXT,
|
701
|
+
environment TEXT,
|
702
|
+
hostname TEXT,
|
703
|
+
ip_address TEXT,
|
704
|
+
python_version TEXT,
|
705
|
+
package_version TEXT,
|
706
|
+
tags TEXT,
|
707
|
+
metadata TEXT,
|
708
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
709
|
+
)
|
710
|
+
"""
|
711
|
+
)
|
536
712
|
|
537
|
-
|
538
|
-
|
539
|
-
|
540
|
-
|
541
|
-
|
713
|
+
# Create indexes for better performance
|
714
|
+
cursor.execute(
|
715
|
+
"""
|
716
|
+
CREATE INDEX IF NOT EXISTS idx_traces_run_id
|
717
|
+
ON traces(run_id)
|
718
|
+
"""
|
719
|
+
)
|
720
|
+
cursor.execute(
|
721
|
+
"""
|
722
|
+
CREATE INDEX IF NOT EXISTS idx_traces_parent_run_id
|
723
|
+
ON traces(parent_run_id)
|
724
|
+
"""
|
725
|
+
)
|
726
|
+
cursor.execute(
|
727
|
+
"""
|
728
|
+
CREATE INDEX IF NOT EXISTS idx_traces_datetime
|
729
|
+
ON traces(datetime)
|
730
|
+
"""
|
731
|
+
)
|
732
|
+
cursor.execute(
|
733
|
+
"""
|
734
|
+
CREATE INDEX IF NOT EXISTS idx_traces_level
|
735
|
+
ON traces(level)
|
736
|
+
"""
|
737
|
+
)
|
542
738
|
|
543
|
-
|
544
|
-
mode == "debug" and dynamic("debug", extras=self.extras)
|
545
|
-
):
|
546
|
-
self.writer(msg, level=mode, is_err=is_err)
|
739
|
+
conn.commit()
|
547
740
|
|
548
|
-
|
741
|
+
except Exception as e:
|
742
|
+
logger.error(f"Failed to initialize SQLite database: {e}")
|
743
|
+
raise
|
549
744
|
|
550
|
-
|
551
|
-
self,
|
745
|
+
def emit(
|
746
|
+
self,
|
747
|
+
metadata: Metadata,
|
748
|
+
*,
|
749
|
+
extra: Optional[DictData] = None,
|
552
750
|
) -> None:
|
553
|
-
|
554
|
-
logging level.
|
751
|
+
self.flush([metadata], extra=extra)
|
555
752
|
|
556
|
-
|
557
|
-
|
558
|
-
:
|
559
|
-
|
560
|
-
|
561
|
-
|
562
|
-
if mode != "debug" or (
|
563
|
-
mode == "debug" and dynamic("debug", extras=self.extras)
|
564
|
-
):
|
565
|
-
await self.awriter(msg, level=mode, is_err=is_err)
|
753
|
+
def amit(
|
754
|
+
self,
|
755
|
+
metadata: Metadata,
|
756
|
+
*,
|
757
|
+
extra: Optional[DictData] = None,
|
758
|
+
) -> None: ...
|
566
759
|
|
567
|
-
|
760
|
+
def flush(
|
761
|
+
self, metadata: list[Metadata], *, extra: Optional[DictData] = None
|
762
|
+
) -> None:
|
763
|
+
"""Flush all buffered records to database."""
|
764
|
+
if not self._buffer:
|
765
|
+
return
|
568
766
|
|
767
|
+
import sqlite3
|
768
|
+
|
769
|
+
with self._lock:
|
770
|
+
try:
|
771
|
+
with sqlite3.connect(self.path) as conn:
|
772
|
+
cursor = conn.cursor()
|
773
|
+
records = []
|
774
|
+
for meta in self._buffer:
|
775
|
+
records.append(
|
776
|
+
(
|
777
|
+
meta.run_id,
|
778
|
+
meta.parent_run_id,
|
779
|
+
meta.level,
|
780
|
+
meta.message,
|
781
|
+
meta.error_flag,
|
782
|
+
meta.datetime,
|
783
|
+
meta.process,
|
784
|
+
meta.thread,
|
785
|
+
meta.filename,
|
786
|
+
meta.lineno,
|
787
|
+
meta.cut_id,
|
788
|
+
meta.workflow_name,
|
789
|
+
meta.stage_name,
|
790
|
+
meta.job_name,
|
791
|
+
meta.duration_ms,
|
792
|
+
meta.memory_usage_mb,
|
793
|
+
meta.cpu_usage_percent,
|
794
|
+
meta.trace_id,
|
795
|
+
meta.span_id,
|
796
|
+
meta.parent_span_id,
|
797
|
+
meta.exception_type,
|
798
|
+
meta.exception_message,
|
799
|
+
meta.stack_trace,
|
800
|
+
meta.error_code,
|
801
|
+
meta.user_id,
|
802
|
+
meta.tenant_id,
|
803
|
+
meta.environment,
|
804
|
+
meta.hostname,
|
805
|
+
meta.ip_address,
|
806
|
+
meta.python_version,
|
807
|
+
meta.package_version,
|
808
|
+
(json.dumps(meta.tags) if meta.tags else None),
|
809
|
+
(
|
810
|
+
json.dumps(meta.metadata)
|
811
|
+
if meta.metadata
|
812
|
+
else None
|
813
|
+
),
|
814
|
+
)
|
815
|
+
)
|
816
|
+
|
817
|
+
# NOTE: Batch insert
|
818
|
+
cursor.executemany(
|
819
|
+
f"""
|
820
|
+
INSERT INTO {self.table_name} (
|
821
|
+
run_id, parent_run_id, level, message, error_flag, datetime,
|
822
|
+
process, thread, filename, lineno, cut_id, workflow_name,
|
823
|
+
stage_name, job_name, duration_ms, memory_usage_mb,
|
824
|
+
cpu_usage_percent, trace_id, span_id, parent_span_id,
|
825
|
+
exception_type, exception_message, stack_trace, error_code,
|
826
|
+
user_id, tenant_id, environment, hostname, ip_address,
|
827
|
+
python_version, package_version, tags, metadata
|
828
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
829
|
+
""",
|
830
|
+
records,
|
831
|
+
)
|
832
|
+
|
833
|
+
conn.commit()
|
834
|
+
|
835
|
+
except Exception as e:
|
836
|
+
logger.error(f"Failed to flush to SQLite database: {e}")
|
837
|
+
finally:
|
838
|
+
self._buffer.clear()
|
569
839
|
|
570
|
-
class BaseTrace(ConsoleTrace, ABC):
|
571
|
-
"""A Base Trace model that will use for override writing or sending trace
|
572
|
-
log to any service type.
|
573
|
-
"""
|
574
|
-
|
575
|
-
model_config = ConfigDict(arbitrary_types_allowed=True)
|
576
|
-
|
577
|
-
url: ParseResult = Field(description="An URL for create pointer.")
|
578
|
-
|
579
|
-
@field_validator(
|
580
|
-
"url", mode="before", json_schema_input_type=Union[ParseResult, str]
|
581
|
-
)
|
582
|
-
def __parse_url(cls, value: Union[ParseResult, str]) -> ParseResult:
|
583
|
-
"""Parsing an URL value."""
|
584
|
-
return urlparse(value) if isinstance(value, str) else value
|
585
|
-
|
586
|
-
@field_serializer("url")
|
587
|
-
def __serialize_url(self, value: ParseResult) -> str:
|
588
|
-
return value.geturl()
|
589
|
-
|
590
|
-
@classmethod
|
591
|
-
@abstractmethod
|
592
840
|
def find_traces(
|
593
|
-
|
841
|
+
self,
|
594
842
|
path: Optional[Path] = None,
|
595
843
|
extras: Optional[DictData] = None,
|
596
|
-
) -> Iterator[TraceData]:
|
597
|
-
"""
|
844
|
+
) -> Iterator[TraceData]:
|
845
|
+
"""Find trace logs from SQLite database."""
|
846
|
+
if path is None:
|
847
|
+
url = self.path
|
848
|
+
if (
|
849
|
+
url is not None
|
850
|
+
and hasattr(url, "path")
|
851
|
+
and getattr(url, "path", None)
|
852
|
+
):
|
853
|
+
path = Path(url.path)
|
854
|
+
else:
|
855
|
+
path = Path("./logs/workflow_traces.db")
|
856
|
+
|
857
|
+
if not path.exists():
|
858
|
+
return
|
598
859
|
|
599
|
-
|
600
|
-
path (:obj:`Path`, optional): A pointer path that want to override.
|
601
|
-
extras (:obj:`DictData`, optional): An extras parameter that want to
|
602
|
-
override default engine config.
|
860
|
+
import sqlite3
|
603
861
|
|
604
|
-
|
605
|
-
|
606
|
-
|
607
|
-
|
608
|
-
|
609
|
-
|
610
|
-
|
862
|
+
try:
|
863
|
+
with sqlite3.connect(path) as conn:
|
864
|
+
cursor = conn.cursor()
|
865
|
+
|
866
|
+
# NOTE: Get all unique run IDs
|
867
|
+
cursor.execute(
|
868
|
+
"""
|
869
|
+
SELECT DISTINCT run_id, parent_run_id, created_at
|
870
|
+
FROM traces
|
871
|
+
ORDER BY created_at DESC
|
872
|
+
"""
|
873
|
+
)
|
874
|
+
|
875
|
+
for run_id, _, _ in cursor.fetchall():
|
876
|
+
# NOTE: Get all records for this run
|
877
|
+
cursor.execute(
|
878
|
+
"""
|
879
|
+
SELECT * FROM traces
|
880
|
+
WHERE run_id = ?
|
881
|
+
ORDER BY created_at
|
882
|
+
""",
|
883
|
+
(run_id,),
|
884
|
+
)
|
885
|
+
|
886
|
+
records = cursor.fetchall()
|
887
|
+
|
888
|
+
# Convert to TraceData format
|
889
|
+
stdout_lines = []
|
890
|
+
stderr_lines = []
|
891
|
+
meta_list = []
|
892
|
+
|
893
|
+
for record in records:
|
894
|
+
trace_meta = Metadata(
|
895
|
+
run_id=record[1],
|
896
|
+
parent_run_id=record[2],
|
897
|
+
error_flag=record[5],
|
898
|
+
level=record[3],
|
899
|
+
message=record[4],
|
900
|
+
datetime=record[6],
|
901
|
+
process=record[7],
|
902
|
+
thread=record[8],
|
903
|
+
cut_id=record[11],
|
904
|
+
filename=record[9],
|
905
|
+
lineno=record[10],
|
906
|
+
workflow_name=record[12],
|
907
|
+
stage_name=record[13],
|
908
|
+
job_name=record[14],
|
909
|
+
duration_ms=record[15],
|
910
|
+
memory_usage_mb=record[16],
|
911
|
+
cpu_usage_percent=record[17],
|
912
|
+
trace_id=record[18],
|
913
|
+
span_id=record[19],
|
914
|
+
parent_span_id=record[20],
|
915
|
+
exception_type=record[21],
|
916
|
+
exception_message=record[22],
|
917
|
+
stack_trace=record[23],
|
918
|
+
error_code=record[24],
|
919
|
+
user_id=record[25],
|
920
|
+
tenant_id=record[26],
|
921
|
+
environment=record[27],
|
922
|
+
hostname=record[28],
|
923
|
+
ip_address=record[29],
|
924
|
+
python_version=record[30],
|
925
|
+
package_version=record[31],
|
926
|
+
tags=json.loads(record[32]) if record[32] else [],
|
927
|
+
metadata=(
|
928
|
+
json.loads(record[33]) if record[33] else {}
|
929
|
+
),
|
930
|
+
)
|
931
|
+
|
932
|
+
meta_list.append(trace_meta)
|
933
|
+
|
934
|
+
# Add to stdout/stderr based on mode
|
935
|
+
fmt = (
|
936
|
+
dynamic("log_format_file", extras=extras)
|
937
|
+
or "{datetime} ({process:5d}, {thread:5d}) ({cut_id}) {message:120s} ({filename}:{lineno})"
|
938
|
+
)
|
939
|
+
formatted_line = fmt.format(**trace_meta.model_dump())
|
940
|
+
|
941
|
+
if trace_meta.error_flag:
|
942
|
+
stderr_lines.append(formatted_line)
|
943
|
+
else:
|
944
|
+
stdout_lines.append(formatted_line)
|
945
|
+
|
946
|
+
yield TraceData(
|
947
|
+
stdout="\n".join(stdout_lines),
|
948
|
+
stderr="\n".join(stderr_lines),
|
949
|
+
meta=meta_list,
|
950
|
+
)
|
951
|
+
|
952
|
+
except Exception as e:
|
953
|
+
logger.error(f"Failed to read from SQLite database: {e}")
|
611
954
|
|
612
955
|
@classmethod
|
613
|
-
@abstractmethod
|
614
956
|
def find_trace_with_id(
|
615
957
|
cls,
|
616
958
|
run_id: str,
|
@@ -619,180 +961,659 @@ class BaseTrace(ConsoleTrace, ABC):
|
|
619
961
|
path: Optional[Path] = None,
|
620
962
|
extras: Optional[DictData] = None,
|
621
963
|
) -> TraceData:
|
622
|
-
|
623
|
-
|
624
|
-
"
|
625
|
-
|
964
|
+
"""Find trace log with specific run ID from SQLite database."""
|
965
|
+
if path is None:
|
966
|
+
url = dynamic("trace_url", extras=extras)
|
967
|
+
if (
|
968
|
+
url is not None
|
969
|
+
and hasattr(url, "path")
|
970
|
+
and getattr(url, "path", None)
|
971
|
+
):
|
972
|
+
path = Path(url.path)
|
973
|
+
else:
|
974
|
+
path = Path("./logs/workflow_traces.db")
|
975
|
+
|
976
|
+
if not path.exists():
|
977
|
+
if force_raise:
|
978
|
+
raise FileNotFoundError(f"SQLite database not found: {path}")
|
979
|
+
return TraceData(stdout="", stderr="")
|
980
|
+
|
981
|
+
import sqlite3
|
626
982
|
|
983
|
+
try:
|
984
|
+
with sqlite3.connect(path) as conn:
|
985
|
+
cursor = conn.cursor()
|
986
|
+
|
987
|
+
# Get all records for this run ID
|
988
|
+
cursor.execute(
|
989
|
+
"""
|
990
|
+
SELECT * FROM traces
|
991
|
+
WHERE run_id = ?
|
992
|
+
ORDER BY created_at
|
993
|
+
""",
|
994
|
+
(run_id,),
|
995
|
+
)
|
627
996
|
|
628
|
-
|
629
|
-
|
997
|
+
records = cursor.fetchall()
|
998
|
+
|
999
|
+
if not records:
|
1000
|
+
if force_raise:
|
1001
|
+
raise FileNotFoundError(
|
1002
|
+
f"Trace log with run_id '{run_id}' not found in database"
|
1003
|
+
)
|
1004
|
+
return TraceData(stdout="", stderr="")
|
1005
|
+
|
1006
|
+
# Convert to TraceData format
|
1007
|
+
stdout_lines = []
|
1008
|
+
stderr_lines = []
|
1009
|
+
meta_list = []
|
1010
|
+
|
1011
|
+
for record in records:
|
1012
|
+
trace_meta = Metadata(
|
1013
|
+
run_id=record[1],
|
1014
|
+
parent_run_id=record[2],
|
1015
|
+
error_flag=record[5],
|
1016
|
+
level=record[3],
|
1017
|
+
datetime=record[6],
|
1018
|
+
process=record[7],
|
1019
|
+
thread=record[8],
|
1020
|
+
message=record[4],
|
1021
|
+
cut_id=record[11],
|
1022
|
+
filename=record[9],
|
1023
|
+
lineno=record[10],
|
1024
|
+
workflow_name=record[12],
|
1025
|
+
stage_name=record[13],
|
1026
|
+
job_name=record[14],
|
1027
|
+
duration_ms=record[15],
|
1028
|
+
memory_usage_mb=record[16],
|
1029
|
+
cpu_usage_percent=record[17],
|
1030
|
+
trace_id=record[18],
|
1031
|
+
span_id=record[19],
|
1032
|
+
parent_span_id=record[20],
|
1033
|
+
exception_type=record[21],
|
1034
|
+
exception_message=record[22],
|
1035
|
+
stack_trace=record[23],
|
1036
|
+
error_code=record[24],
|
1037
|
+
user_id=record[25],
|
1038
|
+
tenant_id=record[26],
|
1039
|
+
environment=record[27],
|
1040
|
+
hostname=record[28],
|
1041
|
+
ip_address=record[29],
|
1042
|
+
python_version=record[30],
|
1043
|
+
package_version=record[31],
|
1044
|
+
tags=json.loads(record[32]) if record[32] else [],
|
1045
|
+
metadata=json.loads(record[33]) if record[33] else {},
|
1046
|
+
)
|
1047
|
+
|
1048
|
+
meta_list.append(trace_meta)
|
1049
|
+
|
1050
|
+
# Add to stdout/stderr based on mode
|
1051
|
+
fmt = (
|
1052
|
+
dynamic("log_format_file", extras=extras)
|
1053
|
+
or "{datetime} ({process:5d}, {thread:5d}) ({cut_id}) {message:120s} ({filename}:{lineno})"
|
1054
|
+
)
|
1055
|
+
formatted_line = fmt.format(**trace_meta.model_dump())
|
1056
|
+
|
1057
|
+
if trace_meta.error_flag:
|
1058
|
+
stderr_lines.append(formatted_line)
|
1059
|
+
else:
|
1060
|
+
stdout_lines.append(formatted_line)
|
1061
|
+
|
1062
|
+
return TraceData(
|
1063
|
+
stdout="\n".join(stdout_lines),
|
1064
|
+
stderr="\n".join(stderr_lines),
|
1065
|
+
meta=meta_list,
|
1066
|
+
)
|
630
1067
|
|
631
|
-
|
632
|
-
|
633
|
-
|
634
|
-
|
635
|
-
|
636
|
-
) -> Iterator[TraceData]: # pragma: no cov
|
637
|
-
"""Find trace logs.
|
1068
|
+
except Exception as e:
|
1069
|
+
logger.error(f"Failed to read from SQLite database: {e}")
|
1070
|
+
if force_raise:
|
1071
|
+
raise
|
1072
|
+
return TraceData(stdout="", stderr="")
|
638
1073
|
|
639
|
-
:param path: (Path) A trace path that want to find.
|
640
|
-
:param extras: An extra parameter that want to override core config.
|
641
|
-
"""
|
642
|
-
for file in sorted(
|
643
|
-
(path or Path(dynamic("trace_url", extras=extras).path)).glob(
|
644
|
-
"./run_id=*"
|
645
|
-
),
|
646
|
-
key=lambda f: f.lstat().st_mtime,
|
647
|
-
):
|
648
|
-
yield TraceData.from_path(file)
|
649
1074
|
|
650
|
-
|
651
|
-
|
652
|
-
|
653
|
-
|
654
|
-
|
655
|
-
|
656
|
-
|
657
|
-
|
658
|
-
|
659
|
-
|
1075
|
+
class RestAPIHandler(BaseHandler): # pragma: no cov
|
1076
|
+
type: Literal["restapi"] = "restapi"
|
1077
|
+
service_type: Literal["datadog", "grafana", "cloudwatch", "generic"] = (
|
1078
|
+
"generic"
|
1079
|
+
)
|
1080
|
+
api_url: str = ""
|
1081
|
+
api_key: Optional[str] = None
|
1082
|
+
timeout: float = 10.0
|
1083
|
+
max_retries: int = 3
|
1084
|
+
|
1085
|
+
def _format_for_service(self, meta: Metadata) -> dict:
|
1086
|
+
"""Format trace metadata for specific service."""
|
1087
|
+
base_data = meta.model_dump()
|
1088
|
+
|
1089
|
+
if self.service_type == "datadog":
|
1090
|
+
return {
|
1091
|
+
"message": base_data["message"],
|
1092
|
+
"level": base_data["level"],
|
1093
|
+
"timestamp": base_data["datetime"],
|
1094
|
+
"service": "ddeutil-workflow",
|
1095
|
+
"source": "python",
|
1096
|
+
"tags": [
|
1097
|
+
f"run_id:{meta.run_id}",
|
1098
|
+
(
|
1099
|
+
f"parent_run_id:{meta.parent_run_id}"
|
1100
|
+
if meta.parent_run_id
|
1101
|
+
else None
|
1102
|
+
),
|
1103
|
+
f"mode:{base_data['mode']}",
|
1104
|
+
f"filename:{base_data['filename']}",
|
1105
|
+
f"lineno:{base_data['lineno']}",
|
1106
|
+
f"process:{base_data['process']}",
|
1107
|
+
f"thread:{base_data['thread']}",
|
1108
|
+
]
|
1109
|
+
+ (base_data.get("tags", []) or []),
|
1110
|
+
"dd": {
|
1111
|
+
"source": "python",
|
1112
|
+
"service": "ddeutil-workflow",
|
1113
|
+
"tags": base_data.get("tags", []) or [],
|
1114
|
+
},
|
1115
|
+
"workflow": {
|
1116
|
+
"run_id": meta.run_id,
|
1117
|
+
"parent_run_id": meta.parent_run_id,
|
1118
|
+
"workflow_name": base_data.get("workflow_name"),
|
1119
|
+
"stage_name": base_data.get("stage_name"),
|
1120
|
+
"job_name": base_data.get("job_name"),
|
1121
|
+
},
|
1122
|
+
"trace": {
|
1123
|
+
"trace_id": base_data.get("trace_id"),
|
1124
|
+
"span_id": base_data.get("span_id"),
|
1125
|
+
"parent_span_id": base_data.get("parent_span_id"),
|
1126
|
+
},
|
1127
|
+
}
|
1128
|
+
|
1129
|
+
elif self.service_type == "grafana":
|
1130
|
+
return {
|
1131
|
+
"streams": [
|
1132
|
+
{
|
1133
|
+
"stream": {
|
1134
|
+
"run_id": meta.run_id,
|
1135
|
+
"parent_run_id": meta.parent_run_id,
|
1136
|
+
"level": base_data["level"],
|
1137
|
+
"mode": base_data["mode"],
|
1138
|
+
"service": "ddeutil-workflow",
|
1139
|
+
},
|
1140
|
+
"values": [
|
1141
|
+
[
|
1142
|
+
str(
|
1143
|
+
int(
|
1144
|
+
meta.datetime.replace(" ", "T").replace(
|
1145
|
+
":", ""
|
1146
|
+
)
|
1147
|
+
)
|
1148
|
+
),
|
1149
|
+
base_data["message"],
|
1150
|
+
]
|
1151
|
+
],
|
1152
|
+
}
|
1153
|
+
]
|
1154
|
+
}
|
1155
|
+
|
1156
|
+
elif self.service_type == "cloudwatch":
|
1157
|
+
return {
|
1158
|
+
"logGroupName": f"/ddeutil/workflow/{meta.run_id}",
|
1159
|
+
"logStreamName": f"workflow-{meta.run_id}",
|
1160
|
+
"logEvents": [
|
1161
|
+
{
|
1162
|
+
"timestamp": int(
|
1163
|
+
meta.datetime.replace(" ", "T").replace(":", "")
|
1164
|
+
),
|
1165
|
+
"message": json.dumps(
|
1166
|
+
{
|
1167
|
+
"message": base_data["message"],
|
1168
|
+
"level": base_data["level"],
|
1169
|
+
"run_id": meta.run_id,
|
1170
|
+
"parent_run_id": meta.parent_run_id,
|
1171
|
+
"mode": base_data["mode"],
|
1172
|
+
"filename": base_data["filename"],
|
1173
|
+
"lineno": base_data["lineno"],
|
1174
|
+
"process": base_data["process"],
|
1175
|
+
"thread": base_data["thread"],
|
1176
|
+
"workflow_name": base_data.get("workflow_name"),
|
1177
|
+
"stage_name": base_data.get("stage_name"),
|
1178
|
+
"job_name": base_data.get("job_name"),
|
1179
|
+
"trace_id": base_data.get("trace_id"),
|
1180
|
+
"span_id": base_data.get("span_id"),
|
1181
|
+
}
|
1182
|
+
),
|
1183
|
+
}
|
1184
|
+
],
|
1185
|
+
}
|
1186
|
+
|
1187
|
+
else:
|
1188
|
+
return {
|
1189
|
+
"timestamp": base_data["datetime"],
|
1190
|
+
"level": base_data["level"],
|
1191
|
+
"message": base_data["message"],
|
1192
|
+
"run_id": meta.run_id,
|
1193
|
+
"parent_run_id": meta.parent_run_id,
|
1194
|
+
"mode": base_data["mode"],
|
1195
|
+
"filename": base_data["filename"],
|
1196
|
+
"lineno": base_data["lineno"],
|
1197
|
+
"process": base_data["process"],
|
1198
|
+
"thread": base_data["thread"],
|
1199
|
+
"workflow_name": base_data.get("workflow_name"),
|
1200
|
+
"stage_name": base_data.get("stage_name"),
|
1201
|
+
"job_name": base_data.get("job_name"),
|
1202
|
+
"trace_id": base_data.get("trace_id"),
|
1203
|
+
"span_id": base_data.get("span_id"),
|
1204
|
+
"tags": base_data.get("tags", []),
|
1205
|
+
"metadata": base_data.get("metadata", {}),
|
1206
|
+
}
|
1207
|
+
|
1208
|
+
def session(self):
|
1209
|
+
try:
|
1210
|
+
import requests
|
1211
|
+
|
1212
|
+
session = requests.Session()
|
1213
|
+
|
1214
|
+
# NOTE: Set default headers
|
1215
|
+
headers: dict[str, Any] = {
|
1216
|
+
"Content-Type": "application/json",
|
1217
|
+
"User-Agent": "ddeutil-workflow/1.0",
|
1218
|
+
}
|
1219
|
+
|
1220
|
+
# NOTE: Add service-specific headers
|
1221
|
+
if self.service_type == "datadog":
|
1222
|
+
if self.api_key:
|
1223
|
+
headers["DD-API-KEY"] = self.api_key
|
1224
|
+
headers["Content-Type"] = "application/json"
|
1225
|
+
elif self.service_type == "grafana":
|
1226
|
+
if self.api_key:
|
1227
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
1228
|
+
elif self.service_type == "cloudwatch":
|
1229
|
+
if self.api_key:
|
1230
|
+
headers["X-Amz-Target"] = "Logs_20140328.PutLogEvents"
|
1231
|
+
headers["Authorization"] = (
|
1232
|
+
f"AWS4-HMAC-SHA256 {self.api_key}"
|
1233
|
+
)
|
1234
|
+
|
1235
|
+
session.headers.update(headers)
|
1236
|
+
return session
|
1237
|
+
except ImportError as e:
|
1238
|
+
raise ImportError(
|
1239
|
+
"REST API handler requires 'requests' package"
|
1240
|
+
) from e
|
660
1241
|
|
661
|
-
|
662
|
-
|
663
|
-
:
|
664
|
-
|
665
|
-
|
666
|
-
|
667
|
-
file: Path = base_path / f"run_id={run_id}"
|
668
|
-
if file.exists():
|
669
|
-
return TraceData.from_path(file)
|
670
|
-
elif force_raise:
|
671
|
-
raise FileNotFoundError(
|
672
|
-
f"Trace log on path {base_path}, does not found trace "
|
673
|
-
f"'run_id={run_id}'."
|
674
|
-
)
|
675
|
-
return TraceData(stdout="", stderr="")
|
1242
|
+
def emit(
|
1243
|
+
self,
|
1244
|
+
metadata: Metadata,
|
1245
|
+
*,
|
1246
|
+
extra: Optional[DictData] = None,
|
1247
|
+
): ...
|
676
1248
|
|
677
|
-
|
678
|
-
|
679
|
-
|
680
|
-
|
1249
|
+
async def amit(
|
1250
|
+
self,
|
1251
|
+
metadata: Metadata,
|
1252
|
+
*,
|
1253
|
+
extra: Optional[DictData] = None,
|
1254
|
+
) -> None: ...
|
681
1255
|
|
682
|
-
|
683
|
-
|
684
|
-
|
1256
|
+
def flush(
|
1257
|
+
self, metadata: list[Metadata], *, extra: Optional[DictData] = None
|
1258
|
+
) -> None:
|
1259
|
+
session = self.session()
|
1260
|
+
try:
|
1261
|
+
formatted_records = [
|
1262
|
+
self._format_for_service(meta) for meta in metadata
|
1263
|
+
]
|
685
1264
|
|
686
|
-
|
687
|
-
|
688
|
-
|
689
|
-
|
690
|
-
|
691
|
-
|
692
|
-
|
693
|
-
|
694
|
-
|
1265
|
+
# NOTE: Prepare payload based on service type
|
1266
|
+
if self.service_type == "datadog":
|
1267
|
+
payload = formatted_records
|
1268
|
+
elif self.service_type == "grafana":
|
1269
|
+
# Merge all streams
|
1270
|
+
all_streams = []
|
1271
|
+
for record in formatted_records:
|
1272
|
+
all_streams.extend(record["streams"])
|
1273
|
+
payload = {"streams": all_streams}
|
1274
|
+
elif self.service_type == "cloudwatch":
|
1275
|
+
# CloudWatch expects individual log events
|
1276
|
+
payload = formatted_records[0] # Take first record
|
1277
|
+
else:
|
1278
|
+
payload = formatted_records
|
1279
|
+
|
1280
|
+
# Send with retry logic
|
1281
|
+
for attempt in range(self.max_retries):
|
1282
|
+
try:
|
1283
|
+
response = session.post(
|
1284
|
+
self.api_url, json=payload, timeout=self.timeout
|
1285
|
+
)
|
1286
|
+
response.raise_for_status()
|
1287
|
+
except Exception as e:
|
1288
|
+
if attempt == self.max_retries - 1:
|
1289
|
+
logger.error(
|
1290
|
+
f"Failed to send logs to REST API after {self.max_retries} attempts: {e}"
|
1291
|
+
)
|
1292
|
+
else:
|
1293
|
+
import time
|
1294
|
+
|
1295
|
+
time.sleep(2**attempt) # Exponential backoff
|
1296
|
+
|
1297
|
+
except Exception as e:
|
1298
|
+
logger.error(f"Failed to send logs to REST API: {e}")
|
1299
|
+
finally:
|
1300
|
+
session.close()
|
1301
|
+
|
1302
|
+
|
1303
|
+
class ElasticHandler(BaseHandler): # pragma: no cov
|
1304
|
+
"""High-performance Elasticsearch logging handler for workflow traces.
|
1305
|
+
|
1306
|
+
This handler provides optimized Elasticsearch-based logging with connection
|
1307
|
+
pooling, bulk indexing, and structured metadata storage for scalable
|
1308
|
+
log aggregation and search capabilities.
|
1309
|
+
"""
|
695
1310
|
|
696
|
-
|
697
|
-
|
698
|
-
|
699
|
-
|
700
|
-
|
701
|
-
|
702
|
-
|
703
|
-
in the same path of standard files.
|
1311
|
+
type: Literal["elastic"] = "elastic"
|
1312
|
+
hosts: Union[str, list[str]]
|
1313
|
+
username: Optional[str] = None
|
1314
|
+
password: Optional[str] = None
|
1315
|
+
index: str
|
1316
|
+
timeout: float = 30.0
|
1317
|
+
max_retries: int = 3
|
704
1318
|
|
705
|
-
|
1319
|
+
@field_validator(
|
1320
|
+
"hosts", mode="before", json_schema_input_type=Union[str, list[str]]
|
1321
|
+
)
|
1322
|
+
def __prepare_hosts(cls, data: Any) -> Any:
|
1323
|
+
if isinstance(data, str):
|
1324
|
+
return [data]
|
1325
|
+
return data
|
706
1326
|
|
707
|
-
|
708
|
-
|
709
|
-
|
1327
|
+
def client(self):
|
1328
|
+
"""Initialize Elasticsearch client."""
|
1329
|
+
try:
|
1330
|
+
from elasticsearch import Elasticsearch
|
1331
|
+
|
1332
|
+
client = Elasticsearch(
|
1333
|
+
hosts=self.hosts,
|
1334
|
+
basic_auth=(
|
1335
|
+
(self.username, self.password)
|
1336
|
+
if self.username and self.password
|
1337
|
+
else None
|
1338
|
+
),
|
1339
|
+
timeout=self.timeout,
|
1340
|
+
max_retries=self.max_retries,
|
1341
|
+
retry_on_timeout=True,
|
1342
|
+
)
|
710
1343
|
|
711
|
-
|
712
|
-
|
713
|
-
|
714
|
-
"""
|
715
|
-
if not dynamic("enable_write_log", extras=self.extras):
|
716
|
-
return
|
1344
|
+
# Test connection
|
1345
|
+
if not client.ping():
|
1346
|
+
raise ConnectionError("Failed to connect to Elasticsearch")
|
717
1347
|
|
718
|
-
|
719
|
-
|
720
|
-
|
721
|
-
|
722
|
-
|
723
|
-
|
724
|
-
|
725
|
-
)
|
1348
|
+
# NOTE: Create index if it doesn't exist
|
1349
|
+
self._create_index(client)
|
1350
|
+
return client
|
1351
|
+
except ImportError as e:
|
1352
|
+
raise ImportError(
|
1353
|
+
"Elasticsearch handler requires 'elasticsearch' package"
|
1354
|
+
) from e
|
726
1355
|
|
727
|
-
|
728
|
-
|
729
|
-
|
730
|
-
|
731
|
-
|
1356
|
+
def _create_index(self, client):
|
1357
|
+
try:
|
1358
|
+
if not client.indices.exists(index=self.index):
|
1359
|
+
mapping = {
|
1360
|
+
"mappings": {
|
1361
|
+
"properties": {
|
1362
|
+
"run_id": {"type": "keyword"},
|
1363
|
+
"parent_run_id": {"type": "keyword"},
|
1364
|
+
"level": {"type": "keyword"},
|
1365
|
+
"message": {"type": "text"},
|
1366
|
+
"mode": {"type": "keyword"},
|
1367
|
+
"datetime": {"type": "date"},
|
1368
|
+
"process": {"type": "integer"},
|
1369
|
+
"thread": {"type": "integer"},
|
1370
|
+
"filename": {"type": "keyword"},
|
1371
|
+
"lineno": {"type": "integer"},
|
1372
|
+
"cut_id": {"type": "keyword"},
|
1373
|
+
"workflow_name": {"type": "keyword"},
|
1374
|
+
"stage_name": {"type": "keyword"},
|
1375
|
+
"job_name": {"type": "keyword"},
|
1376
|
+
"duration_ms": {"type": "float"},
|
1377
|
+
"memory_usage_mb": {"type": "float"},
|
1378
|
+
"cpu_usage_percent": {"type": "float"},
|
1379
|
+
"trace_id": {"type": "keyword"},
|
1380
|
+
"span_id": {"type": "keyword"},
|
1381
|
+
"parent_span_id": {"type": "keyword"},
|
1382
|
+
"exception_type": {"type": "keyword"},
|
1383
|
+
"exception_message": {"type": "text"},
|
1384
|
+
"stack_trace": {"type": "text"},
|
1385
|
+
"error_code": {"type": "keyword"},
|
1386
|
+
"user_id": {"type": "keyword"},
|
1387
|
+
"tenant_id": {"type": "keyword"},
|
1388
|
+
"environment": {"type": "keyword"},
|
1389
|
+
"hostname": {"type": "keyword"},
|
1390
|
+
"ip_address": {"type": "ip"},
|
1391
|
+
"python_version": {"type": "keyword"},
|
1392
|
+
"package_version": {"type": "keyword"},
|
1393
|
+
"tags": {"type": "keyword"},
|
1394
|
+
"metadata": {"type": "object"},
|
1395
|
+
"created_at": {"type": "date"},
|
1396
|
+
}
|
1397
|
+
},
|
1398
|
+
"settings": {
|
1399
|
+
"number_of_shards": 1,
|
1400
|
+
"number_of_replicas": 0,
|
1401
|
+
"refresh_interval": "1s",
|
1402
|
+
},
|
1403
|
+
}
|
1404
|
+
|
1405
|
+
client.indices.create(index=self.index, body=mapping)
|
1406
|
+
|
1407
|
+
except Exception as e:
|
1408
|
+
logger.error(f"Failed to create Elasticsearch index: {e}")
|
1409
|
+
|
1410
|
+
@staticmethod
|
1411
|
+
def _format_for_elastic(metadata: Metadata) -> dict:
|
1412
|
+
"""Format trace metadata for Elasticsearch indexing."""
|
1413
|
+
base_data = metadata.model_dump()
|
1414
|
+
try:
|
1415
|
+
dt = datetime.strptime(base_data["datetime"], "%Y-%m-%d %H:%M:%S")
|
1416
|
+
iso_datetime = dt.isoformat()
|
1417
|
+
except ValueError:
|
1418
|
+
iso_datetime = base_data["datetime"]
|
1419
|
+
|
1420
|
+
return {
|
1421
|
+
"run_id": base_data["run_id"],
|
1422
|
+
"parent_run_id": base_data["parent_run_id"],
|
1423
|
+
"level": base_data["level"],
|
1424
|
+
"message": base_data["message"],
|
1425
|
+
"mode": base_data["mode"],
|
1426
|
+
"datetime": iso_datetime,
|
1427
|
+
"process": base_data["process"],
|
1428
|
+
"thread": base_data["thread"],
|
1429
|
+
"filename": base_data["filename"],
|
1430
|
+
"lineno": base_data["lineno"],
|
1431
|
+
"cut_id": base_data["cut_id"],
|
1432
|
+
"workflow_name": base_data.get("workflow_name"),
|
1433
|
+
"stage_name": base_data.get("stage_name"),
|
1434
|
+
"job_name": base_data.get("job_name"),
|
1435
|
+
"duration_ms": base_data.get("duration_ms"),
|
1436
|
+
"memory_usage_mb": base_data.get("memory_usage_mb"),
|
1437
|
+
"cpu_usage_percent": base_data.get("cpu_usage_percent"),
|
1438
|
+
"trace_id": base_data.get("trace_id"),
|
1439
|
+
"span_id": base_data.get("span_id"),
|
1440
|
+
"parent_span_id": base_data.get("parent_span_id"),
|
1441
|
+
"exception_type": base_data.get("exception_type"),
|
1442
|
+
"exception_message": base_data.get("exception_message"),
|
1443
|
+
"stack_trace": base_data.get("stack_trace"),
|
1444
|
+
"error_code": base_data.get("error_code"),
|
1445
|
+
"user_id": base_data.get("user_id"),
|
1446
|
+
"tenant_id": base_data.get("tenant_id"),
|
1447
|
+
"environment": base_data.get("environment"),
|
1448
|
+
"hostname": base_data.get("hostname"),
|
1449
|
+
"ip_address": base_data.get("ip_address"),
|
1450
|
+
"python_version": base_data.get("python_version"),
|
1451
|
+
"package_version": base_data.get("package_version"),
|
1452
|
+
"tags": base_data.get("tags", []),
|
1453
|
+
"metadata": base_data.get("metadata", {}),
|
1454
|
+
"created_at": iso_datetime,
|
1455
|
+
}
|
732
1456
|
|
733
|
-
|
734
|
-
|
1457
|
+
def emit(
|
1458
|
+
self,
|
1459
|
+
metadata: Metadata,
|
1460
|
+
*,
|
1461
|
+
extra: Optional[DictData] = None,
|
1462
|
+
): ...
|
735
1463
|
|
736
|
-
async def
|
1464
|
+
async def amit(
|
737
1465
|
self,
|
738
|
-
|
739
|
-
|
740
|
-
|
741
|
-
) -> None:
|
742
|
-
"""Write with async mode."""
|
743
|
-
if not dynamic("enable_write_log", extras=self.extras):
|
744
|
-
return
|
1466
|
+
metadata: Metadata,
|
1467
|
+
*,
|
1468
|
+
extra: Optional[DictData] = None,
|
1469
|
+
) -> None: ...
|
745
1470
|
|
1471
|
+
def flush(
|
1472
|
+
self,
|
1473
|
+
metadata: list[Metadata],
|
1474
|
+
*,
|
1475
|
+
extra: Optional[DictData] = None,
|
1476
|
+
) -> None:
|
1477
|
+
client = self.client()
|
746
1478
|
try:
|
747
|
-
|
748
|
-
|
749
|
-
|
1479
|
+
bulk_data = []
|
1480
|
+
for meta in metadata:
|
1481
|
+
record = self._format_for_elastic(metadata=meta)
|
1482
|
+
bulk_data.append(
|
1483
|
+
{
|
1484
|
+
"index": {
|
1485
|
+
"_index": self.index,
|
1486
|
+
"_id": f"{meta.pointer_id}_{record['datetime']}_{record['thread']}",
|
1487
|
+
}
|
1488
|
+
}
|
1489
|
+
)
|
1490
|
+
bulk_data.append(record)
|
750
1491
|
|
751
|
-
|
752
|
-
|
753
|
-
mode=mode,
|
754
|
-
level=level,
|
755
|
-
message=message,
|
756
|
-
cutting_id=self.cut_id,
|
757
|
-
extras=self.extras,
|
758
|
-
)
|
1492
|
+
# Execute bulk indexing
|
1493
|
+
response = client.bulk(body=bulk_data, refresh=True)
|
759
1494
|
|
760
|
-
|
761
|
-
|
762
|
-
|
763
|
-
|
764
|
-
|
765
|
-
|
766
|
-
|
767
|
-
|
768
|
-
|
769
|
-
await f.write(trace_meta.model_dump_json() + "\n")
|
770
|
-
|
771
|
-
|
772
|
-
class SQLiteTrace(BaseTrace): # pragma: no cov
|
773
|
-
"""SQLite Trace dataclass that write trace log to the SQLite database file."""
|
774
|
-
|
775
|
-
table_name: ClassVar[str] = "audits"
|
776
|
-
schemas: ClassVar[
|
777
|
-
str
|
778
|
-
] = """
|
779
|
-
run_id str
|
780
|
-
, parent_run_id str
|
781
|
-
, type str
|
782
|
-
, text str
|
783
|
-
, metadata JSON
|
784
|
-
, created_at datetime
|
785
|
-
, updated_at datetime
|
786
|
-
primary key ( parent_run_id )
|
787
|
-
"""
|
1495
|
+
# Check for errors
|
1496
|
+
if response.get("errors", False):
|
1497
|
+
for item in response.get("items", []):
|
1498
|
+
if "index" in item and "error" in item["index"]:
|
1499
|
+
logger.error(
|
1500
|
+
f"Elasticsearch indexing error: {item['index']['error']}"
|
1501
|
+
)
|
1502
|
+
finally:
|
1503
|
+
client.close()
|
788
1504
|
|
789
1505
|
@classmethod
|
790
1506
|
def find_traces(
|
791
1507
|
cls,
|
792
|
-
|
1508
|
+
es_hosts: Union[str, list[str]] = "http://localhost:9200",
|
1509
|
+
index_name: str = "workflow-traces",
|
1510
|
+
username: Optional[str] = None,
|
1511
|
+
password: Optional[str] = None,
|
793
1512
|
extras: Optional[DictData] = None,
|
794
1513
|
) -> Iterator[TraceData]:
|
795
|
-
|
1514
|
+
"""Find trace logs from Elasticsearch."""
|
1515
|
+
try:
|
1516
|
+
from elasticsearch import Elasticsearch
|
1517
|
+
|
1518
|
+
# Create client
|
1519
|
+
client = Elasticsearch(
|
1520
|
+
hosts=es_hosts if isinstance(es_hosts, list) else [es_hosts],
|
1521
|
+
basic_auth=(
|
1522
|
+
(username, password) if username and password else None
|
1523
|
+
),
|
1524
|
+
)
|
1525
|
+
|
1526
|
+
# Search for all unique run IDs
|
1527
|
+
search_body = {
|
1528
|
+
"size": 0,
|
1529
|
+
"aggs": {
|
1530
|
+
"unique_runs": {"terms": {"field": "run_id", "size": 1000}}
|
1531
|
+
},
|
1532
|
+
}
|
1533
|
+
|
1534
|
+
response = client.search(index=index_name, body=search_body)
|
1535
|
+
|
1536
|
+
for bucket in response["aggregations"]["unique_runs"]["buckets"]:
|
1537
|
+
run_id = bucket["key"]
|
1538
|
+
|
1539
|
+
# Get all records for this run
|
1540
|
+
search_body = {
|
1541
|
+
"query": {"term": {"run_id": run_id}},
|
1542
|
+
"sort": [{"created_at": {"order": "asc"}}],
|
1543
|
+
"size": 1000,
|
1544
|
+
}
|
1545
|
+
|
1546
|
+
response = client.search(index=index_name, body=search_body)
|
1547
|
+
|
1548
|
+
# Convert to TraceData format
|
1549
|
+
stdout_lines = []
|
1550
|
+
stderr_lines = []
|
1551
|
+
meta_list = []
|
1552
|
+
|
1553
|
+
for hit in response["hits"]["hits"]:
|
1554
|
+
source = hit["_source"]
|
1555
|
+
trace_meta = Metadata(
|
1556
|
+
run_id=source["run_id"],
|
1557
|
+
parent_run_id=source["parent_run_id"],
|
1558
|
+
error_flag=source["error_flag"],
|
1559
|
+
level=source["level"],
|
1560
|
+
datetime=source["datetime"],
|
1561
|
+
process=source["process"],
|
1562
|
+
thread=source["thread"],
|
1563
|
+
message=source["message"],
|
1564
|
+
cut_id=source.get("cut_id"),
|
1565
|
+
filename=source["filename"],
|
1566
|
+
lineno=source["lineno"],
|
1567
|
+
workflow_name=source.get("workflow_name"),
|
1568
|
+
stage_name=source.get("stage_name"),
|
1569
|
+
job_name=source.get("job_name"),
|
1570
|
+
duration_ms=source.get("duration_ms"),
|
1571
|
+
memory_usage_mb=source.get("memory_usage_mb"),
|
1572
|
+
cpu_usage_percent=source.get("cpu_usage_percent"),
|
1573
|
+
trace_id=source.get("trace_id"),
|
1574
|
+
span_id=source.get("span_id"),
|
1575
|
+
parent_span_id=source.get("parent_span_id"),
|
1576
|
+
exception_type=source.get("exception_type"),
|
1577
|
+
exception_message=source.get("exception_message"),
|
1578
|
+
stack_trace=source.get("stack_trace"),
|
1579
|
+
error_code=source.get("error_code"),
|
1580
|
+
user_id=source.get("user_id"),
|
1581
|
+
tenant_id=source.get("tenant_id"),
|
1582
|
+
environment=source.get("environment"),
|
1583
|
+
hostname=source.get("hostname"),
|
1584
|
+
ip_address=source.get("ip_address"),
|
1585
|
+
python_version=source.get("python_version"),
|
1586
|
+
package_version=source.get("package_version"),
|
1587
|
+
tags=source.get("tags", []),
|
1588
|
+
metadata=source.get("metadata", {}),
|
1589
|
+
)
|
1590
|
+
|
1591
|
+
meta_list.append(trace_meta)
|
1592
|
+
fmt = (
|
1593
|
+
dynamic("log_format_file", extras=extras)
|
1594
|
+
or "{datetime} ({process:5d}, {thread:5d}) ({cut_id}) {message:120s} ({filename}:{lineno})"
|
1595
|
+
)
|
1596
|
+
formatted_line = fmt.format(**trace_meta.model_dump())
|
1597
|
+
|
1598
|
+
if trace_meta.error_flag:
|
1599
|
+
stderr_lines.append(formatted_line)
|
1600
|
+
else:
|
1601
|
+
stdout_lines.append(formatted_line)
|
1602
|
+
|
1603
|
+
yield TraceData(
|
1604
|
+
stdout="\n".join(stdout_lines),
|
1605
|
+
stderr="\n".join(stderr_lines),
|
1606
|
+
meta=meta_list,
|
1607
|
+
)
|
1608
|
+
|
1609
|
+
client.close()
|
1610
|
+
|
1611
|
+
except ImportError as e:
|
1612
|
+
raise ImportError(
|
1613
|
+
"Elasticsearch handler requires 'elasticsearch' package"
|
1614
|
+
) from e
|
1615
|
+
except Exception as e:
|
1616
|
+
logger.error(f"Failed to read from Elasticsearch: {e}")
|
796
1617
|
|
797
1618
|
@classmethod
|
798
1619
|
def find_trace_with_id(
|
@@ -800,36 +1621,367 @@ class SQLiteTrace(BaseTrace): # pragma: no cov
|
|
800
1621
|
run_id: str,
|
801
1622
|
force_raise: bool = True,
|
802
1623
|
*,
|
803
|
-
|
1624
|
+
es_hosts: Union[str, list[str]] = "http://localhost:9200",
|
1625
|
+
index_name: str = "workflow-traces",
|
1626
|
+
username: Optional[str] = None,
|
1627
|
+
password: Optional[str] = None,
|
804
1628
|
extras: Optional[DictData] = None,
|
805
1629
|
) -> TraceData:
|
806
|
-
|
1630
|
+
"""Find trace log with specific run ID from Elasticsearch."""
|
1631
|
+
try:
|
1632
|
+
from elasticsearch import Elasticsearch
|
1633
|
+
|
1634
|
+
# Create client
|
1635
|
+
client = Elasticsearch(
|
1636
|
+
hosts=es_hosts if isinstance(es_hosts, list) else [es_hosts],
|
1637
|
+
basic_auth=(
|
1638
|
+
(username, password) if username and password else None
|
1639
|
+
),
|
1640
|
+
)
|
1641
|
+
|
1642
|
+
# Search for specific run ID
|
1643
|
+
search_body = {
|
1644
|
+
"query": {"term": {"run_id": run_id}},
|
1645
|
+
"sort": [{"created_at": {"order": "asc"}}],
|
1646
|
+
"size": 1000,
|
1647
|
+
}
|
1648
|
+
|
1649
|
+
response = client.search(index=index_name, body=search_body)
|
1650
|
+
|
1651
|
+
if not response["hits"]["hits"]:
|
1652
|
+
if force_raise:
|
1653
|
+
raise FileNotFoundError(
|
1654
|
+
f"Trace log with run_id '{run_id}' not found in Elasticsearch"
|
1655
|
+
)
|
1656
|
+
return TraceData(stdout="", stderr="")
|
1657
|
+
|
1658
|
+
# Convert to TraceData format
|
1659
|
+
stdout_lines = []
|
1660
|
+
stderr_lines = []
|
1661
|
+
meta_list = []
|
1662
|
+
|
1663
|
+
for hit in response["hits"]["hits"]:
|
1664
|
+
source = hit["_source"]
|
1665
|
+
|
1666
|
+
# Convert to TraceMeta
|
1667
|
+
trace_meta = Metadata(
|
1668
|
+
run_id=source["run_id"],
|
1669
|
+
parent_run_id=source["parent_run_id"],
|
1670
|
+
error_flag=source["error_flag"],
|
1671
|
+
level=source["level"],
|
1672
|
+
datetime=source["datetime"],
|
1673
|
+
process=source["process"],
|
1674
|
+
thread=source["thread"],
|
1675
|
+
message=source["message"],
|
1676
|
+
cut_id=source.get("cut_id"),
|
1677
|
+
filename=source["filename"],
|
1678
|
+
lineno=source["lineno"],
|
1679
|
+
workflow_name=source.get("workflow_name"),
|
1680
|
+
stage_name=source.get("stage_name"),
|
1681
|
+
job_name=source.get("job_name"),
|
1682
|
+
duration_ms=source.get("duration_ms"),
|
1683
|
+
memory_usage_mb=source.get("memory_usage_mb"),
|
1684
|
+
cpu_usage_percent=source.get("cpu_usage_percent"),
|
1685
|
+
trace_id=source.get("trace_id"),
|
1686
|
+
span_id=source.get("span_id"),
|
1687
|
+
parent_span_id=source.get("parent_span_id"),
|
1688
|
+
exception_type=source.get("exception_type"),
|
1689
|
+
exception_message=source.get("exception_message"),
|
1690
|
+
stack_trace=source.get("stack_trace"),
|
1691
|
+
error_code=source.get("error_code"),
|
1692
|
+
user_id=source.get("user_id"),
|
1693
|
+
tenant_id=source.get("tenant_id"),
|
1694
|
+
environment=source.get("environment"),
|
1695
|
+
hostname=source.get("hostname"),
|
1696
|
+
ip_address=source.get("ip_address"),
|
1697
|
+
python_version=source.get("python_version"),
|
1698
|
+
package_version=source.get("package_version"),
|
1699
|
+
tags=source.get("tags", []),
|
1700
|
+
metadata=source.get("metadata", {}),
|
1701
|
+
)
|
1702
|
+
|
1703
|
+
meta_list.append(trace_meta)
|
1704
|
+
|
1705
|
+
# Add to stdout/stderr based on mode
|
1706
|
+
fmt = (
|
1707
|
+
dynamic("log_format_file", extras=extras)
|
1708
|
+
or "{datetime} ({process:5d}, {thread:5d}) ({cut_id}) {message:120s} ({filename}:{lineno})"
|
1709
|
+
)
|
1710
|
+
formatted_line = fmt.format(**trace_meta.model_dump())
|
1711
|
+
|
1712
|
+
if trace_meta.error_flag:
|
1713
|
+
stderr_lines.append(formatted_line)
|
1714
|
+
else:
|
1715
|
+
stdout_lines.append(formatted_line)
|
1716
|
+
|
1717
|
+
client.close()
|
1718
|
+
|
1719
|
+
return TraceData(
|
1720
|
+
stdout="\n".join(stdout_lines),
|
1721
|
+
stderr="\n".join(stderr_lines),
|
1722
|
+
meta=meta_list,
|
1723
|
+
)
|
1724
|
+
|
1725
|
+
except ImportError as e:
|
1726
|
+
raise ImportError(
|
1727
|
+
"Elasticsearch handler requires 'elasticsearch' package"
|
1728
|
+
) from e
|
1729
|
+
except Exception as e:
|
1730
|
+
logger.error(f"Failed to read from Elasticsearch: {e}")
|
1731
|
+
if force_raise:
|
1732
|
+
raise
|
1733
|
+
return TraceData(stdout="", stderr="")
|
1734
|
+
|
1735
|
+
|
1736
|
+
TraceHandler = Annotated[
|
1737
|
+
Union[
|
1738
|
+
ConsoleHandler,
|
1739
|
+
FileHandler,
|
1740
|
+
SQLiteHandler,
|
1741
|
+
],
|
1742
|
+
Field(discriminator="type"),
|
1743
|
+
]
|
1744
|
+
|
807
1745
|
|
808
|
-
|
809
|
-
raise NotImplementedError("SQLiteTrace does not implement yet.")
|
1746
|
+
class BaseEmit(ABC):
|
810
1747
|
|
811
|
-
|
1748
|
+
@abstractmethod
|
1749
|
+
def emit(
|
812
1750
|
self,
|
813
|
-
|
814
|
-
level:
|
815
|
-
|
816
|
-
|
817
|
-
|
1751
|
+
msg: str,
|
1752
|
+
level: Level,
|
1753
|
+
):
|
1754
|
+
"""Write trace log with append mode and logging this message with any
|
1755
|
+
logging level.
|
1756
|
+
|
1757
|
+
Args:
|
1758
|
+
msg: A message that want to log.
|
1759
|
+
level: A logging level.
|
1760
|
+
"""
|
1761
|
+
raise NotImplementedError(
|
1762
|
+
"Logging action should be implement for making trace log."
|
1763
|
+
)
|
1764
|
+
|
1765
|
+
def debug(self, msg: str):
|
1766
|
+
"""Write trace log with append mode and logging this message with the
|
1767
|
+
DEBUG level.
|
1768
|
+
|
1769
|
+
Args:
|
1770
|
+
msg: A message that want to log.
|
1771
|
+
"""
|
1772
|
+
self.emit(msg, level="debug")
|
1773
|
+
|
1774
|
+
def info(self, msg: str) -> None:
|
1775
|
+
"""Write trace log with append mode and logging this message with the
|
1776
|
+
INFO level.
|
1777
|
+
|
1778
|
+
Args:
|
1779
|
+
msg: A message that want to log.
|
1780
|
+
"""
|
1781
|
+
self.emit(msg, level="info")
|
1782
|
+
|
1783
|
+
def warning(self, msg: str) -> None:
|
1784
|
+
"""Write trace log with append mode and logging this message with the
|
1785
|
+
WARNING level.
|
1786
|
+
|
1787
|
+
Args:
|
1788
|
+
msg: A message that want to log.
|
1789
|
+
"""
|
1790
|
+
self.emit(msg, level="warning")
|
1791
|
+
|
1792
|
+
def error(self, msg: str) -> None:
|
1793
|
+
"""Write trace log with append mode and logging this message with the
|
1794
|
+
ERROR level.
|
1795
|
+
|
1796
|
+
Args:
|
1797
|
+
msg: A message that want to log.
|
1798
|
+
"""
|
1799
|
+
self.emit(msg, level="error")
|
1800
|
+
|
1801
|
+
def exception(self, msg: str) -> None:
|
1802
|
+
"""Write trace log with append mode and logging this message with the
|
1803
|
+
EXCEPTION level.
|
1804
|
+
|
1805
|
+
Args:
|
1806
|
+
msg: A message that want to log.
|
1807
|
+
"""
|
1808
|
+
self.emit(msg, level="exception")
|
1809
|
+
|
818
1810
|
|
819
|
-
|
1811
|
+
class BaseAsyncEmit(ABC):
|
1812
|
+
|
1813
|
+
@abstractmethod
|
1814
|
+
async def amit(
|
820
1815
|
self,
|
821
|
-
|
822
|
-
level:
|
823
|
-
is_err: bool = False,
|
1816
|
+
msg: str,
|
1817
|
+
level: Level,
|
824
1818
|
) -> None:
|
825
|
-
|
1819
|
+
"""Async write trace log with append mode and logging this message with
|
1820
|
+
any logging level.
|
826
1821
|
|
1822
|
+
Args:
|
1823
|
+
msg (str): A message that want to log.
|
1824
|
+
level (Mode): A logging level.
|
1825
|
+
"""
|
1826
|
+
raise NotImplementedError(
|
1827
|
+
"Async Logging action should be implement for making trace log."
|
1828
|
+
)
|
827
1829
|
|
828
|
-
|
829
|
-
|
830
|
-
|
831
|
-
|
832
|
-
|
1830
|
+
async def adebug(self, msg: str) -> None: # pragma: no cov
|
1831
|
+
"""Async write trace log with append mode and logging this message with
|
1832
|
+
the DEBUG level.
|
1833
|
+
|
1834
|
+
Args:
|
1835
|
+
msg: A message that want to log.
|
1836
|
+
"""
|
1837
|
+
await self.amit(msg, level="debug")
|
1838
|
+
|
1839
|
+
async def ainfo(self, msg: str) -> None: # pragma: no cov
|
1840
|
+
"""Async write trace log with append mode and logging this message with
|
1841
|
+
the INFO level.
|
1842
|
+
|
1843
|
+
Args:
|
1844
|
+
msg: A message that want to log.
|
1845
|
+
"""
|
1846
|
+
await self.amit(msg, level="info")
|
1847
|
+
|
1848
|
+
async def awarning(self, msg: str) -> None: # pragma: no cov
|
1849
|
+
"""Async write trace log with append mode and logging this message with
|
1850
|
+
the WARNING level.
|
1851
|
+
|
1852
|
+
Args:
|
1853
|
+
msg: A message that want to log.
|
1854
|
+
"""
|
1855
|
+
await self.amit(msg, level="warning")
|
1856
|
+
|
1857
|
+
async def aerror(self, msg: str) -> None: # pragma: no cov
|
1858
|
+
"""Async write trace log with append mode and logging this message with
|
1859
|
+
the ERROR level.
|
1860
|
+
|
1861
|
+
Args:
|
1862
|
+
msg: A message that want to log.
|
1863
|
+
"""
|
1864
|
+
await self.amit(msg, level="error")
|
1865
|
+
|
1866
|
+
async def aexception(self, msg: str) -> None: # pragma: no cov
|
1867
|
+
"""Async write trace log with append mode and logging this message with
|
1868
|
+
the EXCEPTION level.
|
1869
|
+
|
1870
|
+
Args:
|
1871
|
+
msg: A message that want to log.
|
1872
|
+
"""
|
1873
|
+
await self.amit(msg, level="exception")
|
1874
|
+
|
1875
|
+
|
1876
|
+
class TraceManager(BaseModel, BaseEmit, BaseAsyncEmit):
|
1877
|
+
"""Trace Management that keep all trance handler."""
|
1878
|
+
|
1879
|
+
extras: DictData = Field(
|
1880
|
+
default_factory=dict,
|
1881
|
+
description=(
|
1882
|
+
"An extra parameter that want to override on the core config "
|
1883
|
+
"values."
|
1884
|
+
),
|
1885
|
+
)
|
1886
|
+
run_id: str = Field(description="A running ID")
|
1887
|
+
parent_run_id: Optional[str] = Field(
|
1888
|
+
default=None,
|
1889
|
+
description="A parent running ID",
|
1890
|
+
)
|
1891
|
+
handlers: list[TraceHandler] = Field(
|
1892
|
+
description="A list of Trace handler model."
|
1893
|
+
)
|
1894
|
+
buffer_size: int = Field(
|
1895
|
+
default=1,
|
1896
|
+
description="A buffer size to trigger flush trace log",
|
1897
|
+
)
|
1898
|
+
|
1899
|
+
# NOTE: Private attrs for the internal process.
|
1900
|
+
_enable_buffer: bool = PrivateAttr(default=False)
|
1901
|
+
_buffer: list[Metadata] = PrivateAttr(default_factory=list)
|
1902
|
+
|
1903
|
+
@property
|
1904
|
+
def cut_id(self) -> str:
|
1905
|
+
"""Combine cutting ID of parent running ID if it set.
|
1906
|
+
|
1907
|
+
Returns:
|
1908
|
+
str: The combined cutting ID string.
|
1909
|
+
"""
|
1910
|
+
cut_run_id: str = cut_id(self.run_id)
|
1911
|
+
if not self.parent_run_id:
|
1912
|
+
return cut_run_id
|
1913
|
+
|
1914
|
+
cut_parent_run_id: str = cut_id(self.parent_run_id)
|
1915
|
+
return f"{cut_parent_run_id} -> {cut_run_id}"
|
1916
|
+
|
1917
|
+
def make_message(self, msg: str) -> str:
|
1918
|
+
"""Prepare and Make a message before write and log steps.
|
1919
|
+
|
1920
|
+
Args:
|
1921
|
+
msg: A message that want to prepare and make before.
|
1922
|
+
|
1923
|
+
Returns:
|
1924
|
+
str: The prepared message.
|
1925
|
+
"""
|
1926
|
+
return prepare_newline(Message.from_str(msg).prepare(self.extras))
|
1927
|
+
|
1928
|
+
def emit(self, msg: str, level: Level) -> None:
|
1929
|
+
"""Emit a trace log to all handler. This will use synchronise process.
|
1930
|
+
|
1931
|
+
Args:
|
1932
|
+
msg: A message.
|
1933
|
+
level: A tracing level.
|
1934
|
+
"""
|
1935
|
+
_msg: str = self.make_message(msg)
|
1936
|
+
metadata: Metadata = Metadata.make(
|
1937
|
+
error_flag=(level in ("error", "exception")),
|
1938
|
+
level=level,
|
1939
|
+
message=_msg,
|
1940
|
+
cutting_id=self.cut_id,
|
1941
|
+
run_id=self.run_id,
|
1942
|
+
parent_run_id=self.parent_run_id,
|
1943
|
+
extras=self.extras,
|
1944
|
+
)
|
1945
|
+
if self._enable_buffer: # pragma: no cov
|
1946
|
+
self._buffer.append(metadata)
|
1947
|
+
|
1948
|
+
if len(self._buffer) >= self.buffer_size:
|
1949
|
+
for handler in self.handlers:
|
1950
|
+
handler.flush(self._buffer, extra=self.extras)
|
1951
|
+
self._buffer.clear()
|
1952
|
+
else:
|
1953
|
+
for handler in self.handlers:
|
1954
|
+
handler.emit(metadata, extra=self.extras)
|
1955
|
+
|
1956
|
+
async def amit(self, msg: str, level: Level) -> None:
|
1957
|
+
"""Async write trace log with append mode and logging this message with
|
1958
|
+
any logging level.
|
1959
|
+
|
1960
|
+
Args:
|
1961
|
+
msg: A message that want to log.
|
1962
|
+
level (Level): A logging mode.
|
1963
|
+
"""
|
1964
|
+
_msg: str = self.make_message(msg)
|
1965
|
+
metadata: Metadata = Metadata.make(
|
1966
|
+
error_flag=(level in ("error", "exception")),
|
1967
|
+
level=level,
|
1968
|
+
message=_msg,
|
1969
|
+
cutting_id=self.cut_id,
|
1970
|
+
run_id=self.run_id,
|
1971
|
+
parent_run_id=self.parent_run_id,
|
1972
|
+
extras=self.extras,
|
1973
|
+
)
|
1974
|
+
for handler in self.handlers:
|
1975
|
+
await handler.amit(metadata, extra=self.extras)
|
1976
|
+
|
1977
|
+
def __enter__(self): # pragma: no cov
|
1978
|
+
self._enable_buffer = True
|
1979
|
+
|
1980
|
+
def __exit__(self, exc_type, exc_val, exc_tb): # pragma: no cov
|
1981
|
+
if self._buffer:
|
1982
|
+
for handler in self.handlers:
|
1983
|
+
handler.flush(self._buffer, extra=self.extras)
|
1984
|
+
self._buffer.clear()
|
833
1985
|
|
834
1986
|
|
835
1987
|
def get_trace(
|
@@ -837,40 +1989,26 @@ def get_trace(
|
|
837
1989
|
*,
|
838
1990
|
parent_run_id: Optional[str] = None,
|
839
1991
|
extras: Optional[DictData] = None,
|
840
|
-
) ->
|
841
|
-
"""Get dynamic
|
842
|
-
extras argument) that passing running ID and parent running ID.
|
1992
|
+
) -> TraceManager: # pragma: no cov
|
1993
|
+
"""Get dynamic TraceManager instance from the core config.
|
843
1994
|
|
844
|
-
|
845
|
-
|
846
|
-
|
847
|
-
config values.
|
1995
|
+
This factory function returns the appropriate trace implementation based on
|
1996
|
+
configuration. It can be overridden by extras argument and accepts running ID
|
1997
|
+
and parent running ID.
|
848
1998
|
|
849
|
-
:
|
850
|
-
|
851
|
-
|
852
|
-
|
853
|
-
|
854
|
-
)
|
855
|
-
url: ParseResult
|
856
|
-
if (url := dynamic("trace_url", extras=extras)).scheme and (
|
857
|
-
url.scheme == "sqlite"
|
858
|
-
or (url.scheme == "file" and Path(url.path).is_file())
|
859
|
-
):
|
860
|
-
return map_trace_models.get("sqlite", SQLiteTrace)(
|
861
|
-
url=url,
|
862
|
-
run_id=run_id,
|
863
|
-
parent_run_id=parent_run_id,
|
864
|
-
extras=(extras or {}),
|
865
|
-
)
|
866
|
-
elif url.scheme and url.scheme != "file":
|
867
|
-
raise NotImplementedError(
|
868
|
-
f"Does not implement the outside trace model support for URL: {url}"
|
869
|
-
)
|
1999
|
+
Args:
|
2000
|
+
run_id: A running ID.
|
2001
|
+
parent_run_id: A parent running ID.
|
2002
|
+
extras: An extra parameter that want to override the core
|
2003
|
+
config values.
|
870
2004
|
|
871
|
-
|
872
|
-
|
2005
|
+
Returns:
|
2006
|
+
TraceManager: The appropriate trace instance.
|
2007
|
+
"""
|
2008
|
+
handlers = dynamic("trace_handlers", extras=extras)
|
2009
|
+
return TraceManager(
|
873
2010
|
run_id=run_id,
|
874
2011
|
parent_run_id=parent_run_id,
|
875
|
-
|
2012
|
+
handlers=handlers,
|
2013
|
+
extras=extras or {},
|
876
2014
|
)
|