kailash 0.4.0__py3-none-any.whl → 0.4.1__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.
- kailash/__init__.py +4 -4
- kailash/nodes/__init__.py +2 -0
- kailash/nodes/admin/__init__.py +9 -2
- kailash/nodes/admin/audit_log.py +1 -1
- kailash/nodes/admin/security_event.py +7 -3
- kailash/nodes/ai/ai_providers.py +247 -40
- kailash/nodes/ai/llm_agent.py +29 -3
- kailash/nodes/ai/vision_utils.py +148 -0
- kailash/nodes/alerts/__init__.py +26 -0
- kailash/nodes/alerts/base.py +234 -0
- kailash/nodes/alerts/discord.py +499 -0
- kailash/nodes/data/streaming.py +8 -8
- kailash/nodes/security/audit_log.py +48 -36
- kailash/nodes/security/security_event.py +73 -72
- kailash/security.py +1 -1
- {kailash-0.4.0.dist-info → kailash-0.4.1.dist-info}/METADATA +4 -1
- {kailash-0.4.0.dist-info → kailash-0.4.1.dist-info}/RECORD +21 -17
- {kailash-0.4.0.dist-info → kailash-0.4.1.dist-info}/WHEEL +0 -0
- {kailash-0.4.0.dist-info → kailash-0.4.1.dist-info}/entry_points.txt +0 -0
- {kailash-0.4.0.dist-info → kailash-0.4.1.dist-info}/licenses/LICENSE +0 -0
- {kailash-0.4.0.dist-info → kailash-0.4.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,499 @@
|
|
1
|
+
"""Discord alert node for the Kailash SDK.
|
2
|
+
|
3
|
+
This module implements Discord webhook integration for sending alerts and notifications
|
4
|
+
through Discord channels. It supports both simple text messages and rich embeds with
|
5
|
+
full customization options.
|
6
|
+
|
7
|
+
The Discord node provides:
|
8
|
+
- Webhook-based messaging (no bot token required)
|
9
|
+
- Rich embed support with colors and fields
|
10
|
+
- User and role mentions
|
11
|
+
- Thread support
|
12
|
+
- Rate limiting compliance
|
13
|
+
- Retry logic for failed requests
|
14
|
+
"""
|
15
|
+
|
16
|
+
import os
|
17
|
+
import re
|
18
|
+
import time
|
19
|
+
from collections import deque
|
20
|
+
from datetime import UTC, datetime
|
21
|
+
from typing import Any, Optional
|
22
|
+
|
23
|
+
from kailash.nodes.alerts.base import AlertNode, AlertSeverity
|
24
|
+
from kailash.nodes.api.http import HTTPRequestNode
|
25
|
+
from kailash.nodes.base import NodeParameter, register_node
|
26
|
+
from kailash.sdk_exceptions import NodeExecutionError
|
27
|
+
|
28
|
+
|
29
|
+
class DiscordRateLimiter:
|
30
|
+
"""Simple rate limiter for Discord webhooks.
|
31
|
+
|
32
|
+
Discord limits webhooks to 30 requests per minute per webhook.
|
33
|
+
This implements a sliding window rate limiter.
|
34
|
+
"""
|
35
|
+
|
36
|
+
def __init__(self, max_requests: int = 30, window_seconds: int = 60):
|
37
|
+
self.max_requests = max_requests
|
38
|
+
self.window_seconds = window_seconds
|
39
|
+
self.requests = deque()
|
40
|
+
|
41
|
+
def acquire(self) -> float:
|
42
|
+
"""Wait if necessary to comply with rate limits.
|
43
|
+
|
44
|
+
Returns:
|
45
|
+
Seconds waited (0 if no wait was needed)
|
46
|
+
"""
|
47
|
+
now = time.time()
|
48
|
+
|
49
|
+
# Remove old requests outside the window
|
50
|
+
while self.requests and self.requests[0] < now - self.window_seconds:
|
51
|
+
self.requests.popleft()
|
52
|
+
|
53
|
+
# Check if we need to wait
|
54
|
+
if len(self.requests) >= self.max_requests:
|
55
|
+
# Calculate how long to wait
|
56
|
+
oldest_request = self.requests[0]
|
57
|
+
wait_time = (
|
58
|
+
(oldest_request + self.window_seconds) - now + 0.1
|
59
|
+
) # Small buffer
|
60
|
+
if wait_time > 0:
|
61
|
+
time.sleep(wait_time)
|
62
|
+
return wait_time
|
63
|
+
|
64
|
+
# Record this request
|
65
|
+
self.requests.append(now)
|
66
|
+
return 0.0
|
67
|
+
|
68
|
+
|
69
|
+
@register_node()
|
70
|
+
class DiscordAlertNode(AlertNode):
|
71
|
+
"""
|
72
|
+
Node for sending alerts to Discord channels via webhooks.
|
73
|
+
|
74
|
+
This node provides a streamlined interface for sending notifications to Discord
|
75
|
+
using webhook URLs. It supports Discord's rich embed format, allowing for
|
76
|
+
visually appealing alerts with colors, fields, timestamps, and more.
|
77
|
+
|
78
|
+
Design Philosophy:
|
79
|
+
The DiscordAlertNode abstracts Discord's webhook API complexity while
|
80
|
+
providing access to advanced features when needed. It handles rate limiting,
|
81
|
+
retries, and formatting automatically, allowing users to focus on their
|
82
|
+
alert content rather than Discord API specifics.
|
83
|
+
|
84
|
+
Features:
|
85
|
+
- Simple text messages or rich embeds
|
86
|
+
- Automatic color coding based on alert severity
|
87
|
+
- User/role/channel mentions with proper escaping
|
88
|
+
- Thread support for organized discussions
|
89
|
+
- Environment variable support for webhook URLs
|
90
|
+
- Built-in rate limiting (30 requests/minute)
|
91
|
+
- Retry logic with exponential backoff
|
92
|
+
- Context data formatting as embed fields
|
93
|
+
|
94
|
+
Webhook URL Security:
|
95
|
+
Webhook URLs should be treated as secrets. This node supports:
|
96
|
+
- Environment variable substitution (e.g., ${DISCORD_WEBHOOK})
|
97
|
+
- Direct URL input (use with caution in production)
|
98
|
+
- Configuration-based URLs via workflow parameters
|
99
|
+
|
100
|
+
Examples:
|
101
|
+
>>> # Simple text alert
|
102
|
+
>>> node = DiscordAlertNode()
|
103
|
+
>>> result = node.run(
|
104
|
+
... webhook_url="${DISCORD_ALERTS_WEBHOOK}",
|
105
|
+
... title="Deployment Complete",
|
106
|
+
... message="Version 1.2.3 deployed successfully",
|
107
|
+
... alert_type="success"
|
108
|
+
... )
|
109
|
+
>>>
|
110
|
+
>>> # Rich embed with context
|
111
|
+
>>> result = node.run(
|
112
|
+
... webhook_url=webhook_url,
|
113
|
+
... title="Error in Data Pipeline",
|
114
|
+
... message="Failed to process customer data",
|
115
|
+
... alert_type="error",
|
116
|
+
... context={
|
117
|
+
... "Pipeline": "CustomerETL",
|
118
|
+
... "Stage": "Transform",
|
119
|
+
... "Error": "Invalid date format",
|
120
|
+
... "Affected Records": 42
|
121
|
+
... },
|
122
|
+
... embed=True
|
123
|
+
... )
|
124
|
+
>>>
|
125
|
+
>>> # Mention users and post to thread
|
126
|
+
>>> result = node.run(
|
127
|
+
... webhook_url=webhook_url,
|
128
|
+
... title="Critical: Database Connection Lost",
|
129
|
+
... alert_type="critical",
|
130
|
+
... mentions=["@everyone"],
|
131
|
+
... thread_id="1234567890",
|
132
|
+
... embed=True
|
133
|
+
... )
|
134
|
+
"""
|
135
|
+
|
136
|
+
def __init__(self, **kwargs):
|
137
|
+
"""Initialize the Discord alert node.
|
138
|
+
|
139
|
+
Args:
|
140
|
+
**kwargs: Node configuration parameters
|
141
|
+
"""
|
142
|
+
super().__init__(**kwargs)
|
143
|
+
self.http_client = HTTPRequestNode(id=f"{self.id}_http")
|
144
|
+
self.rate_limiter = DiscordRateLimiter()
|
145
|
+
|
146
|
+
def get_channel_parameters(self) -> dict[str, NodeParameter]:
|
147
|
+
"""Define Discord-specific parameters.
|
148
|
+
|
149
|
+
Returns:
|
150
|
+
Dictionary of Discord webhook parameters
|
151
|
+
"""
|
152
|
+
return {
|
153
|
+
"webhook_url": NodeParameter(
|
154
|
+
name="webhook_url",
|
155
|
+
type=str,
|
156
|
+
required=True,
|
157
|
+
description="Discord webhook URL (supports ${ENV_VAR} substitution)",
|
158
|
+
),
|
159
|
+
"username": NodeParameter(
|
160
|
+
name="username",
|
161
|
+
type=str,
|
162
|
+
required=False,
|
163
|
+
default=None,
|
164
|
+
description="Override webhook bot username",
|
165
|
+
),
|
166
|
+
"avatar_url": NodeParameter(
|
167
|
+
name="avatar_url",
|
168
|
+
type=str,
|
169
|
+
required=False,
|
170
|
+
default=None,
|
171
|
+
description="Override webhook bot avatar URL",
|
172
|
+
),
|
173
|
+
"embed": NodeParameter(
|
174
|
+
name="embed",
|
175
|
+
type=bool,
|
176
|
+
required=False,
|
177
|
+
default=True,
|
178
|
+
description="Send as rich embed (True) or plain text (False)",
|
179
|
+
),
|
180
|
+
"color": NodeParameter(
|
181
|
+
name="color",
|
182
|
+
type=int,
|
183
|
+
required=False,
|
184
|
+
default=None,
|
185
|
+
description="Override embed color (decimal color value)",
|
186
|
+
),
|
187
|
+
"fields": NodeParameter(
|
188
|
+
name="fields",
|
189
|
+
type=list,
|
190
|
+
required=False,
|
191
|
+
default=[],
|
192
|
+
description="Additional embed fields as list of {name, value, inline} dicts",
|
193
|
+
),
|
194
|
+
"mentions": NodeParameter(
|
195
|
+
name="mentions",
|
196
|
+
type=list,
|
197
|
+
required=False,
|
198
|
+
default=[],
|
199
|
+
description="List of mentions (@everyone, @here, user/role IDs)",
|
200
|
+
),
|
201
|
+
"thread_id": NodeParameter(
|
202
|
+
name="thread_id",
|
203
|
+
type=str,
|
204
|
+
required=False,
|
205
|
+
default=None,
|
206
|
+
description="Thread ID to post message in",
|
207
|
+
),
|
208
|
+
"footer_text": NodeParameter(
|
209
|
+
name="footer_text",
|
210
|
+
type=str,
|
211
|
+
required=False,
|
212
|
+
default=None,
|
213
|
+
description="Footer text for embeds",
|
214
|
+
),
|
215
|
+
"timestamp": NodeParameter(
|
216
|
+
name="timestamp",
|
217
|
+
type=bool,
|
218
|
+
required=False,
|
219
|
+
default=True,
|
220
|
+
description="Include timestamp in embed",
|
221
|
+
),
|
222
|
+
}
|
223
|
+
|
224
|
+
def resolve_webhook_url(self, webhook_url: str) -> str:
|
225
|
+
"""Resolve webhook URL, supporting environment variable substitution.
|
226
|
+
|
227
|
+
Args:
|
228
|
+
webhook_url: Raw webhook URL or environment variable reference
|
229
|
+
|
230
|
+
Returns:
|
231
|
+
Resolved webhook URL
|
232
|
+
|
233
|
+
Raises:
|
234
|
+
ValueError: If environment variable is not set
|
235
|
+
"""
|
236
|
+
# Check for environment variable pattern ${VAR_NAME}
|
237
|
+
env_pattern = r"\$\{([^}]+)\}"
|
238
|
+
match = re.match(env_pattern, webhook_url.strip())
|
239
|
+
|
240
|
+
if match:
|
241
|
+
var_name = match.group(1)
|
242
|
+
resolved_url = os.environ.get(var_name)
|
243
|
+
if not resolved_url:
|
244
|
+
raise ValueError(
|
245
|
+
f"Environment variable '{var_name}' is not set. "
|
246
|
+
f"Please set it or provide the webhook URL directly."
|
247
|
+
)
|
248
|
+
return resolved_url
|
249
|
+
|
250
|
+
return webhook_url
|
251
|
+
|
252
|
+
def format_mentions(self, mentions: list[str]) -> str:
|
253
|
+
"""Format mention list into Discord mention string.
|
254
|
+
|
255
|
+
Args:
|
256
|
+
mentions: List of mention strings
|
257
|
+
|
258
|
+
Returns:
|
259
|
+
Formatted mention string
|
260
|
+
"""
|
261
|
+
if not mentions:
|
262
|
+
return ""
|
263
|
+
|
264
|
+
formatted_mentions = []
|
265
|
+
for mention in mentions:
|
266
|
+
# Handle special mentions
|
267
|
+
if mention in ["@everyone", "@here"]:
|
268
|
+
formatted_mentions.append(mention)
|
269
|
+
# Handle user mentions (numeric IDs)
|
270
|
+
elif mention.isdigit():
|
271
|
+
formatted_mentions.append(f"<@{mention}>")
|
272
|
+
# Handle role mentions (numeric IDs with & prefix)
|
273
|
+
elif mention.startswith("&") and mention[1:].isdigit():
|
274
|
+
formatted_mentions.append(f"<@{mention}>")
|
275
|
+
# Pass through already formatted mentions
|
276
|
+
elif mention.startswith("<@") and mention.endswith(">"):
|
277
|
+
formatted_mentions.append(mention)
|
278
|
+
else:
|
279
|
+
# Assume it's a user ID
|
280
|
+
formatted_mentions.append(f"<@{mention}>")
|
281
|
+
|
282
|
+
return " ".join(formatted_mentions) + " "
|
283
|
+
|
284
|
+
def build_embed(
|
285
|
+
self,
|
286
|
+
severity: AlertSeverity,
|
287
|
+
title: str,
|
288
|
+
message: str,
|
289
|
+
context: dict[str, Any],
|
290
|
+
color: Optional[int],
|
291
|
+
fields: list[dict[str, Any]],
|
292
|
+
footer_text: Optional[str],
|
293
|
+
timestamp: bool,
|
294
|
+
) -> dict[str, Any]:
|
295
|
+
"""Build a Discord embed object.
|
296
|
+
|
297
|
+
Args:
|
298
|
+
severity: Alert severity for color
|
299
|
+
title: Embed title
|
300
|
+
message: Embed description
|
301
|
+
context: Context data to add as fields
|
302
|
+
color: Override color
|
303
|
+
fields: Additional fields
|
304
|
+
footer_text: Footer text
|
305
|
+
timestamp: Whether to include timestamp
|
306
|
+
|
307
|
+
Returns:
|
308
|
+
Discord embed object
|
309
|
+
"""
|
310
|
+
embed = {
|
311
|
+
"title": title,
|
312
|
+
"color": color if color is not None else severity.get_color(),
|
313
|
+
}
|
314
|
+
|
315
|
+
if message:
|
316
|
+
embed["description"] = message
|
317
|
+
|
318
|
+
# Add context as fields
|
319
|
+
embed_fields = []
|
320
|
+
if context:
|
321
|
+
for key, value in context.items():
|
322
|
+
# Convert value to string, truncate if needed
|
323
|
+
value_str = str(value)
|
324
|
+
if len(value_str) > 1024: # Discord field value limit
|
325
|
+
value_str = value_str[:1021] + "..."
|
326
|
+
|
327
|
+
embed_fields.append({"name": key, "value": value_str, "inline": True})
|
328
|
+
|
329
|
+
# Add custom fields
|
330
|
+
for field in fields:
|
331
|
+
if isinstance(field, dict) and "name" in field and "value" in field:
|
332
|
+
embed_fields.append(
|
333
|
+
{
|
334
|
+
"name": str(field["name"]),
|
335
|
+
"value": str(field["value"]),
|
336
|
+
"inline": field.get("inline", True),
|
337
|
+
}
|
338
|
+
)
|
339
|
+
|
340
|
+
if embed_fields:
|
341
|
+
embed["fields"] = embed_fields[:25] # Discord limit: 25 fields
|
342
|
+
|
343
|
+
# Add footer
|
344
|
+
if footer_text:
|
345
|
+
embed["footer"] = {"text": footer_text}
|
346
|
+
|
347
|
+
# Add timestamp
|
348
|
+
if timestamp:
|
349
|
+
embed["timestamp"] = datetime.now(UTC).isoformat()
|
350
|
+
|
351
|
+
return embed
|
352
|
+
|
353
|
+
def send_alert(
|
354
|
+
self,
|
355
|
+
severity: AlertSeverity,
|
356
|
+
title: str,
|
357
|
+
message: str,
|
358
|
+
context: dict[str, Any],
|
359
|
+
**kwargs,
|
360
|
+
) -> dict[str, Any]:
|
361
|
+
"""Send alert to Discord via webhook.
|
362
|
+
|
363
|
+
Args:
|
364
|
+
severity: Alert severity
|
365
|
+
title: Alert title
|
366
|
+
message: Alert message
|
367
|
+
context: Context data
|
368
|
+
**kwargs: Discord-specific parameters
|
369
|
+
|
370
|
+
Returns:
|
371
|
+
Dictionary with response data
|
372
|
+
|
373
|
+
Raises:
|
374
|
+
NodeExecutionError: If webhook request fails after retries
|
375
|
+
"""
|
376
|
+
# Extract Discord parameters
|
377
|
+
webhook_url = self.resolve_webhook_url(kwargs["webhook_url"])
|
378
|
+
username = kwargs.get("username")
|
379
|
+
avatar_url = kwargs.get("avatar_url")
|
380
|
+
use_embed = kwargs.get("embed", True)
|
381
|
+
color = kwargs.get("color")
|
382
|
+
fields = kwargs.get("fields", [])
|
383
|
+
mentions = kwargs.get("mentions", [])
|
384
|
+
thread_id = kwargs.get("thread_id")
|
385
|
+
footer_text = kwargs.get("footer_text")
|
386
|
+
timestamp = kwargs.get("timestamp", True)
|
387
|
+
|
388
|
+
# Build payload
|
389
|
+
payload = {}
|
390
|
+
|
391
|
+
# Add username and avatar if provided
|
392
|
+
if username:
|
393
|
+
payload["username"] = username
|
394
|
+
if avatar_url:
|
395
|
+
payload["avatar_url"] = avatar_url
|
396
|
+
|
397
|
+
# Format mentions
|
398
|
+
mention_str = self.format_mentions(mentions)
|
399
|
+
|
400
|
+
if use_embed:
|
401
|
+
# Rich embed format
|
402
|
+
embed = self.build_embed(
|
403
|
+
severity, title, message, context, color, fields, footer_text, timestamp
|
404
|
+
)
|
405
|
+
payload["embeds"] = [embed]
|
406
|
+
|
407
|
+
# Add mentions as content if present
|
408
|
+
if mention_str:
|
409
|
+
payload["content"] = mention_str.strip()
|
410
|
+
else:
|
411
|
+
# Plain text format
|
412
|
+
content_parts = []
|
413
|
+
if mention_str:
|
414
|
+
content_parts.append(mention_str.strip())
|
415
|
+
|
416
|
+
# Format as bold title with message
|
417
|
+
content_parts.append(f"**{title}**")
|
418
|
+
if message:
|
419
|
+
content_parts.append(message)
|
420
|
+
|
421
|
+
# Add context as formatted text
|
422
|
+
if context:
|
423
|
+
content_parts.append("\n" + self.format_context(context))
|
424
|
+
|
425
|
+
payload["content"] = "\n".join(content_parts)
|
426
|
+
|
427
|
+
# Handle thread posting
|
428
|
+
url = webhook_url
|
429
|
+
if thread_id:
|
430
|
+
url = f"{webhook_url}?thread_id={thread_id}"
|
431
|
+
|
432
|
+
# Apply rate limiting
|
433
|
+
wait_time = self.rate_limiter.acquire()
|
434
|
+
|
435
|
+
# Send with retry logic
|
436
|
+
max_retries = 3
|
437
|
+
retry_delay = 1.0
|
438
|
+
|
439
|
+
for attempt in range(max_retries):
|
440
|
+
try:
|
441
|
+
response = self.http_client.run(
|
442
|
+
url=url,
|
443
|
+
method="POST",
|
444
|
+
json_data=payload,
|
445
|
+
headers={"Content-Type": "application/json"},
|
446
|
+
timeout=30,
|
447
|
+
)
|
448
|
+
|
449
|
+
# Check response
|
450
|
+
if response["status_code"] == 204:
|
451
|
+
# Success
|
452
|
+
return {
|
453
|
+
"success": True,
|
454
|
+
"status_code": response["status_code"],
|
455
|
+
"wait_time": wait_time,
|
456
|
+
"attempt": attempt + 1,
|
457
|
+
"webhook_url": webhook_url.split("?")[
|
458
|
+
0
|
459
|
+
], # Remove token for security
|
460
|
+
"thread_id": thread_id,
|
461
|
+
}
|
462
|
+
elif response["status_code"] == 429:
|
463
|
+
# Rate limited - wait and retry
|
464
|
+
retry_after = float(
|
465
|
+
response.get("headers", {}).get(
|
466
|
+
"X-RateLimit-Reset-After", retry_delay
|
467
|
+
)
|
468
|
+
)
|
469
|
+
time.sleep(retry_after)
|
470
|
+
retry_delay *= 2
|
471
|
+
else:
|
472
|
+
# Other error
|
473
|
+
error_msg = (
|
474
|
+
f"Discord webhook returned status {response['status_code']}"
|
475
|
+
)
|
476
|
+
if response.get("content"):
|
477
|
+
error_msg += f": {response['content']}"
|
478
|
+
|
479
|
+
if attempt < max_retries - 1:
|
480
|
+
time.sleep(retry_delay)
|
481
|
+
retry_delay *= 2
|
482
|
+
continue
|
483
|
+
else:
|
484
|
+
raise NodeExecutionError(error_msg)
|
485
|
+
|
486
|
+
except Exception as e:
|
487
|
+
if attempt < max_retries - 1:
|
488
|
+
self.logger.warning(
|
489
|
+
f"Discord webhook attempt {attempt + 1} failed: {e}"
|
490
|
+
)
|
491
|
+
time.sleep(retry_delay)
|
492
|
+
retry_delay *= 2
|
493
|
+
else:
|
494
|
+
raise NodeExecutionError(
|
495
|
+
f"Failed to send Discord alert after {max_retries} attempts: {e}"
|
496
|
+
) from e
|
497
|
+
|
498
|
+
# Should not reach here
|
499
|
+
raise NodeExecutionError("Failed to send Discord alert: Max retries exceeded")
|
kailash/nodes/data/streaming.py
CHANGED
@@ -160,8 +160,8 @@ class KafkaConsumerNode(Node):
|
|
160
160
|
self._consumer = None
|
161
161
|
self._topic = None
|
162
162
|
|
163
|
-
# Call parent constructor
|
164
|
-
super().__init__(name=self.name)
|
163
|
+
# Call parent constructor with all kwargs
|
164
|
+
super().__init__(name=self.name, **kwargs)
|
165
165
|
|
166
166
|
def get_parameters(self) -> dict[str, NodeParameter]:
|
167
167
|
"""Define parameters for the Kafka consumer node."""
|
@@ -447,8 +447,8 @@ class StreamPublisherNode(Node):
|
|
447
447
|
self._publisher = None
|
448
448
|
self._protocol = None
|
449
449
|
|
450
|
-
# Call parent constructor
|
451
|
-
super().__init__(name=self.name)
|
450
|
+
# Call parent constructor with all kwargs
|
451
|
+
super().__init__(name=self.name, **kwargs)
|
452
452
|
|
453
453
|
def get_parameters(self) -> dict[str, NodeParameter]:
|
454
454
|
"""Define parameters for the stream publisher node."""
|
@@ -725,8 +725,8 @@ class WebSocketNode(Node):
|
|
725
725
|
self._connected = False
|
726
726
|
self._message_queue = []
|
727
727
|
|
728
|
-
# Call parent constructor
|
729
|
-
super().__init__(name=self.name)
|
728
|
+
# Call parent constructor with all kwargs
|
729
|
+
super().__init__(name=self.name, **kwargs)
|
730
730
|
|
731
731
|
def get_parameters(self) -> dict[str, NodeParameter]:
|
732
732
|
"""Get the parameters for this node.
|
@@ -1051,8 +1051,8 @@ class EventStreamNode(Node):
|
|
1051
1051
|
self._connected = False
|
1052
1052
|
self._last_event_id = None
|
1053
1053
|
|
1054
|
-
# Call parent constructor
|
1055
|
-
super().__init__(name=self.name)
|
1054
|
+
# Call parent constructor with all kwargs
|
1055
|
+
super().__init__(name=self.name, **kwargs)
|
1056
1056
|
|
1057
1057
|
def get_parameters(self) -> dict[str, NodeParameter]:
|
1058
1058
|
"""Get the parameters for this node.
|
@@ -33,59 +33,71 @@ class AuditLogNode(Node):
|
|
33
33
|
self.logger.setLevel(level)
|
34
34
|
|
35
35
|
def get_parameters(self) -> Dict[str, NodeParameter]:
|
36
|
+
"""Define parameters for audit logging."""
|
36
37
|
return {
|
37
|
-
"
|
38
|
-
name="
|
38
|
+
"event_data": NodeParameter(
|
39
|
+
name="event_data",
|
40
|
+
type=dict,
|
41
|
+
description="Event data to log",
|
42
|
+
default=None,
|
43
|
+
),
|
44
|
+
"event_type": NodeParameter(
|
45
|
+
name="event_type",
|
39
46
|
type=str,
|
40
|
-
|
41
|
-
|
47
|
+
description="Type of event being logged",
|
48
|
+
default="info",
|
42
49
|
),
|
43
50
|
"user_id": NodeParameter(
|
44
51
|
name="user_id",
|
45
52
|
type=str,
|
46
|
-
|
47
|
-
|
53
|
+
description="ID of user associated with event",
|
54
|
+
default=None,
|
48
55
|
),
|
49
|
-
"
|
50
|
-
name="
|
51
|
-
type=
|
52
|
-
|
53
|
-
|
56
|
+
"message": NodeParameter(
|
57
|
+
name="message",
|
58
|
+
type=str,
|
59
|
+
description="Log message",
|
60
|
+
default="",
|
54
61
|
),
|
55
62
|
}
|
56
63
|
|
57
|
-
def
|
58
|
-
"""
|
64
|
+
def execute(self, **inputs) -> Dict[str, Any]:
|
65
|
+
"""Execute audit logging."""
|
66
|
+
event_data = inputs.get("event_data", {})
|
67
|
+
event_type = inputs.get("event_type", "info")
|
68
|
+
user_id = inputs.get("user_id")
|
69
|
+
message = inputs.get("message", "")
|
59
70
|
|
71
|
+
# Create audit entry
|
60
72
|
audit_entry = {
|
61
|
-
"
|
62
|
-
"
|
63
|
-
"
|
73
|
+
"event_type": event_type,
|
74
|
+
"message": message,
|
75
|
+
"user_id": user_id,
|
76
|
+
"data": event_data,
|
64
77
|
}
|
65
78
|
|
66
79
|
if self.include_timestamp:
|
67
80
|
audit_entry["timestamp"] = datetime.now(timezone.utc).isoformat()
|
68
81
|
|
69
|
-
# Log the
|
82
|
+
# Log the event
|
70
83
|
if self.output_format == "json":
|
71
|
-
|
84
|
+
log_message = json.dumps(audit_entry)
|
72
85
|
else:
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
def execute(self, **kwargs) -> Dict[str, Any]:
|
86
|
-
"""Alias for process method."""
|
87
|
-
return self.process(kwargs)
|
86
|
+
log_message = (
|
87
|
+
f"[{event_type}] {message} - User: {user_id} - Data: {event_data}"
|
88
|
+
)
|
89
|
+
|
90
|
+
# Use appropriate log level
|
91
|
+
if event_type in ["error", "critical"]:
|
92
|
+
self.logger.error(log_message)
|
93
|
+
elif event_type == "warning":
|
94
|
+
self.logger.warning(log_message)
|
95
|
+
else:
|
96
|
+
self.logger.info(log_message)
|
88
97
|
|
89
|
-
|
90
|
-
|
91
|
-
|
98
|
+
return {
|
99
|
+
"audit_entry": audit_entry,
|
100
|
+
"logged": True,
|
101
|
+
"log_level": event_type,
|
102
|
+
"timestamp": audit_entry.get("timestamp"),
|
103
|
+
}
|