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