ddeutil-workflow 0.0.77__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 +25 -3
- ddeutil/workflow/conf.py +16 -28
- ddeutil/workflow/errors.py +13 -15
- ddeutil/workflow/event.py +37 -41
- ddeutil/workflow/job.py +161 -92
- 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 +35 -37
- ddeutil/workflow/reusables.py +153 -96
- ddeutil/workflow/stages.py +84 -60
- ddeutil/workflow/traces.py +1660 -521
- ddeutil/workflow/utils.py +111 -69
- ddeutil/workflow/workflow.py +74 -47
- {ddeutil_workflow-0.0.77.dist-info → ddeutil_workflow-0.0.79.dist-info}/METADATA +52 -20
- ddeutil_workflow-0.0.79.dist-info/RECORD +36 -0
- ddeutil_workflow-0.0.77.dist-info/RECORD +0 -30
- {ddeutil_workflow-0.0.77.dist-info → ddeutil_workflow-0.0.79.dist-info}/WHEEL +0 -0
- {ddeutil_workflow-0.0.77.dist-info → ddeutil_workflow-0.0.79.dist-info}/entry_points.txt +0 -0
- {ddeutil_workflow-0.0.77.dist-info → ddeutil_workflow-0.0.79.dist-info}/licenses/LICENSE +0 -0
- {ddeutil_workflow-0.0.77.dist-info → ddeutil_workflow-0.0.79.dist-info}/top_level.txt +0 -0
ddeutil/workflow/traces.py
CHANGED
@@ -13,44 +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")
|
17
|
+
set_logging: Configure logger with custom formatting.
|
18
|
+
get_trace: Factory function for trace instances.
|
35
19
|
"""
|
36
|
-
from __future__ import annotations
|
37
|
-
|
38
20
|
import json
|
39
21
|
import logging
|
40
22
|
import os
|
41
23
|
import re
|
42
24
|
from abc import ABC, abstractmethod
|
43
25
|
from collections.abc import Iterator
|
26
|
+
from datetime import datetime
|
44
27
|
from functools import lru_cache
|
45
28
|
from inspect import Traceback, currentframe, getframeinfo
|
46
29
|
from pathlib import Path
|
47
|
-
from threading import get_ident
|
30
|
+
from threading import Lock, get_ident
|
48
31
|
from types import FrameType
|
49
|
-
from typing import ClassVar, Final, Literal, Optional, Union
|
50
|
-
from
|
32
|
+
from typing import Annotated, Any, ClassVar, Final, Literal, Optional, Union
|
33
|
+
from zoneinfo import ZoneInfo
|
51
34
|
|
52
|
-
from pydantic import BaseModel,
|
53
|
-
from pydantic.functional_serializers import field_serializer
|
35
|
+
from pydantic import BaseModel, Field, PrivateAttr
|
54
36
|
from pydantic.functional_validators import field_validator
|
55
37
|
from typing_extensions import Self
|
56
38
|
|
@@ -58,8 +40,8 @@ from .__types import DictData
|
|
58
40
|
from .conf import config, dynamic
|
59
41
|
from .utils import cut_id, get_dt_now, prepare_newline
|
60
42
|
|
61
|
-
METADATA: str = "metadata.json"
|
62
43
|
logger = logging.getLogger("ddeutil.workflow")
|
44
|
+
Level = Literal["debug", "info", "warning", "error", "exception"]
|
63
45
|
|
64
46
|
|
65
47
|
@lru_cache
|
@@ -71,16 +53,14 @@ def set_logging(name: str) -> logging.Logger:
|
|
71
53
|
console output and proper formatting for workflow execution tracking.
|
72
54
|
|
73
55
|
Args:
|
74
|
-
name: Module name to create logger for
|
56
|
+
name: Module name to create logger for.
|
75
57
|
|
76
58
|
Returns:
|
77
|
-
logging.Logger: Configured logger instance with custom formatting
|
59
|
+
logging.Logger: Configured logger instance with custom formatting.
|
78
60
|
|
79
61
|
Example:
|
80
|
-
|
81
|
-
|
82
|
-
logger.info("Stage execution started")
|
83
|
-
```
|
62
|
+
>>> log = set_logging("ddeutil.workflow.stages")
|
63
|
+
>>> log.info("Stage execution started")
|
84
64
|
"""
|
85
65
|
_logger = logging.getLogger(name)
|
86
66
|
|
@@ -103,25 +83,28 @@ def set_logging(name: str) -> logging.Logger:
|
|
103
83
|
|
104
84
|
PREFIX_LOGS: Final[dict[str, dict]] = {
|
105
85
|
"CALLER": {
|
106
|
-
"emoji": "
|
86
|
+
"emoji": "⚙️",
|
107
87
|
"desc": "logs from any usage from custom caller function.",
|
108
88
|
},
|
109
|
-
"STAGE": {"emoji": "
|
89
|
+
"STAGE": {"emoji": "🔗", "desc": "logs from stages module."},
|
110
90
|
"JOB": {"emoji": "⛓️", "desc": "logs from job module."},
|
111
91
|
"WORKFLOW": {"emoji": "🏃", "desc": "logs from workflow module."},
|
112
92
|
"RELEASE": {"emoji": "📅", "desc": "logs from release workflow method."},
|
113
93
|
"POKING": {"emoji": "⏰", "desc": "logs from poke workflow method."},
|
94
|
+
"AUDIT": {"emoji": "📌", "desc": "logs from audit model."},
|
114
95
|
} # pragma: no cov
|
115
96
|
PREFIX_DEFAULT: Final[str] = "CALLER"
|
116
|
-
PREFIX_LOGS_REGEX: re.Pattern[str] = re.compile(
|
97
|
+
PREFIX_LOGS_REGEX: Final[re.Pattern[str]] = re.compile(
|
117
98
|
rf"(^\[(?P<name>{'|'.join(PREFIX_LOGS)})]:\s?)?(?P<message>.*)",
|
118
99
|
re.MULTILINE | re.DOTALL | re.ASCII | re.VERBOSE,
|
119
100
|
) # pragma: no cov
|
120
101
|
|
121
102
|
|
122
103
|
class Message(BaseModel):
|
123
|
-
"""Prefix Message model for receive grouping dict from searching prefix data
|
124
|
-
|
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.
|
125
108
|
"""
|
126
109
|
|
127
110
|
name: Optional[str] = Field(default=None, description="A prefix name.")
|
@@ -132,10 +115,10 @@ class Message(BaseModel):
|
|
132
115
|
"""Extract message prefix from an input message.
|
133
116
|
|
134
117
|
Args:
|
135
|
-
msg
|
118
|
+
msg: A message that want to extract.
|
136
119
|
|
137
120
|
Returns:
|
138
|
-
Message:
|
121
|
+
Message: The validated model from a string message.
|
139
122
|
"""
|
140
123
|
return Message.model_validate(
|
141
124
|
obj=PREFIX_LOGS_REGEX.search(msg).groupdict()
|
@@ -160,13 +143,16 @@ class Message(BaseModel):
|
|
160
143
|
return f"{emoji}[{name}]: {self.message}"
|
161
144
|
|
162
145
|
|
163
|
-
class
|
164
|
-
"""Trace Metadata model for making the current metadata of this CPU, Memory
|
165
|
-
|
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.
|
166
152
|
"""
|
167
153
|
|
168
|
-
|
169
|
-
level:
|
154
|
+
error_flag: bool = Field(default=False, description="A meta error flag.")
|
155
|
+
level: Level = Field(description="A log level.")
|
170
156
|
datetime: str = Field(
|
171
157
|
description="A datetime string with the specific config format."
|
172
158
|
)
|
@@ -176,15 +162,98 @@ class TraceMeta(BaseModel): # pragma: no cov
|
|
176
162
|
cut_id: Optional[str] = Field(
|
177
163
|
default=None, description="A cutting of running ID."
|
178
164
|
)
|
165
|
+
run_id: str
|
166
|
+
parent_run_id: Optional[str] = None
|
179
167
|
filename: str = Field(description="A filename of this log.")
|
180
168
|
lineno: int = Field(description="A line number of this log.")
|
181
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
|
+
|
182
252
|
@classmethod
|
183
253
|
def dynamic_frame(
|
184
254
|
cls, frame: FrameType, *, extras: Optional[DictData] = None
|
185
255
|
) -> Traceback:
|
186
|
-
"""Dynamic Frame information base on the `logs_trace_frame_layer` config
|
187
|
-
value that was set from the extra parameter.
|
256
|
+
"""Dynamic Frame information base on the `logs_trace_frame_layer` config.
|
188
257
|
|
189
258
|
Args:
|
190
259
|
frame: The current frame that want to dynamic.
|
@@ -194,67 +263,136 @@ class TraceMeta(BaseModel): # pragma: no cov
|
|
194
263
|
Returns:
|
195
264
|
Traceback: The frame information at the specified layer.
|
196
265
|
"""
|
197
|
-
|
198
|
-
layer: int =
|
266
|
+
extras_data: DictData = extras or {}
|
267
|
+
layer: int = extras_data.get("logs_trace_frame_layer", 4)
|
268
|
+
current_frame: FrameType = frame
|
199
269
|
for _ in range(layer):
|
200
|
-
_frame: Optional[FrameType] =
|
270
|
+
_frame: Optional[FrameType] = current_frame.f_back
|
201
271
|
if _frame is None:
|
202
272
|
raise ValueError(
|
203
273
|
f"Layer value does not valid, the maximum frame is: {_ + 1}"
|
204
274
|
)
|
205
|
-
|
206
|
-
return getframeinfo(
|
275
|
+
current_frame = _frame
|
276
|
+
return getframeinfo(current_frame)
|
207
277
|
|
208
278
|
@classmethod
|
209
279
|
def make(
|
210
280
|
cls,
|
211
|
-
|
281
|
+
error_flag: bool,
|
212
282
|
message: str,
|
213
|
-
level:
|
283
|
+
level: Level,
|
214
284
|
cutting_id: str,
|
285
|
+
run_id: str,
|
286
|
+
parent_run_id: Optional[str],
|
215
287
|
*,
|
216
288
|
extras: Optional[DictData] = None,
|
217
289
|
) -> Self:
|
218
|
-
"""Make the current metric for contract this
|
219
|
-
|
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.
|
220
294
|
|
221
295
|
Args:
|
222
|
-
|
296
|
+
error_flag: A metadata mode.
|
223
297
|
message: A message.
|
224
298
|
level: A log level.
|
225
299
|
cutting_id: A cutting ID string.
|
300
|
+
run_id:
|
301
|
+
parent_run_id:
|
226
302
|
extras: An extra parameter that want to override core
|
227
303
|
config values.
|
228
304
|
|
229
305
|
Returns:
|
230
|
-
Self: The constructed
|
306
|
+
Self: The constructed Metadata instance.
|
231
307
|
"""
|
232
|
-
|
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
|
+
|
233
315
|
frame_info: Traceback = cls.dynamic_frame(frame, extras=extras)
|
234
|
-
|
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
|
+
|
235
336
|
return cls(
|
236
|
-
|
337
|
+
error_flag=error_flag,
|
237
338
|
level=level,
|
238
339
|
datetime=(
|
239
|
-
get_dt_now()
|
240
|
-
.astimezone(dynamic("log_tz", extras=extras))
|
241
|
-
.strftime(dynamic("log_datetime_format", extras=extras))
|
340
|
+
get_dt_now().astimezone(timezone).strftime(datetime_format)
|
242
341
|
),
|
243
342
|
process=os.getpid(),
|
244
343
|
thread=get_ident(),
|
245
344
|
message=message,
|
246
345
|
cut_id=cutting_id,
|
346
|
+
run_id=run_id,
|
347
|
+
parent_run_id=parent_run_id,
|
247
348
|
filename=frame_info.filename.split(os.path.sep)[-1],
|
248
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", {}),
|
249
379
|
)
|
250
380
|
|
381
|
+
@property
|
382
|
+
def pointer_id(self):
|
383
|
+
return self.parent_run_id or self.run_id
|
384
|
+
|
251
385
|
|
252
386
|
class TraceData(BaseModel): # pragma: no cov
|
253
|
-
"""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
|
+
"""
|
254
392
|
|
255
393
|
stdout: str = Field(description="A standard output trace data.")
|
256
394
|
stderr: str = Field(description="A standard error trace data.")
|
257
|
-
meta: list[
|
395
|
+
meta: list[Metadata] = Field(
|
258
396
|
default_factory=list,
|
259
397
|
description=(
|
260
398
|
"A metadata mapping of this output and error before making it to "
|
@@ -262,13 +400,191 @@ class TraceData(BaseModel): # pragma: no cov
|
|
262
400
|
),
|
263
401
|
)
|
264
402
|
|
403
|
+
|
404
|
+
class BaseHandler(BaseModel, ABC):
|
405
|
+
"""Base Handler model"""
|
406
|
+
|
407
|
+
@abstractmethod
|
408
|
+
def emit(
|
409
|
+
self,
|
410
|
+
metadata: Metadata,
|
411
|
+
*,
|
412
|
+
extra: Optional[DictData] = None,
|
413
|
+
): ...
|
414
|
+
|
415
|
+
@abstractmethod
|
416
|
+
async def amit(
|
417
|
+
self,
|
418
|
+
metadata: Metadata,
|
419
|
+
*,
|
420
|
+
extra: Optional[DictData] = None,
|
421
|
+
) -> None: ...
|
422
|
+
|
423
|
+
@abstractmethod
|
424
|
+
def flush(
|
425
|
+
self, metadata: list[Metadata], *, extra: Optional[DictData] = None
|
426
|
+
) -> None: ...
|
427
|
+
|
428
|
+
|
429
|
+
class ConsoleHandler(BaseHandler):
|
430
|
+
"""Console Handler model."""
|
431
|
+
|
432
|
+
type: Literal["console"] = "console"
|
433
|
+
|
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},
|
441
|
+
)
|
442
|
+
|
443
|
+
async def amit(
|
444
|
+
self, metadata: Metadata, *, extra: Optional[DictData] = None
|
445
|
+
) -> None:
|
446
|
+
self.emit(metadata, extra=extra)
|
447
|
+
|
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)
|
453
|
+
|
454
|
+
|
455
|
+
class FileHandler(BaseHandler):
|
456
|
+
"""File Handler model."""
|
457
|
+
|
458
|
+
metadata_filename: ClassVar[str] = "metadata.txt"
|
459
|
+
|
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
|
469
|
+
|
470
|
+
# NOTE: Private attrs for the internal process.
|
471
|
+
_lock: Lock = PrivateAttr(default_factory=Lock)
|
472
|
+
|
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.
|
476
|
+
|
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.
|
480
|
+
|
481
|
+
Returns:
|
482
|
+
Path: The target path for trace log operations.
|
483
|
+
"""
|
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
|
488
|
+
|
489
|
+
def pre(self) -> None: ...
|
490
|
+
|
491
|
+
def emit(
|
492
|
+
self,
|
493
|
+
metadata: Metadata,
|
494
|
+
*,
|
495
|
+
extra: Optional[DictData] = None,
|
496
|
+
) -> None:
|
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")
|
509
|
+
|
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
|
520
|
+
|
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
|
+
)
|
530
|
+
|
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")
|
535
|
+
|
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
|
+
)
|
559
|
+
|
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()
|
578
|
+
|
265
579
|
@classmethod
|
266
|
-
def from_path(cls, file: Path) ->
|
580
|
+
def from_path(cls, file: Path) -> TraceData: # pragma: no cov
|
267
581
|
"""Construct this trace data model with a trace path.
|
268
582
|
|
269
|
-
:
|
583
|
+
Args:
|
584
|
+
file: A trace path.
|
270
585
|
|
271
|
-
:
|
586
|
+
Returns:
|
587
|
+
Self: The constructed TraceData instance.
|
272
588
|
"""
|
273
589
|
data: DictData = {"stdout": "", "stderr": "", "meta": []}
|
274
590
|
|
@@ -276,559 +592,1396 @@ class TraceData(BaseModel): # pragma: no cov
|
|
276
592
|
if (file / f"{mode}.txt").exists():
|
277
593
|
data[mode] = (file / f"{mode}.txt").read_text(encoding="utf-8")
|
278
594
|
|
279
|
-
if (file /
|
595
|
+
if (file / cls.metadata_filename).exists():
|
280
596
|
data["meta"] = [
|
281
597
|
json.loads(line)
|
282
598
|
for line in (
|
283
|
-
(file /
|
599
|
+
(file / cls.metadata_filename)
|
600
|
+
.read_text(encoding="utf-8")
|
601
|
+
.splitlines()
|
284
602
|
)
|
285
603
|
]
|
286
604
|
|
287
|
-
return
|
605
|
+
return TraceData.model_validate(data)
|
606
|
+
|
607
|
+
def find_traces(
|
608
|
+
self,
|
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.
|
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)
|
621
|
+
|
622
|
+
def find_trace_with_id(
|
623
|
+
self,
|
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.
|
288
630
|
|
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.
|
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="")
|
289
646
|
|
290
|
-
class BaseEmitTrace(BaseModel, ABC): # pragma: no cov
|
291
|
-
"""Base Trace model with abstraction class property."""
|
292
647
|
|
293
|
-
|
648
|
+
class SQLiteHandler(BaseHandler): # pragma: no cov
|
649
|
+
"""High-performance SQLite logging handler for workflow traces.
|
294
650
|
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
|
299
|
-
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
|
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
|
+
"""
|
655
|
+
|
656
|
+
type: Literal["sqlite"] = "sqlite"
|
657
|
+
path: str
|
658
|
+
table_name: str = Field(default="traces")
|
659
|
+
|
660
|
+
# NOTE: Private attrs for the internal process.
|
661
|
+
_lock: Lock = PrivateAttr(default_factory=Lock)
|
662
|
+
|
663
|
+
def pre(self) -> None:
|
664
|
+
import sqlite3
|
665
|
+
|
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
|
+
)
|
712
|
+
|
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
|
+
)
|
738
|
+
|
739
|
+
conn.commit()
|
740
|
+
|
741
|
+
except Exception as e:
|
742
|
+
logger.error(f"Failed to initialize SQLite database: {e}")
|
743
|
+
raise
|
744
|
+
|
745
|
+
def emit(
|
746
|
+
self,
|
747
|
+
metadata: Metadata,
|
748
|
+
*,
|
749
|
+
extra: Optional[DictData] = None,
|
750
|
+
) -> None:
|
751
|
+
self.flush([metadata], extra=extra)
|
752
|
+
|
753
|
+
def amit(
|
754
|
+
self,
|
755
|
+
metadata: Metadata,
|
756
|
+
*,
|
757
|
+
extra: Optional[DictData] = None,
|
758
|
+
) -> None: ...
|
759
|
+
|
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
|
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()
|
839
|
+
|
840
|
+
def find_traces(
|
841
|
+
self,
|
842
|
+
path: Optional[Path] = None,
|
843
|
+
extras: Optional[DictData] = None,
|
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
|
859
|
+
|
860
|
+
import sqlite3
|
861
|
+
|
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}")
|
954
|
+
|
955
|
+
@classmethod
|
956
|
+
def find_trace_with_id(
|
957
|
+
cls,
|
958
|
+
run_id: str,
|
959
|
+
force_raise: bool = True,
|
960
|
+
*,
|
961
|
+
path: Optional[Path] = None,
|
962
|
+
extras: Optional[DictData] = None,
|
963
|
+
) -> TraceData:
|
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
|
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
|
+
)
|
996
|
+
|
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
|
+
)
|
1067
|
+
|
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="")
|
1073
|
+
|
1074
|
+
|
1075
|
+
class RestAPIHandler(BaseHandler): # pragma: no cov
|
1076
|
+
type: Literal["restapi"] = "restapi"
|
1077
|
+
service_type: Literal["datadog", "grafana", "cloudwatch", "generic"] = (
|
1078
|
+
"generic"
|
306
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
|
307
1241
|
|
308
|
-
|
309
|
-
def writer(
|
1242
|
+
def emit(
|
310
1243
|
self,
|
311
|
-
|
312
|
-
|
313
|
-
|
1244
|
+
metadata: Metadata,
|
1245
|
+
*,
|
1246
|
+
extra: Optional[DictData] = None,
|
1247
|
+
): ...
|
1248
|
+
|
1249
|
+
async def amit(
|
1250
|
+
self,
|
1251
|
+
metadata: Metadata,
|
1252
|
+
*,
|
1253
|
+
extra: Optional[DictData] = None,
|
1254
|
+
) -> None: ...
|
1255
|
+
|
1256
|
+
def flush(
|
1257
|
+
self, metadata: list[Metadata], *, extra: Optional[DictData] = None
|
314
1258
|
) -> None:
|
315
|
-
|
316
|
-
|
317
|
-
|
318
|
-
|
319
|
-
|
320
|
-
:param level: (str) A log level.
|
321
|
-
:param is_err: (bool) A flag for writing with an error trace or not.
|
322
|
-
(Default be False)
|
323
|
-
"""
|
324
|
-
raise NotImplementedError(
|
325
|
-
"Create writer logic for this trace object before using."
|
326
|
-
)
|
1259
|
+
session = self.session()
|
1260
|
+
try:
|
1261
|
+
formatted_records = [
|
1262
|
+
self._format_for_service(meta) for meta in metadata
|
1263
|
+
]
|
327
1264
|
|
328
|
-
|
329
|
-
|
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
|
+
"""
|
1310
|
+
|
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
|
1318
|
+
|
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
|
1326
|
+
|
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
|
+
)
|
1343
|
+
|
1344
|
+
# Test connection
|
1345
|
+
if not client.ping():
|
1346
|
+
raise ConnectionError("Failed to connect to Elasticsearch")
|
1347
|
+
|
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
|
1355
|
+
|
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
|
+
}
|
1456
|
+
|
1457
|
+
def emit(
|
330
1458
|
self,
|
331
|
-
|
332
|
-
|
333
|
-
|
1459
|
+
metadata: Metadata,
|
1460
|
+
*,
|
1461
|
+
extra: Optional[DictData] = None,
|
1462
|
+
): ...
|
1463
|
+
|
1464
|
+
async def amit(
|
1465
|
+
self,
|
1466
|
+
metadata: Metadata,
|
1467
|
+
*,
|
1468
|
+
extra: Optional[DictData] = None,
|
1469
|
+
) -> None: ...
|
1470
|
+
|
1471
|
+
def flush(
|
1472
|
+
self,
|
1473
|
+
metadata: list[Metadata],
|
1474
|
+
*,
|
1475
|
+
extra: Optional[DictData] = None,
|
334
1476
|
) -> None:
|
335
|
-
|
1477
|
+
client = self.client()
|
1478
|
+
try:
|
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)
|
336
1491
|
|
337
|
-
|
338
|
-
|
339
|
-
:param is_err: (bool) A flag for writing with an error trace or not.
|
340
|
-
(Default be False)
|
341
|
-
"""
|
342
|
-
raise NotImplementedError(
|
343
|
-
"Create async writer logic for this trace object before using."
|
344
|
-
)
|
1492
|
+
# Execute bulk indexing
|
1493
|
+
response = client.bulk(body=bulk_data, refresh=True)
|
345
1494
|
|
346
|
-
|
347
|
-
|
348
|
-
|
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()
|
349
1504
|
|
350
|
-
|
1505
|
+
@classmethod
|
1506
|
+
def find_traces(
|
1507
|
+
cls,
|
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,
|
1512
|
+
extras: Optional[DictData] = None,
|
1513
|
+
) -> Iterator[TraceData]:
|
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
|
+
)
|
351
1525
|
|
352
|
-
|
353
|
-
|
354
|
-
|
355
|
-
|
356
|
-
|
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}")
|
1617
|
+
|
1618
|
+
@classmethod
|
1619
|
+
def find_trace_with_id(
|
1620
|
+
cls,
|
1621
|
+
run_id: str,
|
1622
|
+
force_raise: bool = True,
|
1623
|
+
*,
|
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,
|
1628
|
+
extras: Optional[DictData] = None,
|
1629
|
+
) -> TraceData:
|
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
|
+
|
1745
|
+
|
1746
|
+
class BaseEmit(ABC):
|
357
1747
|
|
358
1748
|
@abstractmethod
|
359
1749
|
def emit(
|
360
1750
|
self,
|
361
|
-
|
362
|
-
|
363
|
-
*,
|
364
|
-
is_err: bool = False,
|
1751
|
+
msg: str,
|
1752
|
+
level: Level,
|
365
1753
|
):
|
366
1754
|
"""Write trace log with append mode and logging this message with any
|
367
1755
|
logging level.
|
368
1756
|
|
369
|
-
:
|
370
|
-
|
371
|
-
|
1757
|
+
Args:
|
1758
|
+
msg: A message that want to log.
|
1759
|
+
level: A logging level.
|
372
1760
|
"""
|
373
1761
|
raise NotImplementedError(
|
374
1762
|
"Logging action should be implement for making trace log."
|
375
1763
|
)
|
376
1764
|
|
377
|
-
def debug(self,
|
1765
|
+
def debug(self, msg: str):
|
378
1766
|
"""Write trace log with append mode and logging this message with the
|
379
1767
|
DEBUG level.
|
380
1768
|
|
381
|
-
:
|
1769
|
+
Args:
|
1770
|
+
msg: A message that want to log.
|
382
1771
|
"""
|
383
|
-
self.emit(
|
1772
|
+
self.emit(msg, level="debug")
|
384
1773
|
|
385
|
-
def info(self,
|
1774
|
+
def info(self, msg: str) -> None:
|
386
1775
|
"""Write trace log with append mode and logging this message with the
|
387
1776
|
INFO level.
|
388
1777
|
|
389
|
-
:
|
1778
|
+
Args:
|
1779
|
+
msg: A message that want to log.
|
390
1780
|
"""
|
391
|
-
self.emit(
|
1781
|
+
self.emit(msg, level="info")
|
392
1782
|
|
393
|
-
def warning(self,
|
1783
|
+
def warning(self, msg: str) -> None:
|
394
1784
|
"""Write trace log with append mode and logging this message with the
|
395
1785
|
WARNING level.
|
396
1786
|
|
397
|
-
:
|
1787
|
+
Args:
|
1788
|
+
msg: A message that want to log.
|
398
1789
|
"""
|
399
|
-
self.emit(
|
1790
|
+
self.emit(msg, level="warning")
|
400
1791
|
|
401
|
-
def error(self,
|
1792
|
+
def error(self, msg: str) -> None:
|
402
1793
|
"""Write trace log with append mode and logging this message with the
|
403
1794
|
ERROR level.
|
404
1795
|
|
405
|
-
:
|
1796
|
+
Args:
|
1797
|
+
msg: A message that want to log.
|
406
1798
|
"""
|
407
|
-
self.emit(
|
1799
|
+
self.emit(msg, level="error")
|
408
1800
|
|
409
|
-
def exception(self,
|
1801
|
+
def exception(self, msg: str) -> None:
|
410
1802
|
"""Write trace log with append mode and logging this message with the
|
411
1803
|
EXCEPTION level.
|
412
1804
|
|
413
|
-
:
|
1805
|
+
Args:
|
1806
|
+
msg: A message that want to log.
|
414
1807
|
"""
|
415
|
-
self.emit(
|
1808
|
+
self.emit(msg, level="exception")
|
1809
|
+
|
1810
|
+
|
1811
|
+
class BaseAsyncEmit(ABC):
|
416
1812
|
|
417
1813
|
@abstractmethod
|
418
1814
|
async def amit(
|
419
1815
|
self,
|
420
|
-
|
421
|
-
|
422
|
-
*,
|
423
|
-
is_err: bool = False,
|
1816
|
+
msg: str,
|
1817
|
+
level: Level,
|
424
1818
|
) -> None:
|
425
1819
|
"""Async write trace log with append mode and logging this message with
|
426
1820
|
any logging level.
|
427
1821
|
|
428
|
-
:
|
429
|
-
|
430
|
-
|
1822
|
+
Args:
|
1823
|
+
msg (str): A message that want to log.
|
1824
|
+
level (Mode): A logging level.
|
431
1825
|
"""
|
432
1826
|
raise NotImplementedError(
|
433
1827
|
"Async Logging action should be implement for making trace log."
|
434
1828
|
)
|
435
1829
|
|
436
|
-
async def adebug(self,
|
1830
|
+
async def adebug(self, msg: str) -> None: # pragma: no cov
|
437
1831
|
"""Async write trace log with append mode and logging this message with
|
438
1832
|
the DEBUG level.
|
439
1833
|
|
440
|
-
:
|
1834
|
+
Args:
|
1835
|
+
msg: A message that want to log.
|
441
1836
|
"""
|
442
|
-
await self.amit(
|
1837
|
+
await self.amit(msg, level="debug")
|
443
1838
|
|
444
|
-
async def ainfo(self,
|
1839
|
+
async def ainfo(self, msg: str) -> None: # pragma: no cov
|
445
1840
|
"""Async write trace log with append mode and logging this message with
|
446
1841
|
the INFO level.
|
447
1842
|
|
448
|
-
:
|
1843
|
+
Args:
|
1844
|
+
msg: A message that want to log.
|
449
1845
|
"""
|
450
|
-
await self.amit(
|
1846
|
+
await self.amit(msg, level="info")
|
451
1847
|
|
452
|
-
async def awarning(self,
|
1848
|
+
async def awarning(self, msg: str) -> None: # pragma: no cov
|
453
1849
|
"""Async write trace log with append mode and logging this message with
|
454
1850
|
the WARNING level.
|
455
1851
|
|
456
|
-
:
|
1852
|
+
Args:
|
1853
|
+
msg: A message that want to log.
|
457
1854
|
"""
|
458
|
-
await self.amit(
|
1855
|
+
await self.amit(msg, level="warning")
|
459
1856
|
|
460
|
-
async def aerror(self,
|
1857
|
+
async def aerror(self, msg: str) -> None: # pragma: no cov
|
461
1858
|
"""Async write trace log with append mode and logging this message with
|
462
1859
|
the ERROR level.
|
463
1860
|
|
464
|
-
:
|
1861
|
+
Args:
|
1862
|
+
msg: A message that want to log.
|
465
1863
|
"""
|
466
|
-
await self.amit(
|
1864
|
+
await self.amit(msg, level="error")
|
467
1865
|
|
468
|
-
async def aexception(self,
|
1866
|
+
async def aexception(self, msg: str) -> None: # pragma: no cov
|
469
1867
|
"""Async write trace log with append mode and logging this message with
|
470
1868
|
the EXCEPTION level.
|
471
1869
|
|
472
|
-
:
|
1870
|
+
Args:
|
1871
|
+
msg: A message that want to log.
|
473
1872
|
"""
|
474
|
-
await self.amit(
|
475
|
-
|
1873
|
+
await self.amit(msg, level="exception")
|
476
1874
|
|
477
|
-
class ConsoleTrace(BaseEmitTrace): # pragma: no cov
|
478
|
-
"""Console Trace log model."""
|
479
1875
|
|
480
|
-
|
481
|
-
|
482
|
-
message: str,
|
483
|
-
level: str,
|
484
|
-
is_err: bool = False,
|
485
|
-
) -> None:
|
486
|
-
"""Write a trace message after making to target pointer object. The
|
487
|
-
target can be anything be inherited this class and overwrite this method
|
488
|
-
such as file, console, or database.
|
489
|
-
|
490
|
-
:param message: (str) A message after making.
|
491
|
-
:param level: (str) A log level.
|
492
|
-
:param is_err: (bool) A flag for writing with an error trace or not.
|
493
|
-
(Default be False)
|
494
|
-
"""
|
1876
|
+
class TraceManager(BaseModel, BaseEmit, BaseAsyncEmit):
|
1877
|
+
"""Trace Management that keep all trance handler."""
|
495
1878
|
|
496
|
-
|
497
|
-
|
498
|
-
|
499
|
-
|
500
|
-
|
501
|
-
|
502
|
-
|
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
|
+
)
|
503
1898
|
|
504
|
-
|
505
|
-
|
506
|
-
|
507
|
-
(Default be False)
|
508
|
-
"""
|
1899
|
+
# NOTE: Private attrs for the internal process.
|
1900
|
+
_enable_buffer: bool = PrivateAttr(default=False)
|
1901
|
+
_buffer: list[Metadata] = PrivateAttr(default_factory=list)
|
509
1902
|
|
510
1903
|
@property
|
511
1904
|
def cut_id(self) -> str:
|
512
1905
|
"""Combine cutting ID of parent running ID if it set.
|
513
1906
|
|
514
|
-
:
|
1907
|
+
Returns:
|
1908
|
+
str: The combined cutting ID string.
|
515
1909
|
"""
|
516
1910
|
cut_run_id: str = cut_id(self.run_id)
|
517
1911
|
if not self.parent_run_id:
|
518
|
-
return
|
1912
|
+
return cut_run_id
|
519
1913
|
|
520
1914
|
cut_parent_run_id: str = cut_id(self.parent_run_id)
|
521
1915
|
return f"{cut_parent_run_id} -> {cut_run_id}"
|
522
1916
|
|
523
|
-
def make_message(self,
|
1917
|
+
def make_message(self, msg: str) -> str:
|
524
1918
|
"""Prepare and Make a message before write and log steps.
|
525
1919
|
|
526
|
-
:param message: (str) A message that want to prepare and make before.
|
527
|
-
|
528
|
-
:rtype: str
|
529
|
-
"""
|
530
|
-
return prepare_newline(Message.from_str(message).prepare(self.extras))
|
531
|
-
|
532
|
-
def emit(self, message: str, mode: str, *, is_err: bool = False) -> None:
|
533
|
-
"""Write trace log with append mode and logging this message with any
|
534
|
-
logging level.
|
535
|
-
|
536
|
-
:param message: (str) A message that want to log.
|
537
|
-
:param mode: (str)
|
538
|
-
:param is_err: (bool)
|
539
|
-
"""
|
540
|
-
msg: str = self.make_message(message)
|
541
|
-
|
542
|
-
if mode != "debug" or (
|
543
|
-
mode == "debug" and dynamic("debug", extras=self.extras)
|
544
|
-
):
|
545
|
-
self.writer(msg, level=mode, is_err=is_err)
|
546
|
-
|
547
|
-
getattr(logger, mode)(msg, stacklevel=3, extra={"cut_id": self.cut_id})
|
548
|
-
|
549
|
-
async def amit(
|
550
|
-
self, message: str, mode: str, *, is_err: bool = False
|
551
|
-
) -> None:
|
552
|
-
"""Write trace log with append mode and logging this message with any
|
553
|
-
logging level.
|
554
|
-
|
555
|
-
:param message: (str) A message that want to log.
|
556
|
-
:param mode: (str)
|
557
|
-
:param is_err: (bool)
|
558
|
-
"""
|
559
|
-
msg: str = self.make_message(message)
|
560
|
-
|
561
|
-
if mode != "debug" or (
|
562
|
-
mode == "debug" and dynamic("debug", extras=self.extras)
|
563
|
-
):
|
564
|
-
await self.awriter(msg, level=mode, is_err=is_err)
|
565
|
-
|
566
|
-
getattr(logger, mode)(msg, stacklevel=3, extra={"cut_id": self.cut_id})
|
567
|
-
|
568
|
-
|
569
|
-
class BaseTrace(ConsoleTrace, ABC):
|
570
|
-
"""A Base Trace model that will use for override writing or sending trace
|
571
|
-
log to any service type.
|
572
|
-
"""
|
573
|
-
|
574
|
-
model_config = ConfigDict(arbitrary_types_allowed=True)
|
575
|
-
|
576
|
-
url: ParseResult = Field(description="An URL for create pointer.")
|
577
|
-
|
578
|
-
@field_validator(
|
579
|
-
"url", mode="before", json_schema_input_type=Union[ParseResult, str]
|
580
|
-
)
|
581
|
-
def __parse_url(cls, value: Union[ParseResult, str]) -> ParseResult:
|
582
|
-
"""Parsing an URL value."""
|
583
|
-
return urlparse(value) if isinstance(value, str) else value
|
584
|
-
|
585
|
-
@field_serializer("url")
|
586
|
-
def __serialize_url(self, value: ParseResult) -> str:
|
587
|
-
return value.geturl()
|
588
|
-
|
589
|
-
@classmethod
|
590
|
-
@abstractmethod
|
591
|
-
def find_traces(
|
592
|
-
cls,
|
593
|
-
path: Optional[Path] = None,
|
594
|
-
extras: Optional[DictData] = None,
|
595
|
-
) -> Iterator[TraceData]: # pragma: no cov
|
596
|
-
"""Return iterator of TraceData models from the target pointer.
|
597
|
-
|
598
1920
|
Args:
|
599
|
-
|
600
|
-
extras (:obj:`DictData`, optional): An extras parameter that want to
|
601
|
-
override default engine config.
|
1921
|
+
msg: A message that want to prepare and make before.
|
602
1922
|
|
603
1923
|
Returns:
|
604
|
-
|
605
|
-
model.
|
606
|
-
"""
|
607
|
-
raise NotImplementedError(
|
608
|
-
"Trace dataclass should implement `find_traces` class-method."
|
609
|
-
)
|
610
|
-
|
611
|
-
@classmethod
|
612
|
-
@abstractmethod
|
613
|
-
def find_trace_with_id(
|
614
|
-
cls,
|
615
|
-
run_id: str,
|
616
|
-
force_raise: bool = True,
|
617
|
-
*,
|
618
|
-
path: Optional[Path] = None,
|
619
|
-
extras: Optional[DictData] = None,
|
620
|
-
) -> TraceData:
|
621
|
-
raise NotImplementedError(
|
622
|
-
"Trace dataclass should implement `find_trace_with_id` "
|
623
|
-
"class-method."
|
624
|
-
)
|
625
|
-
|
626
|
-
|
627
|
-
class FileTrace(BaseTrace): # pragma: no cov
|
628
|
-
"""File Trace dataclass that write file to the local storage."""
|
629
|
-
|
630
|
-
@classmethod
|
631
|
-
def find_traces(
|
632
|
-
cls,
|
633
|
-
path: Optional[Path] = None,
|
634
|
-
extras: Optional[DictData] = None,
|
635
|
-
) -> Iterator[TraceData]: # pragma: no cov
|
636
|
-
"""Find trace logs.
|
637
|
-
|
638
|
-
:param path: (Path) A trace path that want to find.
|
639
|
-
:param extras: An extra parameter that want to override core config.
|
640
|
-
"""
|
641
|
-
for file in sorted(
|
642
|
-
(path or Path(dynamic("trace_url", extras=extras).path)).glob(
|
643
|
-
"./run_id=*"
|
644
|
-
),
|
645
|
-
key=lambda f: f.lstat().st_mtime,
|
646
|
-
):
|
647
|
-
yield TraceData.from_path(file)
|
648
|
-
|
649
|
-
@classmethod
|
650
|
-
def find_trace_with_id(
|
651
|
-
cls,
|
652
|
-
run_id: str,
|
653
|
-
*,
|
654
|
-
force_raise: bool = True,
|
655
|
-
path: Optional[Path] = None,
|
656
|
-
extras: Optional[DictData] = None,
|
657
|
-
) -> TraceData:
|
658
|
-
"""Find trace log with an input specific run ID.
|
659
|
-
|
660
|
-
:param run_id: A running ID of trace log.
|
661
|
-
:param force_raise: (bool)
|
662
|
-
:param path: (Path)
|
663
|
-
:param extras: An extra parameter that want to override core config.
|
664
|
-
"""
|
665
|
-
base_path: Path = path or Path(dynamic("trace_url", extras=extras).path)
|
666
|
-
file: Path = base_path / f"run_id={run_id}"
|
667
|
-
if file.exists():
|
668
|
-
return TraceData.from_path(file)
|
669
|
-
elif force_raise:
|
670
|
-
raise FileNotFoundError(
|
671
|
-
f"Trace log on path {base_path}, does not found trace "
|
672
|
-
f"'run_id={run_id}'."
|
673
|
-
)
|
674
|
-
return TraceData(stdout="", stderr="")
|
675
|
-
|
676
|
-
@property
|
677
|
-
def pointer(self) -> Path:
|
678
|
-
"""Pointer of the target path that use to writing trace log or searching
|
679
|
-
trace log.
|
680
|
-
|
681
|
-
This running ID folder that use to keeping trace log data will use
|
682
|
-
a parent running ID first. If it does not set, it will use running ID
|
683
|
-
instead.
|
684
|
-
|
685
|
-
:rtype: Path
|
1924
|
+
str: The prepared message.
|
686
1925
|
"""
|
687
|
-
|
688
|
-
Path(unquote_plus(self.url.path))
|
689
|
-
/ f"run_id={self.parent_run_id or self.run_id}"
|
690
|
-
)
|
691
|
-
if not log_file.exists():
|
692
|
-
log_file.mkdir(parents=True)
|
693
|
-
return log_file
|
1926
|
+
return prepare_newline(Message.from_str(msg).prepare(self.extras))
|
694
1927
|
|
695
|
-
def
|
696
|
-
|
697
|
-
message: str,
|
698
|
-
level: str,
|
699
|
-
is_err: bool = False,
|
700
|
-
) -> None:
|
701
|
-
"""Write a trace message after making to target file and write metadata
|
702
|
-
in the same path of standard files.
|
703
|
-
|
704
|
-
The path of logging data will store by format:
|
1928
|
+
def emit(self, msg: str, level: Level) -> None:
|
1929
|
+
"""Emit a trace log to all handler. This will use synchronise process.
|
705
1930
|
|
706
|
-
|
707
|
-
|
708
|
-
|
709
|
-
|
710
|
-
:param message: (str) A message after making.
|
711
|
-
:param level: (str) A log level.
|
712
|
-
:param is_err: A flag for writing with an error trace or not.
|
1931
|
+
Args:
|
1932
|
+
msg: A message.
|
1933
|
+
level: A tracing level.
|
713
1934
|
"""
|
714
|
-
|
715
|
-
|
716
|
-
|
717
|
-
mode: Literal["stdout", "stderr"] = "stderr" if is_err else "stdout"
|
718
|
-
trace_meta: TraceMeta = TraceMeta.make(
|
719
|
-
mode=mode,
|
1935
|
+
_msg: str = self.make_message(msg)
|
1936
|
+
metadata: Metadata = Metadata.make(
|
1937
|
+
error_flag=(level in ("error", "exception")),
|
720
1938
|
level=level,
|
721
|
-
message=
|
1939
|
+
message=_msg,
|
722
1940
|
cutting_id=self.cut_id,
|
1941
|
+
run_id=self.run_id,
|
1942
|
+
parent_run_id=self.parent_run_id,
|
723
1943
|
extras=self.extras,
|
724
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.
|
725
1959
|
|
726
|
-
|
727
|
-
|
728
|
-
|
729
|
-
|
730
|
-
|
731
|
-
|
732
|
-
|
733
|
-
f.write(trace_meta.model_dump_json() + "\n")
|
734
|
-
|
735
|
-
async def awriter(
|
736
|
-
self,
|
737
|
-
message: str,
|
738
|
-
level: str,
|
739
|
-
is_err: bool = False,
|
740
|
-
) -> None: # pragma: no cov
|
741
|
-
"""Write with async mode."""
|
742
|
-
if not dynamic("enable_write_log", extras=self.extras):
|
743
|
-
return
|
744
|
-
|
745
|
-
try:
|
746
|
-
import aiofiles
|
747
|
-
except ImportError as e:
|
748
|
-
raise ImportError("Async mode need aiofiles package") from e
|
749
|
-
|
750
|
-
mode: Literal["stdout", "stderr"] = "stderr" if is_err else "stdout"
|
751
|
-
trace_meta: TraceMeta = TraceMeta.make(
|
752
|
-
mode=mode,
|
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")),
|
753
1967
|
level=level,
|
754
|
-
message=
|
1968
|
+
message=_msg,
|
755
1969
|
cutting_id=self.cut_id,
|
1970
|
+
run_id=self.run_id,
|
1971
|
+
parent_run_id=self.parent_run_id,
|
756
1972
|
extras=self.extras,
|
757
1973
|
)
|
1974
|
+
for handler in self.handlers:
|
1975
|
+
await handler.amit(metadata, extra=self.extras)
|
758
1976
|
|
759
|
-
|
760
|
-
|
761
|
-
) as f:
|
762
|
-
fmt: str = dynamic("log_format_file", extras=self.extras)
|
763
|
-
await f.write(f"{fmt}\n".format(**trace_meta.model_dump()))
|
764
|
-
|
765
|
-
async with aiofiles.open(
|
766
|
-
self.pointer / METADATA, mode="at", encoding="utf-8"
|
767
|
-
) as f:
|
768
|
-
await f.write(trace_meta.model_dump_json() + "\n")
|
769
|
-
|
770
|
-
|
771
|
-
class SQLiteTrace(BaseTrace): # pragma: no cov
|
772
|
-
"""SQLite Trace dataclass that write trace log to the SQLite database file."""
|
773
|
-
|
774
|
-
table_name: ClassVar[str] = "audits"
|
775
|
-
schemas: ClassVar[
|
776
|
-
str
|
777
|
-
] = """
|
778
|
-
run_id str
|
779
|
-
, parent_run_id str
|
780
|
-
, type str
|
781
|
-
, text str
|
782
|
-
, metadata JSON
|
783
|
-
, created_at datetime
|
784
|
-
, updated_at datetime
|
785
|
-
primary key ( parent_run_id )
|
786
|
-
"""
|
787
|
-
|
788
|
-
@classmethod
|
789
|
-
def find_traces(
|
790
|
-
cls,
|
791
|
-
path: Optional[Path] = None,
|
792
|
-
extras: Optional[DictData] = None,
|
793
|
-
) -> Iterator[TraceData]:
|
794
|
-
raise NotImplementedError("SQLiteTrace does not implement yet.")
|
795
|
-
|
796
|
-
@classmethod
|
797
|
-
def find_trace_with_id(
|
798
|
-
cls,
|
799
|
-
run_id: str,
|
800
|
-
force_raise: bool = True,
|
801
|
-
*,
|
802
|
-
path: Optional[Path] = None,
|
803
|
-
extras: Optional[DictData] = None,
|
804
|
-
) -> TraceData:
|
805
|
-
raise NotImplementedError("SQLiteTrace does not implement yet.")
|
806
|
-
|
807
|
-
def make_message(self, message: str) -> str:
|
808
|
-
raise NotImplementedError("SQLiteTrace does not implement yet.")
|
809
|
-
|
810
|
-
def writer(
|
811
|
-
self,
|
812
|
-
message: str,
|
813
|
-
level: str,
|
814
|
-
is_err: bool = False,
|
815
|
-
) -> None:
|
816
|
-
raise NotImplementedError("SQLiteTrace does not implement yet.")
|
817
|
-
|
818
|
-
def awriter(
|
819
|
-
self,
|
820
|
-
message: str,
|
821
|
-
level: str,
|
822
|
-
is_err: bool = False,
|
823
|
-
) -> None:
|
824
|
-
raise NotImplementedError("SQLiteTrace does not implement yet.")
|
825
|
-
|
1977
|
+
def __enter__(self): # pragma: no cov
|
1978
|
+
self._enable_buffer = True
|
826
1979
|
|
827
|
-
|
828
|
-
|
829
|
-
|
830
|
-
|
831
|
-
|
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()
|
832
1985
|
|
833
1986
|
|
834
1987
|
def get_trace(
|
@@ -836,40 +1989,26 @@ def get_trace(
|
|
836
1989
|
*,
|
837
1990
|
parent_run_id: Optional[str] = None,
|
838
1991
|
extras: Optional[DictData] = None,
|
839
|
-
) ->
|
840
|
-
"""Get dynamic
|
841
|
-
extras argument) that passing running ID and parent running ID.
|
1992
|
+
) -> TraceManager: # pragma: no cov
|
1993
|
+
"""Get dynamic TraceManager instance from the core config.
|
842
1994
|
|
843
|
-
|
844
|
-
|
845
|
-
|
846
|
-
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.
|
847
1998
|
|
848
|
-
:
|
849
|
-
|
850
|
-
|
851
|
-
|
852
|
-
|
853
|
-
)
|
854
|
-
url: ParseResult
|
855
|
-
if (url := dynamic("trace_url", extras=extras)).scheme and (
|
856
|
-
url.scheme == "sqlite"
|
857
|
-
or (url.scheme == "file" and Path(url.path).is_file())
|
858
|
-
):
|
859
|
-
return map_trace_models.get("sqlite", SQLiteTrace)(
|
860
|
-
url=url,
|
861
|
-
run_id=run_id,
|
862
|
-
parent_run_id=parent_run_id,
|
863
|
-
extras=(extras or {}),
|
864
|
-
)
|
865
|
-
elif url.scheme and url.scheme != "file":
|
866
|
-
raise NotImplementedError(
|
867
|
-
f"Does not implement the outside trace model support for URL: {url}"
|
868
|
-
)
|
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.
|
869
2004
|
|
870
|
-
|
871
|
-
|
2005
|
+
Returns:
|
2006
|
+
TraceManager: The appropriate trace instance.
|
2007
|
+
"""
|
2008
|
+
handlers = dynamic("trace_handlers", extras=extras)
|
2009
|
+
return TraceManager(
|
872
2010
|
run_id=run_id,
|
873
2011
|
parent_run_id=parent_run_id,
|
874
|
-
|
2012
|
+
handlers=handlers,
|
2013
|
+
extras=extras or {},
|
875
2014
|
)
|